9 Commits

Author SHA1 Message Date
prosolis
46db0a3e16 Checkpoint: auto-retry after a failed pass; release rate-limit slot on failure
A paste fires exactly one grammar checkpoint, and a failed one never retried
until the next keystroke — stranding the writer on "Petal's helper is resting"
after a paste. Long docs make it worse: their 15-25s checks have a wide window
to catch a transient 502 from the shared Ollama (co-tenant apps load other
models and evict the 9B). A failed pass also burned the per-document rate-limit
slot, so a retry within 30s hit the throttle path and got an empty set back.

- llm.RateLimiter.Release rolls back a slot when its pass fails; Allow now
  returns the recorded timestamp so Release only frees its own slot.
- suggestions.runPass releases the slot on LLM failure before returning 502.
- useCheckpoint auto-retries a failed checkpoint with backoff (3/12/35s),
  keeping the breathing dot up and only flagging "resting" once retries exhaust.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 13:09:12 -07:00
prosolis
1bd38b20e8 Merge feat/suggestion-rail: right-margin comment rail + Mandarin explanations
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 12:23:22 -07:00
prosolis
82e2bcc777 Suggestions: right-margin comment rail + Mandarin explanations
Surface every outstanding suggestion as a card in the right-hand
whitespace, vertically aligned to the text it flags — so the writer sees
the whole queue at once instead of hovering each highlight. Cards stack
with collision avoidance, link both ways with their highlight (hover/click
↔ soft text wash, driven through the decoration plugin so it survives
edit repaints), and carry the same Accept / Dismiss / Ask Petal actions.
The rail is a progressive enhancement: it mounts only when there's room
beside the editor, otherwise the existing inline hover card is unchanged.
Stacked cards that reach the bottom-right corner tuck behind the
companion mascot (z-order).

When a card is expanded, the Ask Petal bubble now opens with the
Simplified-Chinese translation of the explanation (the English stays in
the card body) instead of repeating the same text twice — a new
POST /api/suggestions/{id}/translate one-shot LLM endpoint, loaded
lazily on open with an English fallback.

Verified live against the local LLM via the uitest harness: rail
stacking, hover↔text wash, expand/Ask Petal, accept-from-rail, narrow
fallback, and the Mandarin bubble.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 12:23:02 -07:00
prosolis
035dcf5d25 Merge feat/tts-piper: natural read-aloud via local Piper TTS (EN+ZH)
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 11:35:07 -07:00
prosolis
5120b1e5a2 Read-aloud: natural neural voice via local Piper TTS
Replace the browser's robotic Web Speech API (espeak on Chromium) as the
primary read-aloud path with server-side Piper neural TTS, served by petal
and kept fully offline on millenia next to Ollama.

- internal/tts: proxy short passages to Piper, transcode WAV -> mp3/opus via
  ffmpeg, content-addressed disk cache (instant re-taps). Each Piper instance
  loads one voice, so language routes to its own endpoint:{voice}. Unknown
  language -> 404 so the client falls back to Web Speech. UTF-8-safe truncation
  for multibyte (Chinese) text.
- config: TTS_ENDPOINT / TTS_ENDPOINT_ZH / TTS_VOICE_EN / TTS_VOICE_ZH /
  TTS_CACHE_DIR / TTS_TIMEOUT / TTS_AUDIO_FORMAT. Route mounts only when
  TTS_ENDPOINT is set; otherwise unchanged behavior.
- web/audio/speech.ts: speak() hits /api/tts first, falls back to Web Speech on
  any failure; rapid-tap-safe via a request token. Call sites unchanged.
- deploy/: Piper user systemd units (EN :5005, ZH :5006), setup script, README.

English (en_US-amy-medium) and Chinese (zh_CN-huayan-medium) are both live.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 11:34:52 -07:00
prosolis
adc0cdff1c Add butterfly/parrot/wiggle-dog companions; scale mascot + tips
- Three new Lottie companions: Wiggle Dog (摇尾狗), Butterfly (蝴蝶),
  Parrot (鹦鹉). Parrot is mirrored via a new `flip` flag on Companion,
  threaded through LottiePlayer (scaleX(-1)) since the asset faces left.
- Mascot size now scales with the viewport: --petal-companion-size
  clamp(10rem, 17vw, 20rem); art, emoji fallback, and the sleep "z" all
  derive from it.
- Larger, more legible tip bubble (1.4rem zh / 1.15rem en, wider bubble).
- Bubbles linger longer for a bilingual ESL read: baseline 9s→14s,
  cheer 6s→9s, per-char 45→55ms, cap 20s→32s.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 11:08:55 -07:00
prosolis
9d6698dcc6 Merge fix/suggestion-renag-anchoring: stop re-nagging resolved suggestions + typographic anchoring
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 10:40:30 -07:00
prosolis
6783ce7a51 Suggestions: stop re-nagging resolved edits + fix typographic anchoring
Two fixes for the "accept Petal's change, then it nags about the same
sentence moments later" report:

- replacePending now suppresses any freshly-generated suggestion whose
  original->replacement matches one the user already accepted or
  dismissed for that doc. The model has no memory between passes, so
  without this it re-proposes the identical edit on the next checkpoint.

- findRange anchored suggestions by exact string match, which missed
  whenever the model echoed an `original` with plain ASCII (straight
  quotes, --, ...) while the document held the Typography-converted
  glyphs (curly quotes, em-dash, single-char ellipsis). The miss meant
  no highlight AND a silent no-op on accept, which then fed the re-nag
  above. foldTypography canonicalizes those variants (with a source
  index map for length changes) so matching survives the mismatch.

Covered by a server-side regression test (accept+dismiss then re-check
returns nothing) and frontend unit tests for the fold and anchoring.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 10:35:57 -07:00
prosolis
3ac6382696 Merge feat/writer-power-ups: editor power-ups (find/replace, read-aloud, backup, phonetic)
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 09:47:50 -07:00
34 changed files with 1713 additions and 87 deletions

View File

@@ -17,6 +17,20 @@ LLM_MODEL= # checkpoint model (small/fast). Never hardcod
LLM_CHAT_MODEL= # Ask Petal model (Mandarin-native). Falls back to LLM_MODEL if empty. LLM_CHAT_MODEL= # Ask Petal model (Mandarin-native). Falls back to LLM_MODEL if empty.
LLM_TIMEOUT=30s LLM_TIMEOUT=30s
# Read-aloud (TTS). Off unless TTS_ENDPOINT is set — when empty, the /api/tts route
# is not mounted and the frontend falls back to the browser's Web Speech API.
# Backed by a local Piper HTTP server (python3 -m piper.http_server).
# Each Piper HTTP server loads ONE voice, so English and Chinese need separate
# instances (different ports). zh is only routed when both TTS_ENDPOINT_ZH and
# TTS_VOICE_ZH are set; otherwise Chinese falls back to Web Speech.
TTS_ENDPOINT= # e.g. http://127.0.0.1:5005 — empty disables server TTS
TTS_ENDPOINT_ZH= # e.g. http://127.0.0.1:5006 — Chinese Piper instance
TTS_VOICE_EN=en_US-amy-medium # Piper voice id for English
TTS_VOICE_ZH=zh_CN-huayan-medium # Piper voice id for Chinese
TTS_CACHE_DIR=./data/tts # on-disk store for synthesized clips (content-addressed)
TTS_TIMEOUT=15s
TTS_AUDIO_FORMAT=mp3 # mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg
# --- Deferred (not wired in the local-dev build) --- # --- Deferred (not wired in the local-dev build) ---
# Auth (Authentik OIDC) — deferred; single hardcoded local user for now # Auth (Authentik OIDC) — deferred; single hardcoded local user for now

View File

@@ -19,6 +19,7 @@ import (
"gitea.parodia.dev/drwily/petal/internal/lexicon" "gitea.parodia.dev/drwily/petal/internal/lexicon"
"gitea.parodia.dev/drwily/petal/internal/llm" "gitea.parodia.dev/drwily/petal/internal/llm"
"gitea.parodia.dev/drwily/petal/internal/suggestions" "gitea.parodia.dev/drwily/petal/internal/suggestions"
"gitea.parodia.dev/drwily/petal/internal/tts"
"gitea.parodia.dev/drwily/petal/web" "gitea.parodia.dev/drwily/petal/web"
) )
@@ -88,6 +89,14 @@ func main() {
log.Fatalf("image store: %v", err) log.Fatalf("image store: %v", err)
} }
api.Mount("/images", imgHandler.Routes()) api.Mount("/images", imgHandler.Routes())
// Read-aloud: proxy short passages to a local Piper TTS server. Only
// mounted when TTS_ENDPOINT is configured; otherwise the frontend falls
// back to the browser's Web Speech API on its own.
if ttsHandler, ok := tts.New(cfg); ok {
api.Mount("/tts", ttsHandler.Routes())
log.Printf("read-aloud enabled (TTS endpoint=%s)", cfg.TTSEndpoint)
}
}) })
// 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).

65
deploy/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Deploying read-aloud (Piper TTS) to millenia
Petal's read-aloud generates audio with a local **Piper** neural-TTS server and
transcodes it to mp3 with **ffmpeg**. Both run on millenia (192.168.1.212);
nothing leaves the box. If Piper is down or `TTS_ENDPOINT` is unset, the frontend
falls back to the browser's Web Speech API automatically.
## 1. Piper as a systemd service (one-time)
```bash
# from this repo, on your workstation:
scp deploy/piper.service deploy/setup-piper.sh 192.168.1.212:/tmp/
ssh 192.168.1.212 'cd /tmp && sudo ./setup-piper.sh'
```
`setup-piper.sh` creates `~/piper/venv`, installs `piper-tts[http]`, downloads the
`en_US-amy-medium` voice into `~/piper/voices`, installs+enables `piper.service`
(loopback :5005), and smoke-tests it. Idempotent.
Check it any time: `systemctl status piper`, `journalctl -u piper -f`.
## 2. Point petal at Piper + redeploy the binary
Add to petal's environment (its `.env` or launch env):
```
TTS_ENDPOINT=http://127.0.0.1:5005
TTS_VOICE_EN=en_US-amy-medium
TTS_AUDIO_FORMAT=mp3
```
Then ship the rebuilt binary (`go build -o petal ./cmd/server` already done) and
restart the petal `:8088` session. Confirm the log line:
`read-aloud enabled (TTS endpoint=http://127.0.0.1:5005)`.
## 3. Verify
```bash
# on millenia — end-to-end through petal, including ffmpeg transcode:
curl -sf -X POST localhost:8088/api/tts \
-H 'Content-Type: application/json' \
-d '{"text":"hello there","lang":"en-US"}' -o /tmp/petal-tts.mp3 \
&& file /tmp/petal-tts.mp3 # expect: Audio file ... MPEG ... layer III
```
- Second identical call is served from the cache (`~/petal/.../data/tts/*.mp3`).
- A language with no configured instance returns 404 → client uses Web Speech.
- Browser check via the uitest harness (`~/petal/uitest`): tap a word in the
WordCard / select a sentence and hit speak — expect the natural Piper voice.
## Chinese voice (live)
Each Piper HTTP server loads ONE model, so Chinese runs as a **second instance**:
`piper-zh.service` on :5006 with `zh_CN-huayan-medium`. Deployed via:
```bash
scp deploy/piper-zh.service 192.168.1.212:~/.config/systemd/user/
ssh 192.168.1.212 'export XDG_RUNTIME_DIR=/run/user/$(id -u)
~/piper/venv/bin/python -m piper.download_voices zh_CN-huayan-medium --data-dir ~/piper/voices
systemctl --user daemon-reload && systemctl --user enable --now piper-zh.service'
```
Then in petal's `start.sh`: `TTS_ENDPOINT_ZH=http://127.0.0.1:5006` and
`TTS_VOICE_ZH=zh_CN-huayan-medium`. The handler maps language → instance from config,
so adding more languages is just another instance + env pair (no code change).

17
deploy/piper-zh.service Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=Piper TTS HTTP server — Chinese voice (read-aloud backend for petal)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Bound to loopback only — petal proxies to it; never exposed off-box.
ExecStart=%h/piper/venv/bin/python -m piper.http_server \
-m zh_CN-huayan-medium \
--data-dir %h/piper/voices \
--host 127.0.0.1 --port 5006
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target

17
deploy/piper.service Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=Piper TTS HTTP server (read-aloud backend for petal)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Bound to loopback only — petal proxies to it; never exposed off-box.
ExecStart=%h/piper/venv/bin/python -m piper.http_server \
-m en_US-amy-medium \
--data-dir %h/piper/voices \
--host 127.0.0.1 --port 5005
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target

43
deploy/setup-piper.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# One-time Piper TTS setup on millenia as a *user* systemd service (no sudo).
# Idempotent — safe to re-run. Run on the box:
# bash setup-piper.sh
# To survive logout/reboot, also (once, needs root):
# sudo loginctl enable-linger "$USER"
set -euo pipefail
PIPER_HOME="$HOME/piper"
VENV="$PIPER_HOME/venv"
VOICES="$PIPER_HOME/voices"
VOICE=en_US-amy-medium
UNIT_DIR="$HOME/.config/systemd/user"
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
echo ">> ffmpeg check (petal transcodes Piper WAV -> mp3 with it):"
command -v ffmpeg >/dev/null || { echo "ffmpeg not found on PATH"; exit 1; }
echo ">> venv + piper-tts[http]"
[ -d "$VENV" ] || python3 -m venv "$VENV"
"$VENV/bin/pip" install --upgrade pip >/dev/null
"$VENV/bin/pip" install "piper-tts[http]"
echo ">> download voice $VOICE"
mkdir -p "$VOICES"
"$VENV/bin/python" -m piper.download_voices "$VOICE" --data-dir "$VOICES"
echo ">> install user service"
mkdir -p "$UNIT_DIR"
install -m 0644 "$(dirname "$0")/piper.service" "$UNIT_DIR/piper.service"
systemctl --user daemon-reload
systemctl --user enable --now piper.service
echo ">> wait + status"
sleep 2
systemctl --user --no-pager --full status piper.service | head -n 6 || true
echo ">> smoke test"
curl -sf -X POST localhost:5005/ \
-H 'Content-Type: application/json' \
-d "{\"text\":\"hello there\",\"voice\":\"$VOICE\"}" -o /tmp/piper-test.wav \
&& echo "OK: /tmp/piper-test.wav ($(stat -c%s /tmp/piper-test.wav) bytes)" \
|| { echo "smoke test FAILED"; journalctl --user -u piper.service --no-pager | tail -n 20; exit 1; }

View File

@@ -20,6 +20,17 @@ type Config struct {
LLMChatModel string // Ask Petal model; falls back to LLMModel if empty LLMChatModel string // Ask Petal model; falls back to LLMModel if empty
LLMTimeout time.Duration LLMTimeout time.Duration
// TTS (read-aloud). Off unless TTSEndpoint is set — when empty, the /api/tts
// route isn't mounted and the frontend falls back to the browser's Web Speech
// API. Endpoint points at a local Piper HTTP server.
TTSEndpoint string // Piper instance serving the English voice
TTSEndpointZH string // Piper instance serving the Chinese voice; empty = zh falls back to Web Speech
TTSVoiceEN string // Piper voice id for English (e.g. en_US-amy-medium)
TTSVoiceZH string // Piper voice id for Chinese (e.g. zh_CN-huayan-medium)
TTSCacheDir string // on-disk store for synthesized clips (content-addressed)
TTSTimeout time.Duration
TTSFormat string // mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg
// Auth (deferred — not wired in the local-dev build, kept for later) // Auth (deferred — not wired in the local-dev build, kept for later)
AuthentikURL string AuthentikURL string
AuthentikClientID string AuthentikClientID string
@@ -41,6 +52,14 @@ func Load() *Config {
LLMChatModel: env("LLM_CHAT_MODEL", ""), LLMChatModel: env("LLM_CHAT_MODEL", ""),
LLMTimeout: envDuration("LLM_TIMEOUT", 30*time.Second), LLMTimeout: envDuration("LLM_TIMEOUT", 30*time.Second),
TTSEndpoint: env("TTS_ENDPOINT", ""),
TTSEndpointZH: env("TTS_ENDPOINT_ZH", ""),
TTSVoiceEN: env("TTS_VOICE_EN", "en_US-amy-medium"),
TTSVoiceZH: env("TTS_VOICE_ZH", "zh_CN-huayan-medium"),
TTSCacheDir: env("TTS_CACHE_DIR", "./data/tts"),
TTSTimeout: envDuration("TTS_TIMEOUT", 15*time.Second),
TTSFormat: env("TTS_AUDIO_FORMAT", "mp3"),
AuthentikURL: env("AUTHENTIK_URL", ""), AuthentikURL: env("AUTHENTIK_URL", ""),
AuthentikClientID: env("AUTHENTIK_CLIENT_ID", ""), AuthentikClientID: env("AUTHENTIK_CLIENT_ID", ""),
AuthentikClientSecret: env("AUTHENTIK_CLIENT_SECRET", ""), AuthentikClientSecret: env("AUTHENTIK_CLIENT_SECRET", ""),

View File

@@ -127,17 +127,33 @@ func NewRateLimiter(interval time.Duration) *RateLimiter {
} }
// Allow reports whether a checkpoint may run for docID now. When allowed it // Allow reports whether a checkpoint may run for docID now. When allowed it
// records the time and returns (true, 0); when throttled it returns // records the time and returns (true, 0, recordedAt); when throttled it returns
// (false, retryAfter) where retryAfter is the wait until the next allowed run. // (false, retryAfter, zero) where retryAfter is the wait until the next allowed
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration) { // run. recordedAt lets a caller whose pass fails hand the exact timestamp to
// Release so it rolls back only its own slot.
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration, time.Time) {
rl.mu.Lock() rl.mu.Lock()
defer rl.mu.Unlock() defer rl.mu.Unlock()
now := time.Now() now := time.Now()
if last, ok := rl.last[docID]; ok { if last, ok := rl.last[docID]; ok {
if elapsed := now.Sub(last); elapsed < rl.interval { if elapsed := now.Sub(last); elapsed < rl.interval {
return false, rl.interval - elapsed return false, rl.interval - elapsed, time.Time{}
} }
} }
rl.last[docID] = now rl.last[docID] = now
return true, 0 return true, 0, now
}
// Release rolls back the slot a prior Allow recorded for docID, but only if no
// newer Allow has since claimed it. Called when an LLM pass fails: Allow runs
// before the model call, so without this a failed checkpoint would hold the
// per-document slot for the full interval and a retry (or the frontend's
// auto-retry) would hit the throttle path and get the stale/empty set back
// instead of re-running. `at` is the time the failed Allow returned.
func (rl *RateLimiter) Release(docID string, at time.Time) {
rl.mu.Lock()
defer rl.mu.Unlock()
if last, ok := rl.last[docID]; ok && last.Equal(at) {
delete(rl.last, docID)
}
} }

View File

@@ -65,16 +65,31 @@ func TestParseCheckpoint(t *testing.T) {
func TestRateLimiter(t *testing.T) { func TestRateLimiter(t *testing.T) {
rl := NewRateLimiter(30 * time.Second) rl := NewRateLimiter(30 * time.Second)
if ok, _ := rl.Allow("doc1"); !ok { ok, _, at := rl.Allow("doc1")
if !ok {
t.Fatal("first call should be allowed") t.Fatal("first call should be allowed")
} }
if ok, retry := rl.Allow("doc1"); ok || retry <= 0 { if ok, retry, _ := rl.Allow("doc1"); ok || retry <= 0 {
t.Fatalf("immediate second call should be throttled, got ok=%v retry=%v", ok, retry) t.Fatalf("immediate second call should be throttled, got ok=%v retry=%v", ok, retry)
} }
// A different document is independent. // A different document is independent.
if ok, _ := rl.Allow("doc2"); !ok { if ok, _, _ := rl.Allow("doc2"); !ok {
t.Fatal("different doc should be allowed") t.Fatal("different doc should be allowed")
} }
// Releasing doc1's slot (as a failed pass does) lets the next check re-run
// immediately instead of waiting out the interval.
rl.Release("doc1", at)
if ok, _, _ := rl.Allow("doc1"); !ok {
t.Fatal("after Release the next call should be allowed again")
}
// Release only rolls back its own slot: a stale timestamp must not evict the
// slot a newer Allow holds (so a late failure can't cancel a fresh in-flight
// check). doc2 still holds its slot from above; a zero-time Release is a no-op.
rl.Release("doc2", time.Time{})
if ok, _, _ := rl.Allow("doc2"); ok {
t.Fatal("stale Release must not free doc2's current slot")
}
} }
func TestTruncateDoc(t *testing.T) { func TestTruncateDoc(t *testing.T) {

View File

@@ -165,3 +165,24 @@ func RewriteMessages(text, style string) []Message {
{Role: "user", Content: text}, {Role: "user", Content: text},
} }
} }
// translateSystemPrompt drives the explanation translator: it renders a
// suggestion's English explanation into Simplified Chinese so an ESL reader sees
// the "why" in her first language. Strict about returning ONLY the translation
// (no quotes, no pinyin, no English echo) so it can drop straight into the chat
// bubble. Kept warm and plain — these are short, friendly one-liners.
const translateSystemPrompt = `You are Petal, a warm writing assistant. Translate the English text the user ` +
`sends into natural, friendly Simplified Chinese (Mandarin). It is a short explanation of a writing ` +
`suggestion, written for a native Chinese speaker learning English.
Respond with ONLY the Simplified Chinese translation. No quotation marks, no pinyin, no English, no preamble — ` +
`just the translated sentence.`
// TranslateMessages builds the message array for translating one short English
// explanation into Simplified Chinese.
func TranslateMessages(text string) []Message {
return []Message{
{Role: "system", Content: translateSystemPrompt},
{Role: "user", Content: text},
}
}

23
internal/llm/translate.go Normal file
View File

@@ -0,0 +1,23 @@
package llm
import (
"context"
)
// RunTranslate renders a short English explanation into Simplified Chinese. It
// is a one-shot Complete (the result seeds the Ask Petal bubble), kept at a low
// temperature so the translation is faithful rather than creative. Output is
// trimmed of any stray surrounding quotes the model may add.
func RunTranslate(ctx context.Context, client LLMClient, text string) (string, error) {
out, err := client.Complete(ctx, CompletionRequest{
Messages: TranslateMessages(text),
MaxTokens: 512,
Temperature: 0.2,
TopP: 0.9,
RepetitionPenalty: 1.1,
})
if err != nil {
return "", err
}
return cleanRewrite(out), nil
}

View File

@@ -55,6 +55,7 @@ func (h *Handler) Routes() chi.Router {
r.Post("/{id}/accept", h.accept) r.Post("/{id}/accept", h.accept)
r.Post("/{id}/dismiss", h.dismiss) r.Post("/{id}/dismiss", h.dismiss)
r.Post("/{id}/chat", h.chat) r.Post("/{id}/chat", h.chat)
r.Post("/{id}/translate", h.translate)
return r return r
} }
@@ -101,7 +102,8 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
return return
} }
if ok, _ := limiter.Allow(docID); !ok { ok, _, slotAt := limiter.Allow(docID)
if !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)
@@ -115,6 +117,10 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
raw, err := run(r.Context(), h.Client, contentText, tone) raw, err := run(r.Context(), h.Client, contentText, tone)
if err != nil { if err != nil {
// Allow ran before the model call, so a failed pass would otherwise hold
// the per-document slot for the full interval — stranding the frontend's
// auto-retry on the throttle path. Release it so a retry can re-run.
limiter.Release(docID, slotAt)
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error()) errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
return return
} }
@@ -154,6 +160,14 @@ var (
// replacePending swaps a document's pending suggestions within one family for a // replacePending swaps a document's pending suggestions within one family for a
// fresh batch in a single transaction. Accepted/rejected suggestions and the // fresh batch in a single transaction. Accepted/rejected suggestions and the
// other family's pending rows are left untouched. // other family's pending rows are left untouched.
//
// Suggestions the user already accepted or dismissed are suppressed from the
// fresh batch: the model has no memory between passes, so without this it would
// re-propose the identical edit on the very next checkpoint — re-nagging a
// sentence the user already resolved. This matters most when an accept silently
// no-ops (the `original` text couldn't be anchored in the editor, so the doc
// never changed): the sentence is unaltered, yet the user shouldn't see the same
// card again.
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error { 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 {
@@ -168,7 +182,15 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
return err return err
} }
actioned, err := actionedKeys(tx, docID)
if err != nil {
return err
}
for _, s := range raw { for _, s := range raw {
if _, seen := actioned[suggestionKey(s.Original, s.Replacement)]; seen {
continue
}
typ := scope.forceType typ := scope.forceType
if typ == "" { if typ == "" {
typ = normalizeType(s.Type) typ = normalizeType(s.Type)
@@ -186,6 +208,39 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
return tx.Commit() return tx.Commit()
} }
// actionedKeys returns the set of original→replacement keys the user has already
// accepted or rejected for this document, so a fresh checkpoint can skip
// re-proposing them. Keyed on the trimmed original+replacement pair, matching the
// normalization ParseCheckpoint applies, so a genuinely different edit on the same
// sentence is not suppressed.
func actionedKeys(tx *sql.Tx, docID string) (map[string]struct{}, error) {
rows, err := tx.Query(
`SELECT original, replacement FROM suggestions
WHERE doc_id = ? AND status IN (?, ?)`,
docID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected,
)
if err != nil {
return nil, err
}
defer rows.Close()
keys := make(map[string]struct{})
for rows.Next() {
var original, replacement string
if err := rows.Scan(&original, &replacement); err != nil {
return nil, err
}
keys[suggestionKey(original, replacement)] = struct{}{}
}
return keys, rows.Err()
}
// suggestionKey is the dedup identity for a suggestion: its trimmed original and
// replacement text. Two suggestions with the same key are "the same edit."
func suggestionKey(original, replacement string) string {
return strings.TrimSpace(original) + "\x00" + strings.TrimSpace(replacement)
}
// listForDoc returns the document's current pending suggestions (used when the // listForDoc returns the document's current pending suggestions (used when the
// editor loads a document, before any new checkpoint fires). // editor loads a document, before any new checkpoint fires).
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) { func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {

View File

@@ -127,6 +127,44 @@ func TestCheckpointFlow(t *testing.T) {
} }
} }
// TestResolvedSuggestionsNotReproposed proves a checkpoint won't re-nag a
// sentence the user already accepted or dismissed: the model returns the same
// raw batch on the next pass (it has no memory), but the resolved edits are
// suppressed. This is the "accept it, then it nags again moments later" bug.
func TestResolvedSuggestionsNotReproposed(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, h := newTestServer(t, client)
// Zero the checkpoint floor so the second pass runs instead of being throttled.
h.Limit = llm.NewRateLimiter(0)
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
var got []db.Suggestion
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if len(got) != 2 {
t.Fatalf("first pass: want 2, got %d", len(got))
}
// Accept one, dismiss the other.
do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", "")
do(t, srv, http.MethodPost, "/suggestions/"+got[1].ID+"/dismiss", "")
// Second checkpoint returns the identical raw batch — both must be suppressed.
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
if rec.Code != http.StatusOK {
t.Fatalf("second check: code=%d body=%s", rec.Code, rec.Body)
}
var again []db.Suggestion
if err := json.Unmarshal(rec.Body.Bytes(), &again); err != nil {
t.Fatalf("decode: %v", err)
}
if len(again) != 0 {
t.Fatalf("resolved suggestions should not be re-proposed, got %d: %+v", len(again), again)
}
}
// TestVoicePassCoexists proves the voice pass and the grammar checkpoint own // TestVoicePassCoexists proves the voice pass and the grammar checkpoint own
// disjoint pending families: running one never wipes the other's flags, and // disjoint pending families: running one never wipes the other's flags, and
// each endpoint returns the unified pending set. // each endpoint returns the unified pending set.

View File

@@ -0,0 +1,57 @@
package suggestions
import (
"database/sql"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
type translateResponse struct {
Translation string `json:"translation"`
}
// translate renders a suggestion's English explanation into Simplified Chinese
// for the Ask Petal bubble, so the ESL reader sees the "why" in her first
// language instead of a second copy of the same English text. The explanation is
// loaded server-side from the suggestion id (scoped to the local user) and never
// trusted from the client, mirroring chat (spec Note #10).
func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
sugID := chi.URLParam(r, "id")
var explanation string
err := h.DB.QueryRow(
`SELECT s.explanation
FROM suggestions s
JOIN documents d ON d.id = s.doc_id
WHERE s.id = ? AND d.user_id = ?`,
sugID, db.LocalUserID,
).Scan(&explanation)
if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "suggestion not found")
return
}
if err != nil {
serverError(w, err)
return
}
explanation = strings.TrimSpace(explanation)
if explanation == "" {
writeJSON(w, http.StatusOK, translateResponse{Translation: ""})
return
}
out, err := llm.RunTranslate(r.Context(), h.Client, explanation)
if err != nil {
errorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
return
}
writeJSON(w, http.StatusOK, translateResponse{Translation: out})
}

264
internal/tts/handler.go Normal file
View File

@@ -0,0 +1,264 @@
// Package tts implements read-aloud: it proxies short passages to a local Piper
// HTTP server (a fast, offline neural TTS), optionally transcodes Piper's WAV to
// mp3/opus with ffmpeg, and serves the audio back to the editor. Synthesized
// clips are content-addressed on disk so tapping the same word twice is instant
// and never re-synthesizes. The feature is entirely optional: when TTS_ENDPOINT
// is unset the route isn't mounted and the frontend falls back to the browser's
// Web Speech API.
package tts
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/config"
)
// maxTextBytes bounds a single synthesis request. Read-aloud is for a word or a
// sentence or two, not whole documents; this keeps one tap from pinning Piper.
const maxTextBytes = 4000
// lengthScale slows Piper slightly below natural pace — a learner following along
// with the words, mirroring the old utterance.rate = 0.95. Higher = slower.
const lengthScale = 1.1
// audioFormat describes one output encoding: the cache-file extension, the
// response Content-Type, and the ffmpeg args that turn Piper's WAV (on stdin)
// into this format (on stdout). A nil ffmpegArgs means "serve the WAV as-is".
type audioFormat struct {
ext string
contentType string
ffmpegArgs []string
}
// formats maps the configured TTS_AUDIO_FORMAT to its encoding. mp3 is the safe
// default (every browser plays it); opus is ~half the size for speech but has
// patchier Safari support; wav skips ffmpeg entirely.
var formats = map[string]audioFormat{
"wav": {ext: ".wav", contentType: "audio/wav"},
"mp3": {ext: ".mp3", contentType: "audio/mpeg",
ffmpegArgs: []string{"-f", "mp3", "-c:a", "libmp3lame", "-b:a", "64k", "-ac", "1"}},
"opus": {ext: ".opus", contentType: "audio/ogg",
ffmpegArgs: []string{"-f", "ogg", "-c:a", "libopus", "-b:a", "32k", "-ac", "1"}},
}
// route is the Piper instance and voice id serving one language. Each Piper
// HTTP server loads exactly one model, so distinct languages mean distinct
// endpoints (e.g. English on :5005, Chinese on :5006).
type route struct {
endpoint string // Piper HTTP base URL (no trailing slash)
voice string // Piper voice id (sent in the request; also part of the cache key)
}
// Handler proxies synthesis to Piper and caches the result on disk.
type Handler struct {
routes map[string]route // base language (e.g. "en", "zh") -> Piper instance
cacheDir string
format audioFormat
client *http.Client
}
// New builds a Handler from config. It returns (nil, false) when TTS_ENDPOINT is
// unset, signalling main to skip mounting the route. An unknown TTS_AUDIO_FORMAT
// falls back to mp3 rather than failing the whole server.
func New(cfg *config.Config) (*Handler, bool) {
if strings.TrimSpace(cfg.TTSEndpoint) == "" {
return nil, false
}
format, ok := formats[strings.ToLower(strings.TrimSpace(cfg.TTSFormat))]
if !ok {
format = formats["mp3"]
}
// Map by base language so en-US, en-GB, etc. all resolve to the English
// instance (the client sends BCP-47 tags like the old Web Speech path did).
// A language is only routable when both its endpoint and voice are set;
// otherwise the client falls back to Web Speech for that language.
routes := map[string]route{}
if cfg.TTSVoiceEN != "" {
routes["en"] = route{strings.TrimRight(cfg.TTSEndpoint, "/"), cfg.TTSVoiceEN}
}
if cfg.TTSEndpointZH != "" && cfg.TTSVoiceZH != "" {
routes["zh"] = route{strings.TrimRight(cfg.TTSEndpointZH, "/"), cfg.TTSVoiceZH}
}
if err := os.MkdirAll(cfg.TTSCacheDir, 0o755); err != nil {
// A missing cache dir isn't fatal — synthesis still works, it just won't
// cache. Disable the feature only on endpoint absence, not this.
fmt.Fprintf(os.Stderr, "tts: cache dir %q: %v\n", cfg.TTSCacheDir, err)
}
return &Handler{
routes: routes,
cacheDir: cfg.TTSCacheDir,
format: format,
client: &http.Client{Timeout: cfg.TTSTimeout},
}, true
}
// Routes mounts the synthesis endpoint. Mount under "/tts" so the full path is
// POST /api/tts.
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Post("/", h.synth)
return r
}
// synthRequest is the body the editor posts: a passage and the BCP-47 language
// tag it's written in (e.g. "en-US", "zh-CN").
type synthRequest struct {
Text string `json:"text"`
Lang string `json:"lang"`
}
// synth resolves a voice for the requested language, returns cached audio when
// present, and otherwise asks Piper to synthesize, transcodes if configured,
// caches, and serves. An unconfigured language yields 404 so the client can fall
// back to Web Speech without treating it as an error.
func (h *Handler) synth(w http.ResponseWriter, r *http.Request) {
var req synthRequest
// Cap the raw body generously above maxTextBytes to leave room for JSON
// framing and the lang field; the text itself is truncated after decoding.
if err := json.NewDecoder(io.LimitReader(r.Body, maxTextBytes+1024)).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
text := strings.TrimSpace(req.Text)
if text == "" {
http.Error(w, "empty text", http.StatusBadRequest)
return
}
if len(text) > maxTextBytes {
// Trim back to the last valid rune boundary so we never hand Piper a
// half-encoded multibyte character (common with Chinese, 3 bytes/char).
text = text[:maxTextBytes]
for len(text) > 0 && !utf8.ValidString(text) {
text = text[:len(text)-1]
}
}
rt, ok := h.routes[baseLang(req.Lang)]
if !ok {
// No voice for this language — let the client fall back to Web Speech.
http.Error(w, "no voice for language", http.StatusNotFound)
return
}
// Content-addressed: identical (voice, text) → identical clip. The format
// extension keeps encodings from colliding in the same dir.
sum := sha256.Sum256([]byte(rt.voice + "\n" + text))
name := hex.EncodeToString(sum[:])[:32] + h.format.ext
path := filepath.Join(h.cacheDir, name)
if _, err := os.Stat(path); err == nil {
h.serve(w, r, path)
return
}
audio, err := h.synthesize(r.Context(), rt, text)
if err != nil {
http.Error(w, "synthesis failed", http.StatusBadGateway)
fmt.Fprintf(os.Stderr, "tts: synthesize: %v\n", err)
return
}
// Best-effort cache write via a temp file + rename so a concurrent reader
// never sees a half-written clip. A failed write just means no caching.
if h.cacheDir != "" {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, audio, 0o644); err == nil {
_ = os.Rename(tmp, path)
}
}
w.Header().Set("Content-Type", h.format.contentType)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
_, _ = w.Write(audio)
}
// serve streams a cached clip with a long-lived immutable cache header (the URL
// is content-addressed, so the bytes never change for a given request).
func (h *Handler) serve(w http.ResponseWriter, r *http.Request, path string) {
w.Header().Set("Content-Type", h.format.contentType)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeFile(w, r, path)
}
// synthesize POSTs to the route's Piper instance, then transcodes the returned
// WAV when the configured format calls for it.
func (h *Handler) synthesize(ctx context.Context, rt route, text string) ([]byte, error) {
body, _ := json.Marshal(map[string]any{
"text": text,
"voice": rt.voice,
"length_scale": lengthScale,
})
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rt.endpoint+"/", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := h.client.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return nil, fmt.Errorf("piper %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet)))
}
wav, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if h.format.ffmpegArgs == nil {
return wav, nil
}
return transcode(ctx, wav, h.format.ffmpegArgs)
}
// transcode pipes WAV bytes through ffmpeg (stdin → stdout) into the target
// encoding. ffmpeg is assumed on PATH; an error here surfaces as a 502.
func transcode(ctx context.Context, wav []byte, args []string) ([]byte, error) {
// Guard ffmpeg against a hang independent of the HTTP client timeout.
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
full := append([]string{"-hide_banner", "-loglevel", "error", "-i", "pipe:0"}, args...)
full = append(full, "pipe:1")
cmd := exec.CommandContext(ctx, "ffmpeg", full...)
cmd.Stdin = bytes.NewReader(wav)
var out, errBuf bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg: %v: %s", err, strings.TrimSpace(errBuf.String()))
}
return out.Bytes(), nil
}
// baseLang reduces a BCP-47 tag to its primary subtag, lowercased: "en-US" → "en",
// "zh-CN" → "zh", "" → "". Mirrors the client's pickVoice base-language matching.
func baseLang(tag string) string {
tag = strings.ToLower(strings.TrimSpace(tag))
if i := strings.IndexAny(tag, "-_"); i >= 0 {
return tag[:i]
}
return tag
}

View File

@@ -0,0 +1,170 @@
package tts
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// newStubPiper returns a fake Piper server that echoes a fixed WAV body and
// records how many times it was called and the last request payload.
func newStubPiper(t *testing.T, body []byte) (*httptest.Server, *int32, *synthEcho) {
t.Helper()
var calls int32
last := &synthEcho{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
last.voice, _ = req["voice"].(string)
last.text, _ = req["text"].(string)
w.Header().Set("Content-Type", "audio/wav")
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
return srv, &calls, last
}
type synthEcho struct{ voice, text string }
// newHandler builds a wav-format handler (no ffmpeg) pointed at a stub server.
func newHandler(t *testing.T, endpoint string) *Handler {
t.Helper()
return &Handler{
routes: map[string]route{"en": {strings.TrimRight(endpoint, "/"), "en_US-amy-medium"}},
cacheDir: t.TempDir(),
format: formats["wav"],
client: http.DefaultClient,
}
}
func post(t *testing.T, h *Handler, text, lang string) *httptest.ResponseRecorder {
t.Helper()
b, _ := json.Marshal(synthRequest{Text: text, Lang: lang})
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(b))
rr := httptest.NewRecorder()
h.synth(rr, req)
return rr
}
func TestSynthSuccessAndVoiceSelection(t *testing.T) {
wav := []byte("RIFF....fake-wav")
srv, calls, last := newStubPiper(t, wav)
h := newHandler(t, srv.URL)
rr := post(t, h, "hello there", "en-US")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.Bytes(); !bytes.Equal(got, wav) {
t.Fatalf("body = %q, want the piper wav", got)
}
if ct := rr.Header().Get("Content-Type"); ct != "audio/wav" {
t.Fatalf("content-type = %q, want audio/wav", ct)
}
if last.voice != "en_US-amy-medium" {
t.Fatalf("piper voice = %q, want en_US-amy-medium", last.voice)
}
if *calls != 1 {
t.Fatalf("piper calls = %d, want 1", *calls)
}
}
func TestRoutesByLanguageToSeparateInstances(t *testing.T) {
enSrv, enCalls, _ := newStubPiper(t, []byte("EN-wav"))
zhSrv, zhCalls, zhLast := newStubPiper(t, []byte("ZH-wav"))
h := &Handler{
routes: map[string]route{
"en": {strings.TrimRight(enSrv.URL, "/"), "en_US-amy-medium"},
"zh": {strings.TrimRight(zhSrv.URL, "/"), "zh_CN-huayan-medium"},
},
cacheDir: t.TempDir(),
format: formats["wav"],
client: http.DefaultClient,
}
if rr := post(t, h, "你好世界", "zh-CN"); rr.Code != http.StatusOK {
t.Fatalf("zh status = %d, want 200", rr.Code)
}
if *zhCalls != 1 || *enCalls != 0 {
t.Fatalf("calls en=%d zh=%d, want en=0 zh=1 (zh routed to zh instance)", *enCalls, *zhCalls)
}
if zhLast.voice != "zh_CN-huayan-medium" {
t.Fatalf("zh voice = %q, want zh_CN-huayan-medium", zhLast.voice)
}
}
func TestUnknownLanguageReturns404(t *testing.T) {
srv, calls, _ := newStubPiper(t, []byte("x"))
h := newHandler(t, srv.URL)
rr := post(t, h, "你好", "zh-CN") // only "en" is configured
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
if *calls != 0 {
t.Fatalf("piper calls = %d, want 0 (should not synthesize)", *calls)
}
}
func TestCacheHitSkipsPiper(t *testing.T) {
srv, calls, _ := newStubPiper(t, []byte("RIFF....fake-wav"))
h := newHandler(t, srv.URL)
if rr := post(t, h, "same words", "en-US"); rr.Code != http.StatusOK {
t.Fatalf("first status = %d, want 200", rr.Code)
}
if rr := post(t, h, "same words", "en-US"); rr.Code != http.StatusOK {
t.Fatalf("second status = %d, want 200", rr.Code)
}
if *calls != 1 {
t.Fatalf("piper calls = %d, want 1 (second served from cache)", *calls)
}
}
func TestEmptyTextReturns400(t *testing.T) {
srv, _, _ := newStubPiper(t, []byte("x"))
h := newHandler(t, srv.URL)
if rr := post(t, h, " ", "en-US"); rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
}
func TestTextIsCapped(t *testing.T) {
srv, _, last := newStubPiper(t, []byte("x"))
h := newHandler(t, srv.URL)
long := strings.Repeat("a", maxTextBytes+500)
if rr := post(t, h, long, "en-US"); rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if len(last.text) != maxTextBytes {
t.Fatalf("piper received %d chars, want capped to %d", len(last.text), maxTextBytes)
}
}
func TestBaseLang(t *testing.T) {
cases := map[string]string{"en-US": "en", "EN_gb": "en", "zh-CN": "zh", "en": "en", "": ""}
for in, want := range cases {
if got := baseLang(in); got != want {
t.Errorf("baseLang(%q) = %q, want %q", in, got, want)
}
}
}
// ensure the stub's body is fully consumable (guards against the LimitReader cap
// accidentally truncating a normal request body in synth()).
func TestRequestBodyNotTruncated(t *testing.T) {
srv, _, last := newStubPiper(t, []byte("x"))
h := newHandler(t, srv.URL)
text := strings.Repeat("word ", 200) // ~1000 bytes, under the cap
post(t, h, text, "en-US")
if last.text != strings.TrimSpace(text) {
t.Fatalf("piper text length = %d, want %d", len(last.text), len(strings.TrimSpace(text)))
}
}

View File

@@ -147,6 +147,10 @@ export const api = {
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }), req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
dismissSuggestion: (id: string) => dismissSuggestion: (id: string) =>
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }), req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
// Simplified-Chinese rendering of a suggestion's explanation, for the Ask Petal
// opening bubble (the explanation itself stays English in the card body).
translateSuggestion: (id: string) =>
req<{ translation: string }>(`/suggestions/${id}/translate`, { method: 'POST' }),
// Version history. listVersions returns metadata only (no bodies); getVersion // Version history. listVersions returns metadata only (no bodies); getVersion
// loads one full snapshot for preview; snapshotDoc takes an explicit restore // loads one full snapshot for preview; snapshotDoc takes an explicit restore

View File

@@ -1,12 +1,42 @@
// Read-aloud via the browser's Web Speech API — an offline pronunciation aid for // Read-aloud for an ESL writer: hear how an English word or passage sounds.
// an ESL writer: hear how an English word or passage sounds. No model, no //
// network, no extra weight in the binary. Feature-detected so the buttons that // Primary path is server-side neural TTS (Piper, proxied at /api/tts) — natural,
// use it can hide where speech isn't available. // consistent across devices, and far better than the browser's built-in voices
// (which fall back to robotic espeak on Chromium). If the server route is absent
// (TTS disabled) or unreachable, we fall back to the browser's Web Speech API so
// the buttons still do something. No model or network is strictly required.
// speechSupported reports whether read-aloud can do anything at all. Audio
// playback is universal, so as long as we can construct an Audio element OR the
// Web Speech API exists, the buttons should show. The server path is tried at
// call time and degrades on its own.
export function speechSupported(): boolean { export function speechSupported(): boolean {
if (typeof window === 'undefined') return false
return typeof window.Audio === 'function' || 'speechSynthesis' in window
}
// webSpeechSupported gates the fallback path specifically.
function webSpeechSupported(): boolean {
return typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window return typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window
} }
// current holds the in-flight server-audio element and its object URL so a rapid
// re-tap can stop and replace it (mirroring speechSynthesis.cancel()).
let current: { audio: HTMLAudioElement; url: string } | null = null
// requestSeq increments on every speak() call so a slow fetch that resolves after
// a newer tap can detect it's stale and bow out instead of double-playing.
let requestSeq = 0
function stopCurrent(): void {
if (current) {
current.audio.pause()
URL.revokeObjectURL(current.url)
current = null
}
if (webSpeechSupported()) window.speechSynthesis.cancel()
}
// pickVoice prefers an exact locale match (e.g. en-US), then any voice in the // pickVoice prefers an exact locale match (e.g. en-US), then any voice in the
// same language family (en-*). Returns undefined to let the engine default. // same language family (en-*). Returns undefined to let the engine default.
function pickVoice(lang: string): SpeechSynthesisVoice | undefined { function pickVoice(lang: string): SpeechSynthesisVoice | undefined {
@@ -15,12 +45,10 @@ function pickVoice(lang: string): SpeechSynthesisVoice | undefined {
return voices.find((v) => v.lang === lang) || voices.find((v) => v.lang?.startsWith(base)) return voices.find((v) => v.lang === lang) || voices.find((v) => v.lang?.startsWith(base))
} }
// speak reads `text` aloud, cancelling anything already in flight so rapid taps // speakWebSpeech is the fallback: the browser's built-in synthesizer. A touch
// don't queue up. `lang` defaults to US English (the language she's learning); // slower than default so learners can follow along.
// pass a zh locale to hear a Chinese passage. A touch slower than default so function speakWebSpeech(text: string, lang: string): void {
// learners can follow along. if (!webSpeechSupported()) return
export function speak(text: string, lang = 'en-US'): void {
if (!speechSupported() || !text.trim()) return
const synth = window.speechSynthesis const synth = window.speechSynthesis
synth.cancel() synth.cancel()
const utterance = new SpeechSynthesisUtterance(text) const utterance = new SpeechSynthesisUtterance(text)
@@ -30,3 +58,47 @@ export function speak(text: string, lang = 'en-US'): void {
utterance.rate = 0.95 utterance.rate = 0.95
synth.speak(utterance) synth.speak(utterance)
} }
// speak reads `text` aloud, cancelling anything already in flight so rapid taps
// don't queue up. `lang` defaults to US English (the language she's learning);
// pass a zh locale to hear a Chinese passage. It tries the server's neural voice
// first and silently falls back to the browser voice if that's unavailable (route
// off, network error, or a 404 for a language with no configured voice).
export function speak(text: string, lang = 'en-US'): void {
if (!text.trim()) return
stopCurrent()
const seq = ++requestSeq
fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, lang }),
})
.then((res) => {
if (!res.ok) throw new Error(`tts ${res.status}`)
return res.blob()
})
.then((blob) => {
// A newer tap superseded this one while the fetch was in flight — drop it.
if (seq !== requestSeq) return
const url = URL.createObjectURL(blob)
const audio = new Audio(url)
current = { audio, url }
// Release the blob once playback finishes (or errors) to avoid leaking.
const cleanup = () => {
if (current?.audio === audio) {
URL.revokeObjectURL(url)
current = null
}
}
audio.addEventListener('ended', cleanup)
audio.addEventListener('error', cleanup)
return audio.play()
})
.catch(() => {
// Server TTS unavailable for this request — use the browser voice instead,
// unless a newer tap has already superseded this one.
if (seq !== requestSeq) return
speakWebSpeech(text, lang)
})
}

View File

@@ -11,6 +11,8 @@ interface Props {
animationData?: object animationData?: object
loop?: boolean loop?: boolean
className?: string className?: string
// Mirror the animation left↔right (for assets drawn facing the wrong way).
flip?: boolean
fallback: React.ReactNode fallback: React.ReactNode
} }
@@ -48,7 +50,7 @@ function fitToContent(anim: AnimationItem, svg: SVGSVGElement) {
// A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully // A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully
// offline). Reloads the animation whenever the data changes — moods swap by // offline). Reloads the animation whenever the data changes — moods swap by
// passing a different `animationData`. // passing a different `animationData`.
export function LottiePlayer({ animationData, loop = true, className, fallback }: Props) { export function LottiePlayer({ animationData, loop = true, className, flip, fallback }: Props) {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const anim = useRef<AnimationItem | null>(null) const anim = useRef<AnimationItem | null>(null)
@@ -74,5 +76,12 @@ export function LottiePlayer({ animationData, loop = true, className, fallback }
}, [animationData, loop]) }, [animationData, loop])
if (!animationData) return <>{fallback}</> if (!animationData) return <>{fallback}</>
return <div ref={ref} className={className} aria-hidden /> return (
<div
ref={ref}
className={className}
style={flip ? { transform: 'scaleX(-1)' } : undefined}
aria-hidden
/>
)
} }

View File

@@ -147,7 +147,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
onClick={dismiss} onClick={dismiss}
onMouseEnter={holdBubble} onMouseEnter={holdBubble}
onMouseLeave={releaseBubble} onMouseLeave={releaseBubble}
className="petal-bubble pointer-events-auto max-w-[260px] cursor-pointer p-3 pr-3.5" className="petal-bubble pointer-events-auto max-w-[420px] cursor-pointer p-4 pr-5"
style={{ style={{
background: 'var(--color-surface)', background: 'var(--color-surface)',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
@@ -158,10 +158,16 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
}} }}
title="Click to dismiss" title="Click to dismiss"
> >
<p className="text-sm font-bold leading-snug" style={{ color: 'var(--color-plum)' }}> <p
className="font-bold leading-snug"
style={{ color: 'var(--color-plum)', fontSize: '1.4rem' }}
>
{bubble.zh} {bubble.zh}
</p> </p>
<p className="mt-0.5 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}> <p
className="mt-0.5 leading-snug"
style={{ color: 'var(--color-muted)', fontSize: '1.15rem' }}
>
{bubble.en} {bubble.en}
</p> </p>
</div> </div>
@@ -174,8 +180,9 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
aria-label="Choose a companion" aria-label="Choose a companion"
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`} className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
style={{ style={{
width: 144, // Size scales with the viewport — see --petal-companion-size in index.css.
height: 144, width: 'var(--petal-companion-size)',
height: 'var(--petal-companion-size)',
padding: 0, padding: 0,
borderRadius: 'var(--radius-pill)', borderRadius: 'var(--radius-pill)',
background: 'var(--color-surface-alt)', background: 'var(--color-surface-alt)',
@@ -189,8 +196,13 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
<LottiePlayer <LottiePlayer
key={companion.id} key={companion.id}
animationData={animationData} animationData={animationData}
className="h-32 w-32" flip={companion.flip}
fallback={<span style={{ fontSize: 72, lineHeight: 1 }}>{MOOD_EMOJI[renderMood]}</span>} className="petal-companion-art"
fallback={
<span style={{ fontSize: 'calc(var(--petal-companion-size) * 0.5)', lineHeight: 1 }}>
{MOOD_EMOJI[renderMood]}
</span>
}
/> />
{napping && ( {napping && (
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}> <span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,9 @@
import type { Mood } from './useCompanion' import type { Mood } from './useCompanion'
import sleepingCat from './animations/sleeping-cat.json' import sleepingCat from './animations/sleeping-cat.json'
import happyDog from './animations/happy-dog.json' import happyDog from './animations/happy-dog.json'
import wiggleDog from './animations/wiggle-dog.json'
import butterfly from './animations/butterfly.json'
import parrot from './animations/parrot.json'
export interface Companion { export interface Companion {
id: string id: string
@@ -12,6 +15,9 @@ export interface Companion {
// When true the mascot keeps its calm sway + drifting zzz regardless of mood // When true the mascot keeps its calm sway + drifting zzz regardless of mood
// (e.g. a cat that's always asleep but still talks in its sleep). // (e.g. a cat that's always asleep but still talks in its sleep).
alwaysAsleep?: boolean alwaysAsleep?: boolean
// When true the rendered animation is mirrored left↔right — for assets drawn
// facing the "wrong" way for our bottom-right corner (e.g. the parrot).
flip?: boolean
} }
// The roster. Add a companion by dropping a pure-vector Lottie JSON into // The roster. Add a companion by dropping a pure-vector Lottie JSON into
@@ -44,6 +50,43 @@ export const COMPANIONS: Companion[] = [
// no sleeping clip → stays in its idle pose instead of visibly napping // no sleeping clip → stays in its idle pose instead of visibly napping
}, },
}, },
{
id: 'wiggle-dog',
name: 'Wiggle Dog',
zh: '摇尾狗',
emoji: '🐕',
animations: {
idle: wiggleDog,
happy: wiggleDog,
talking: wiggleDog,
celebrate: wiggleDog,
},
},
{
id: 'butterfly',
name: 'Butterfly',
zh: '蝴蝶',
emoji: '🦋',
animations: {
idle: butterfly,
happy: butterfly,
talking: butterfly,
celebrate: butterfly,
},
},
{
id: 'parrot',
name: 'Parrot',
zh: '鹦鹉',
emoji: '🦜',
flip: true, // asset faces left; mirror it to face into the page
animations: {
idle: parrot,
happy: parrot,
talking: parrot,
celebrate: parrot,
},
},
] ]
export const DEFAULT_COMPANION = 'cat' export const DEFAULT_COMPANION = 'cat'

View File

@@ -47,10 +47,10 @@ const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by // How long a bubble lingers. These are *floors* — readBubbleMs extends them by
// how much there is to read, since she reads both the Mandarin and the English // how much there is to read, since she reads both the Mandarin and the English
// (and tips now quote a slice of her own sentence, so they run longer). // (and tips now quote a slice of her own sentence, so they run longer).
const BUBBLE_MS = 9_000 // baseline for a tip / break const BUBBLE_MS = 14_000 // baseline for a tip / break
const CHEER_MS = 6_000 // a short cheer still needs a beat in two languages const CHEER_MS = 9_000 // a short cheer still needs a beat in two languages
const READ_MS_PER_CHAR = 45 // ~22 chars/sec, generous for a bilingual read const READ_MS_PER_CHAR = 55 // ~18 chars/sec, generous for a bilingual ESL read
const MAX_BUBBLE_MS = 20_000 // cap so a long quote can't pin the bubble forever const MAX_BUBBLE_MS = 32_000 // cap so a long quote can't pin the bubble forever
const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
const now = () => Date.now() const now = () => Date.now()

View File

@@ -1,9 +1,11 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { streamSuggestionChat, type ChatMessage } from '../../api/client' import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
interface Props { interface Props {
suggestionId: string suggestionId: string
// Pre-populates Petal's first bubble so the conversation opens with context. // The English explanation (shown in the card body). Petal's opening bubble is
// its Simplified-Chinese translation, fetched on open — so the panel doesn't
// just repeat the same English text twice. Falls back to this on failure.
explanation: string explanation: string
} }
@@ -17,9 +19,10 @@ const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC
// the card (unmounting) clears it. Each send streams Petal's reply token-by- // the card (unmounting) clears it. Each send streams Petal's reply token-by-
// token into the latest assistant bubble. // token into the latest assistant bubble.
export function AskPetal({ suggestionId, explanation }: Props) { export function AskPetal({ suggestionId, explanation }: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([ // Opening bubble starts empty (caret-only) and fills with the Mandarin
{ role: 'assistant', content: explanation }, // translation once it lands; `seeding` drives that loading caret.
]) const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }])
const [seeding, setSeeding] = useState(true)
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false) const [streaming, setStreaming] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
@@ -36,6 +39,31 @@ export function AskPetal({ suggestionId, explanation }: Props) {
inputRef.current?.focus() inputRef.current?.focus()
}, []) }, [])
// Fetch the Chinese translation of the explanation to seed the first bubble.
// Only replaces the seed bubble if the user hasn't started chatting yet (the
// conversation always opens with this one assistant turn). Falls back to the
// English explanation if the translation can't be fetched.
useEffect(() => {
let cancelled = false
api
.translateSuggestion(suggestionId)
.then((res) => {
if (cancelled) return
const text = res.translation.trim() || explanation
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: text }] : prev))
})
.catch(() => {
if (cancelled) return
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: explanation }] : prev))
})
.finally(() => {
if (!cancelled) setSeeding(false)
})
return () => {
cancelled = true
}
}, [suggestionId, explanation])
async function send() { async function send() {
const text = input.trim() const text = input.trim()
if (!text || streaming) return if (!text || streaming) return
@@ -86,7 +114,12 @@ export function AskPetal({ suggestionId, explanation }: Props) {
style={{ maxHeight: 220 }} style={{ maxHeight: 220 }}
> >
{messages.map((m, i) => ( {messages.map((m, i) => (
<Bubble key={i} role={m.role} content={m.content} streaming={streaming && i === messages.length - 1} /> <Bubble
key={i}
role={m.role}
content={m.content}
streaming={(streaming && i === messages.length - 1) || (seeding && i === 0)}
/>
))} ))}
</div> </div>

View File

@@ -18,7 +18,8 @@ import type { EditorView } from '@tiptap/pm/view'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Toolbar } from '../Toolbar/Toolbar' import { Toolbar } from '../Toolbar/Toolbar'
import { SuggestionCard } from './SuggestionCard' import { SuggestionCard } from './SuggestionCard'
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight' import { SuggestionRail, type RailItem } from './SuggestionRail'
import { SuggestionHighlight, setSuggestions, setActiveSuggestion, findRange } from './SuggestionHighlight'
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck' import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
import { MisspellCard } from './MisspellCard' import { MisspellCard } from './MisspellCard'
import { WordCard } from './WordCard' import { WordCard } from './WordCard'
@@ -219,6 +220,18 @@ export function EditorCore({
const rewriteReqRef = useRef(0) const rewriteReqRef = useRef(0)
// The in-document Find & Replace bar (Ctrl/Cmd+F). // The in-document Find & Replace bar (Ctrl/Cmd+F).
const [findOpen, setFindOpen] = useState(false) const [findOpen, setFindOpen] = useState(false)
// The right-margin comment rail. `railItems` carries each anchored suggestion's
// vertical offset; `railEnabled` is true only when the viewport has room for the
// column beside the editor (otherwise we fall back to the inline hover cards).
// `railExpandedId` is the card showing its full explanation + Ask Petal, and
// `activeId` is the suggestion currently emphasized (hovered text or card).
const [railItems, setRailItems] = useState<RailItem[]>([])
const [railEnabled, setRailEnabled] = useState(false)
const [railExpandedId, setRailExpandedId] = useState<string | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
// A stable handle to the latest recompute so the editor's onUpdate (captured
// once at construction) can trigger a re-measure without stale closures.
const recomputeRailRef = useRef<() => void>(() => {})
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
@@ -282,6 +295,8 @@ export function EditorCore({
content_text: editor.getText(), content_text: editor.getText(),
word_count: editor.storage.characterCount.words(), word_count: editor.storage.characterCount.words(),
}) })
// Edits reflow the text, so the rail anchors need re-measuring.
recomputeRailRef.current()
}, },
onSelectionUpdate: ({ editor }) => { onSelectionUpdate: ({ editor }) => {
// A selection supersedes the hover gloss; an empty one clears the bubble. // A selection supersedes the hover gloss; an empty one clears the bubble.
@@ -333,8 +348,87 @@ export function EditorCore({
setSuggestions(editor.state, editor.view.dispatch, suggestions) setSuggestions(editor.state, editor.view.dispatch, suggestions)
// Close the card if its suggestion is gone. // Close the card if its suggestion is gone.
setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null)) setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null))
// Drop any rail expand/emphasis that points at a now-removed suggestion.
setRailExpandedId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
setActiveId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
}, [editor, suggestions]) }, [editor, suggestions])
// Re-measure the margin rail: whether there's room for the column beside the
// editor, and where each suggestion's highlight sits vertically. Anchors are
// taken from the live decoration DOM (keyed by data-suggestion-id) relative to
// the wrapper — stable under scroll since text and wrapper scroll together.
// Deferred a frame so decoration DOM and reflow have settled.
const recomputeRail = useCallback(() => {
requestAnimationFrame(() => {
const wrapper = wrapperRef.current
if (!wrapper) return
const wrapRect = wrapper.getBoundingClientRect()
// Need room for the 300px column + its 32px gutter (see .petal-rail), plus
// a little breathing space to the viewport edge.
setRailEnabled(window.innerWidth - wrapRect.right >= 348)
const seen = new Set<string>()
const items: RailItem[] = []
wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => {
const id = el.getAttribute('data-suggestion-id')
if (!id || seen.has(id)) return
const s = suggestions.find((x) => x.id === id)
if (!s) return
seen.add(id)
items.push({ suggestion: s, anchorTop: el.getBoundingClientRect().top - wrapRect.top })
})
setRailItems(items)
})
}, [suggestions])
useEffect(() => {
recomputeRailRef.current = recomputeRail
}, [recomputeRail])
// Re-anchor when the suggestion set changes (after the decorations repaint),
// and keep the rail in sync with viewport/editor width changes (room + reflow).
useEffect(() => {
recomputeRail()
const wrapper = wrapperRef.current
const ro = wrapper ? new ResizeObserver(() => recomputeRail()) : null
if (wrapper && ro) ro.observe(wrapper)
window.addEventListener('resize', recomputeRail)
return () => {
ro?.disconnect()
window.removeEventListener('resize', recomputeRail)
}
}, [recomputeRail])
// Emphasize the flagged text for the active suggestion, mirroring the rail
// card ↔ text link both ways. Driven through the decoration plugin (not an
// imperative DOM class) so it survives the repaints that fire on every edit.
useEffect(() => {
if (!editor) return
setActiveSuggestion(editor.state, editor.view.dispatch, railEnabled ? activeId : null)
}, [editor, activeId, railEnabled])
// Scroll a suggestion's highlight to the middle of the viewport (clicking its
// rail card jumps the editor to the flagged text).
const scrollToSuggestion = useCallback((id: string) => {
const el = wrapperRef.current?.querySelector(
`.petal-suggestion[data-suggestion-id="${CSS.escape(id)}"]`,
)
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
}, [])
// Clicking a rail card's body jumps to the text and toggles its detail panel.
const activateRailCard = useCallback(
(id: string) => {
setActiveId(id)
scrollToSuggestion(id)
setRailExpandedId((cur) => (cur === id ? null : id))
},
[scrollToSuggestion],
)
const toggleRailExpand = useCallback((id: string) => {
setRailExpandedId((cur) => (cur === id ? null : id))
}, [])
const openCardFor = useCallback( const openCardFor = useCallback(
(id: string, el: HTMLElement) => { (id: string, el: HTMLElement) => {
const wrapper = wrapperRef.current const wrapper = wrapperRef.current
@@ -366,10 +460,17 @@ export function EditorCore({
if (!target) return if (!target) return
const id = target.getAttribute('data-suggestion-id') const id = target.getAttribute('data-suggestion-id')
if (!id) return if (!id) return
// With the rail open the card already lives in the margin — hovering the
// text just emphasizes its card (and the highlight) rather than popping a
// second, redundant floating card.
if (railEnabled) {
setActiveId(id)
return
}
clearTimeout(closeTimer.current) clearTimeout(closeTimer.current)
openCardFor(id, target) openCardFor(id, target)
}, },
[openCardFor], [openCardFor, railEnabled],
) )
const scheduleClose = useCallback(() => { const scheduleClose = useCallback(() => {
@@ -389,35 +490,53 @@ export function EditorCore({
// pointer can bridge the small gap from text to card. // pointer can bridge the small gap from text to card.
const handleMouseOut = useCallback( const handleMouseOut = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('.petal-suggestion')) scheduleClose() if (!(e.target as HTMLElement).closest('.petal-suggestion')) return
if (railEnabled) {
setActiveId(null)
return
}
scheduleClose()
}, },
[scheduleClose], [scheduleClose, railEnabled],
) )
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), []) const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
// Accept applies the replacement to the document, plays a little confetti // Accept applies the replacement to the document, plays a little confetti
// burst where the card sat, then notifies the parent. // burst over the flagged text, then notifies the parent. The confetti is
// anchored to the highlight itself (captured before the replacement removes it),
// so it fires in the right spot whether the accept came from the hover card or
// the margin rail.
const handleAccept = useCallback( const handleAccept = useCallback(
(s: Suggestion) => { (s: Suggestion) => {
const wrapper = wrapperRef.current
const el = wrapper?.querySelector(
`.petal-suggestion[data-suggestion-id="${CSS.escape(s.id)}"]`,
) as HTMLElement | null
let burst: { top: number; left: number } | null = null
if (wrapper && el) {
const wrapRect = wrapper.getBoundingClientRect()
const elRect = el.getBoundingClientRect()
burst = { top: elRect.top - wrapRect.top, left: elRect.right - wrapRect.left }
}
if (editor && s.replacement.trim() !== '') { if (editor && s.replacement.trim() !== '') {
const range = findRange(editor.state.doc, s.original) const range = findRange(editor.state.doc, s.original)
if (range) { if (range) {
editor.chain().focus().insertContentAt(range, s.replacement).run() editor.chain().focus().insertContentAt(range, s.replacement).run()
} }
} }
setHover((h) => { // Fall back to the hover card's position if the highlight wasn't found.
if (h) { if (!burst && hover) burst = { top: hover.top, left: hover.left + 16 }
setConfetti({ top: h.top, left: h.left + 16 }) if (burst) {
clearTimeout(confettiTimer.current) setConfetti(burst)
confettiTimer.current = setTimeout(() => setConfetti(null), 720) clearTimeout(confettiTimer.current)
} confettiTimer.current = setTimeout(() => setConfetti(null), 720)
return h }
})
closeCard() closeCard()
setRailExpandedId(null)
onAccept(s) onAccept(s)
}, },
[editor, onAccept, closeCard], [editor, onAccept, closeCard, hover],
) )
const handleDismiss = useCallback( const handleDismiss = useCallback(
@@ -439,7 +558,16 @@ export function EditorCore({
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
if (suggestionEl) { if (suggestionEl) {
const id = suggestionEl.getAttribute('data-suggestion-id') const id = suggestionEl.getAttribute('data-suggestion-id')
if (id) openCardFor(id, suggestionEl) if (id) {
// With the rail open, a tap emphasizes and expands its margin card
// instead of opening a floating one.
if (railEnabled) {
setActiveId(id)
setRailExpandedId(id)
} else {
openCardFor(id, suggestionEl)
}
}
return return
} }
if (!editor || !spellChecker) return if (!editor || !spellChecker) return
@@ -461,7 +589,7 @@ export function EditorCore({
setWordInfo(null) setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left }) setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
}, },
[editor, spellChecker, closeCard, openCardFor], [editor, spellChecker, closeCard, openCardFor, railEnabled],
) )
const replaceMisspelling = useCallback( const replaceMisspelling = useCallback(
@@ -854,7 +982,7 @@ export function EditorCore({
onAdd={addMisspellingToDict} onAdd={addMisspellingToDict}
/> />
)} )}
{hover && ( {hover && !railEnabled && (
<SuggestionCard <SuggestionCard
suggestion={hover.suggestion} suggestion={hover.suggestion}
style={{ top: hover.top, left: hover.left }} style={{ top: hover.top, left: hover.left }}
@@ -865,6 +993,18 @@ export function EditorCore({
onExpandChange={setPinned} onExpandChange={setPinned}
/> />
)} )}
{railEnabled && railItems.length > 0 && (
<SuggestionRail
items={railItems}
activeId={activeId}
expandedId={railExpandedId}
onAccept={handleAccept}
onDismiss={handleDismiss}
onHover={setActiveId}
onActivate={activateRailCard}
onToggleExpand={toggleRailExpand}
/>
)}
</div> </div>
</div> </div>
) )

View File

@@ -1,15 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import type { Suggestion, SuggestionType } from '../../api/client' import type { Suggestion } from '../../api/client'
import { AskPetal } from './AskPetal' import { AskPetal } from './AskPetal'
import { TYPE_META } from './suggestionMeta'
// 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 { interface Props {
suggestion: Suggestion suggestion: Suggestion

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { foldTypography, findRange } from './SuggestionHighlight'
import { Schema, type Node as PMNode } from '@tiptap/pm/model'
// A minimal doc/paragraph/text schema, enough to exercise findRange's textblock
// walk without pulling in the full editor.
const schema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
text: { group: 'inline' },
},
})
// Build a single-paragraph doc with the given plaintext.
const para = (text: string): PMNode => schema.node('doc', null, [schema.node('paragraph', null, text ? [schema.text(text)] : [])])
describe('foldTypography', () => {
it('folds curly quotes back to straight ASCII', () => {
expect(foldTypography('“Hes here,” she said').folded).toBe('"He\'s here," she said')
})
it('folds dashes and ellipsis, keeping a source-index map across length changes', () => {
const { folded, map } = foldTypography('wait—really...')
expect(folded).toBe('wait—really…')
// The em dash is already one glyph; the `...` collapsed 3 source chars to 1,
// so the trailing sentinel still points just past the last source char.
expect(map[map.length - 1]).toBe('wait—really...'.length)
})
it('treats `--` and `...` typed-but-unconverted as their canonical glyphs', () => {
expect(foldTypography('a--b...c').folded).toBe('a—b…c')
})
})
describe('findRange typographic anchoring', () => {
it('anchors a model echo with straight quotes onto curly-quote document text', () => {
const doc = para('She said “hello” to me')
const range = findRange(doc, '“hello”'.replace(/[“”]/g, '"')) // model echoed "hello"
expect(range).not.toBeNull()
// The matched span must cover the curly-quoted region in the real document.
expect(doc.textBetween(range!.from - 1, range!.to - 1)).toContain('hello')
})
it('anchors an ASCII `--` echo onto an em-dash in the document', () => {
const doc = para('I waited—forever')
const range = findRange(doc, 'waited--forever')
expect(range).not.toBeNull()
})
it('still returns null when the text genuinely is not present', () => {
expect(findRange(para('nothing to see'), 'absent phrase')).toBeNull()
})
})

View File

@@ -16,9 +16,17 @@ export const suggestionPluginKey = new PluginKey<PluginState>('petalSuggestions'
interface PluginState { interface PluginState {
suggestions: Suggestion[] suggestions: Suggestion[]
// The suggestion currently emphasized from its margin card / a hover, or null.
// Carried in plugin state (not an imperative DOM class) so it survives the
// decoration repaints that fire on every document change.
activeId: string | null
decorations: DecorationSet decorations: DecorationSet
} }
// Meta carried on a transaction to update the plugin: either a fresh suggestion
// list or a change to which suggestion is emphasized.
type SuggestionMeta = { suggestions: Suggestion[] } | { activeId: string | null }
// mapOffset converts a character offset within a textblock's flattened text into // mapOffset converts a character offset within a textblock's flattened text into
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard // an absolute ProseMirror position, accounting for inline atoms (e.g. hard
// breaks) that occupy a position but contribute no text. // breaks) that occupy a position but contribute no text.
@@ -42,21 +50,87 @@ export function mapOffset(block: PMNode, blockPos: number, targetOffset: number)
return result return result
} }
// foldTypography canonicalizes the typographic variants that the editor's
// Typography input rules introduce (curly quotes, em/en dashes, single-glyph
// ellipsis) and the unicode spaces a paste can carry. The model echoes a
// suggestion's `original` from the same text but often normalizes these back to
// plain ASCII — straight quotes, `--`, `...` — so an exact match would miss and
// the suggestion could be neither highlighted nor applied.
//
// It returns the folded string plus `map`, where map[k] is the source index at
// which folded character k begins. A multi-character source run (`...`, `--`)
// folds to one glyph, so lengths differ; map projects a match in folded space
// back onto real document offsets. map has a trailing sentinel (= source length)
// so map[start + folded.length] yields the offset just past the match.
export function foldTypography(text: string): { folded: string; map: number[] } {
let folded = ''
const map: number[] = []
let i = 0
while (i < text.length) {
// Multi-character ASCII sequences the Typography rules would have replaced.
if (text.startsWith('...', i)) {
folded += '…'
map.push(i)
i += 3
continue
}
if (text[i] === '-' && text[i + 1] === '-') {
folded += '—'
map.push(i)
i += 2
continue
}
folded += foldChar(text[i])
map.push(i)
i++
}
map.push(text.length)
return { folded, map }
}
// foldChar maps a single character onto its canonical form (length-preserving).
function foldChar(c: string): string {
switch (c) {
case '':
case '':
case '':
case '':
return "'"
case '“':
case '”':
case '„':
case '‟':
return '"'
case '': // en dash → em dash, the canonical form `--` also folds to
return '—'
case ' ': // non-breaking space
case '': // thin space
case '': // narrow no-break space
return ' '
default:
return c
}
}
// findRange locates the first occurrence of `search` within a single textblock // 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 // 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 // user may have edited or removed it since the checkpoint ran). Matching is done
// accept flow can resolve the same span to replace. // in canonical typographic space (see foldTypography) so curly-quote/dash/ellipsis
// differences between the model's echo and the live document don't strand the
// anchor. Exported so the accept flow can resolve the same span to replace.
export function findRange(doc: PMNode, search: string): { from: number; to: number } | null { export function findRange(doc: PMNode, search: string): { from: number; to: number } | null {
if (!search) return null const needle = foldTypography(search).folded
if (!needle) return null
let result: { from: number; to: number } | null = null let result: { from: number; to: number } | null = null
doc.descendants((node, pos) => { doc.descendants((node, pos) => {
if (result) return false if (result) return false
if (!node.isTextblock) return true // keep descending to the textblock if (!node.isTextblock) return true // keep descending to the textblock
const idx = node.textContent.indexOf(search) const { folded, map } = foldTypography(node.textContent)
const idx = folded.indexOf(needle)
if (idx >= 0) { if (idx >= 0) {
result = { result = {
from: mapOffset(node, pos, idx), from: mapOffset(node, pos, map[idx]),
to: mapOffset(node, pos, idx + search.length), to: mapOffset(node, pos, map[idx + needle.length]),
} }
} }
return false // never descend into a textblock's inline children return false // never descend into a textblock's inline children
@@ -64,14 +138,15 @@ export function findRange(doc: PMNode, search: string): { from: number; to: numb
return result return result
} }
function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet { function buildDecorations(doc: PMNode, suggestions: Suggestion[], activeId: string | null): DecorationSet {
const decos: Decoration[] = [] const decos: Decoration[] = []
for (const s of suggestions) { for (const s of suggestions) {
const range = findRange(doc, s.original) const range = findRange(doc, s.original)
if (!range) continue if (!range) continue
const active = s.id === activeId ? ' petal-suggestion-active' : ''
decos.push( decos.push(
Decoration.inline(range.from, range.to, { Decoration.inline(range.from, range.to, {
class: `petal-suggestion petal-suggestion-${s.type}`, class: `petal-suggestion petal-suggestion-${s.type}${active}`,
'data-suggestion-id': s.id, 'data-suggestion-id': s.id,
}), }),
) )
@@ -82,10 +157,22 @@ function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet
// setSuggestions pushes a new suggestion list into the plugin. Decorations are // setSuggestions pushes a new suggestion list into the plugin. Decorations are
// rebuilt against the current document immediately. // rebuilt against the current document immediately.
export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) { export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) {
const tr = state.tr.setMeta(suggestionPluginKey, suggestions) const tr = state.tr.setMeta(suggestionPluginKey, { suggestions } satisfies SuggestionMeta)
dispatch(tr) dispatch(tr)
} }
// setActiveSuggestion marks one suggestion (or none) as emphasized, rebuilding
// the decorations so the flagged text gets the active wash. Driven by the margin
// rail's hover/selection so the card↔text link reads both ways.
export function setActiveSuggestion(
state: EditorState,
dispatch: (tr: Transaction) => void,
activeId: string | null,
) {
if (suggestionPluginKey.getState(state)?.activeId === activeId) return
dispatch(state.tr.setMeta(suggestionPluginKey, { activeId } satisfies SuggestionMeta))
}
export const SuggestionHighlight = Extension.create({ export const SuggestionHighlight = Extension.create({
name: 'suggestionHighlight', name: 'suggestionHighlight',
@@ -94,17 +181,29 @@ export const SuggestionHighlight = Extension.create({
new Plugin<PluginState>({ new Plugin<PluginState>({
key: suggestionPluginKey, key: suggestionPluginKey,
state: { state: {
init: () => ({ suggestions: [], decorations: DecorationSet.empty }), init: () => ({ suggestions: [], activeId: null, decorations: DecorationSet.empty }),
apply(tr, value, _oldState, newState) { apply(tr, value, _oldState, newState) {
const meta = tr.getMeta(suggestionPluginKey) as Suggestion[] | undefined const meta = tr.getMeta(suggestionPluginKey) as SuggestionMeta | undefined
if (meta) { if (meta && 'suggestions' in meta) {
return { suggestions: meta, decorations: buildDecorations(newState.doc, meta) } return {
suggestions: meta.suggestions,
activeId: value.activeId,
decorations: buildDecorations(newState.doc, meta.suggestions, value.activeId),
}
}
if (meta && 'activeId' in meta) {
return {
suggestions: value.suggestions,
activeId: meta.activeId,
decorations: buildDecorations(newState.doc, value.suggestions, meta.activeId),
}
} }
// On any document change, re-anchor by string against the new doc. // On any document change, re-anchor by string against the new doc.
if (tr.docChanged) { if (tr.docChanged) {
return { return {
suggestions: value.suggestions, suggestions: value.suggestions,
decorations: buildDecorations(newState.doc, value.suggestions), activeId: value.activeId,
decorations: buildDecorations(newState.doc, value.suggestions, value.activeId),
} }
} }
return value return value

View File

@@ -0,0 +1,211 @@
import { forwardRef, useLayoutEffect, useRef, useState } from 'react'
import type { Suggestion } from '../../api/client'
import { AskPetal } from './AskPetal'
import { TYPE_META } from './suggestionMeta'
// Vertical breathing room kept between stacked cards when their natural anchors
// would otherwise collide.
const CARD_GAP = 12
export interface RailItem {
suggestion: Suggestion
// Vertical offset (px) of the suggestion's highlight, relative to the editor
// wrapper — the position the card wants to sit beside.
anchorTop: number
}
interface Props {
items: RailItem[]
// The suggestion currently emphasized (its highlight hovered, or this card
// hovered) — gets a lifted, ring-accented treatment so the text↔card link reads.
activeId: string | null
// The card expanded to show the full explanation + Ask Petal, or null.
expandedId: string | null
onAccept: (s: Suggestion) => void
onDismiss: (s: Suggestion) => void
// Pointer entering/leaving a card, so the matching highlight can light up.
onHover: (id: string | null) => void
// A card's body was clicked — scroll its highlight into view and toggle expand.
onActivate: (id: string) => void
onToggleExpand: (id: string) => void
}
// SuggestionRail is the right-margin "comment column": every outstanding
// suggestion as a card, vertically aligned to the text it flags, so the writer
// sees the whole queue at once instead of hunting highlight by highlight. Cards
// are anchored to their highlight's vertical position and pushed down only as far
// as needed to avoid overlapping their neighbor above (a la doc-editor comments).
export function SuggestionRail({
items,
activeId,
expandedId,
onAccept,
onDismiss,
onHover,
onActivate,
onToggleExpand,
}: Props) {
// Measured resolved tops keyed by suggestion id (after collision avoidance).
const [tops, setTops] = useState<Record<string, number>>({})
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// Bumped whenever a card's own height changes (Ask Petal streaming in, wrapping
// text) so the stack re-packs and cards below don't get overlapped.
const [measureTick, setMeasureTick] = useState(0)
const observerRef = useRef<ResizeObserver | null>(null)
if (!observerRef.current && typeof ResizeObserver !== 'undefined') {
observerRef.current = new ResizeObserver(() => setMeasureTick((t) => t + 1))
}
useLayoutEffect(() => () => observerRef.current?.disconnect(), [])
// Sort by anchor, then greedily stack: each card sits at its anchor unless that
// would overlap the previous card, in which case it slides down just enough.
// Re-runs whenever the anchors or the expanded card (which changes a height)
// shift. Reads live offsetHeight so variable-length explanations pack tightly.
const ordered = [...items].sort((a, b) => a.anchorTop - b.anchorTop)
const layoutKey = ordered.map((i) => `${i.suggestion.id}:${Math.round(i.anchorTop)}`).join('|')
useLayoutEffect(() => {
let cursor = -Infinity
const next: Record<string, number> = {}
for (const { suggestion, anchorTop } of ordered) {
const h = cardRefs.current.get(suggestion.id)?.offsetHeight ?? 96
const top = Math.max(anchorTop, cursor)
next[suggestion.id] = top
cursor = top + h + CARD_GAP
}
setTops((prev) => {
const ids = Object.keys(next)
if (ids.length === Object.keys(prev).length && ids.every((id) => prev[id] === next[id])) return prev
return next
})
// layoutKey captures anchor positions; expandedId/measureTick capture height
// changes (toggling detail, Ask Petal streaming in).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutKey, expandedId, measureTick])
return (
<div className="petal-rail petal-no-print" aria-label="Suggestions">
{ordered.map(({ suggestion }) => (
<RailCard
key={suggestion.id}
ref={(el) => {
const prev = cardRefs.current.get(suggestion.id)
if (prev && prev !== el) observerRef.current?.unobserve(prev)
if (el) {
cardRefs.current.set(suggestion.id, el)
observerRef.current?.observe(el)
} else {
cardRefs.current.delete(suggestion.id)
}
}}
suggestion={suggestion}
top={tops[suggestion.id] ?? 0}
active={activeId === suggestion.id}
expanded={expandedId === suggestion.id}
onAccept={onAccept}
onDismiss={onDismiss}
onHover={onHover}
onActivate={onActivate}
onToggleExpand={onToggleExpand}
/>
))}
</div>
)
}
interface CardProps {
suggestion: Suggestion
top: number
active: boolean
expanded: boolean
onAccept: (s: Suggestion) => void
onDismiss: (s: Suggestion) => void
onHover: (id: string | null) => void
onActivate: (id: string) => void
onToggleExpand: (id: string) => void
}
const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
{ suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand },
ref,
) {
const meta = TYPE_META[suggestion.type]
const hasReplacement = suggestion.replacement.trim() !== ''
return (
<div
ref={ref}
role="group"
aria-label={`${meta.label} suggestion`}
onMouseEnter={() => onHover(suggestion.id)}
onMouseLeave={() => onHover(null)}
className={`petal-rail-card${active ? ' petal-rail-card-active' : ''}`}
style={{ top, borderLeftColor: meta.color }}
>
<div className="flex items-center justify-between">
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[0.68rem] font-bold"
style={{ background: meta.color, color: 'var(--color-plum)' }}
>
{meta.label}
</span>
<button
type="button"
aria-label="Dismiss suggestion"
onClick={() => onDismiss(suggestion)}
className="petal-rail-x"
>
</button>
</div>
{/* The body toggles the expanded explanation + Ask Petal and points the
editor at the flagged text. */}
<button type="button" onClick={() => onActivate(suggestion.id)} className="mt-2 block w-full text-left">
{hasReplacement && (
<span className="flex flex-col gap-0.5" style={{ fontFamily: 'var(--font-body)' }}>
<span className="truncate text-[0.9rem] line-through" style={{ color: 'var(--color-muted)' }}>
{suggestion.original}
</span>
<span className="truncate text-[0.9rem] font-medium" style={{ color: 'var(--color-plum)' }}>
{suggestion.replacement}
</span>
</span>
)}
<span
className={`mt-1.5 block text-[0.82rem] leading-snug${expanded ? '' : ' line-clamp-2'}`}
style={{ color: 'var(--color-plum)' }}
>
{suggestion.explanation}
</span>
</button>
{expanded && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />}
<div className="mt-2.5 flex items-center gap-2">
{hasReplacement && (
<button
type="button"
onClick={() => onAccept(suggestion)}
className="rounded-full px-3 py-1 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={() => onToggleExpand(suggestion.id)}
className="rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
style={{
background: expanded ? 'var(--color-surface-alt)' : 'transparent',
color: 'var(--color-accent-hover)',
}}
>
{expanded ? 'Hide Petal' : 'Ask Petal ✨'}
</button>
</div>
</div>
)
})

View File

@@ -0,0 +1,12 @@
import type { SuggestionType } from '../../api/client'
// Per-type accent color + human label, mirroring the design tokens. Shared by the
// inline hover SuggestionCard and the margin SuggestionRail so a given suggestion
// type reads the same color/name wherever it surfaces.
export 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' },
}

View File

@@ -20,17 +20,29 @@ export function useCheckpoint(docId: string | null) {
const [llmDown, setLlmDown] = useState(false) const [llmDown, setLlmDown] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined) const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
// Pending auto-retry timer for a failed checkpoint (see runCheck).
const retryRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const docIdRef = useRef(docId) const docIdRef = useRef(docId)
docIdRef.current = docId docIdRef.current = docId
// Token to discard responses from a doc we've since navigated away from. // Token to discard responses from a doc we've since navigated away from.
const runRef = useRef(0) const runRef = useRef(0)
const runCheck = useCallback(async () => { // Backoff schedule for re-running a checkpoint that failed. A paste fires
// exactly ONE checkpoint, and nothing re-fires until the next keystroke — so a
// single transient failure (a momentary stall on the shared inference box)
// would otherwise strand the user on "resting" indefinitely after a paste.
// These delays clear the server's 30s per-doc floor by the later attempts, and
// a failed pass now releases its slot server-side so a retry can truly re-run.
const RETRY_DELAYS_MS = [3000, 12000, 35000]
const runCheck = useCallback(async (attempt = 0) => {
const id = docIdRef.current const id = docIdRef.current
if (!id) return if (!id) return
clearTimeout(retryRef.current)
const run = ++runRef.current const run = ++runRef.current
setChecking(true) setChecking(true)
let retrying = false
try { try {
const fresh = await api.checkDoc(id) const fresh = await api.checkDoc(id)
if (run === runRef.current && id === docIdRef.current) { if (run === runRef.current && id === docIdRef.current) {
@@ -39,9 +51,18 @@ export function useCheckpoint(docId: string | null) {
} }
} catch (err) { } catch (err) {
console.error('checkpoint failed', err) console.error('checkpoint failed', err)
if (run === runRef.current) setLlmDown(true) if (run !== runRef.current) return
if (attempt < RETRY_DELAYS_MS.length) {
// Keep trying quietly — don't flag "resting" until retries are exhausted.
retrying = true
retryRef.current = setTimeout(() => void runCheck(attempt + 1), RETRY_DELAYS_MS[attempt])
} else {
setLlmDown(true)
}
} finally { } finally {
if (run === runRef.current) setChecking(false) // Stay in the "checking" state while a retry is queued so the breathing dot
// keeps reassuring rather than flickering off between attempts.
if (run === runRef.current && !retrying) setChecking(false)
} }
}, []) }, [])
@@ -51,6 +72,7 @@ export function useCheckpoint(docId: string | null) {
const runVoice = useCallback(async () => { const runVoice = useCallback(async () => {
const id = docIdRef.current const id = docIdRef.current
if (!id) return if (!id) return
clearTimeout(retryRef.current) // a voice pass supersedes a queued grammar retry
const run = ++runRef.current const run = ++runRef.current
setVoicing(true) setVoicing(true)
try { try {
@@ -70,13 +92,15 @@ export function useCheckpoint(docId: string | null) {
// 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)
debounceRef.current = setTimeout(runCheck, DEBOUNCE_MS) clearTimeout(retryRef.current) // a fresh edit supersedes any queued retry
debounceRef.current = setTimeout(() => void runCheck(), DEBOUNCE_MS)
}, [runCheck]) }, [runCheck])
// Load existing pending suggestions whenever the document changes, and cancel // Load existing pending suggestions whenever the document changes, and cancel
// any in-flight debounce from the previous doc. // any in-flight debounce from the previous doc.
useEffect(() => { useEffect(() => {
clearTimeout(debounceRef.current) clearTimeout(debounceRef.current)
clearTimeout(retryRef.current)
runRef.current++ runRef.current++
setSuggestions([]) setSuggestions([])
setChecking(false) setChecking(false)
@@ -97,7 +121,13 @@ export function useCheckpoint(docId: string | null) {
} }
}, [docId]) }, [docId])
useEffect(() => () => clearTimeout(debounceRef.current), []) useEffect(
() => () => {
clearTimeout(debounceRef.current)
clearTimeout(retryRef.current)
},
[],
)
// Drop one suggestion locally (after accept/dismiss) without a refetch. // Drop one suggestion locally (after accept/dismiss) without a refetch.
const removeSuggestion = useCallback((id: string) => { const removeSuggestion = useCallback((id: string) => {

View File

@@ -223,6 +223,67 @@ button, a, input {
animation: petal-suggestion-in 200ms ease both; animation: petal-suggestion-in 200ms ease both;
} }
/* Text emphasized from its margin card (or a hover) — a soft wash so the
card↔text link reads at a glance, both directions. */
.petal-suggestion-active {
background: var(--color-surface-alt);
box-shadow: 0 0 0 3px var(--color-surface-alt);
border-radius: 3px;
}
/* --- Suggestion margin rail -------------------------------------------------
The right-hand "comment column": every outstanding suggestion as a card,
vertically aligned to the text it flags, so the whole queue is visible at a
glance instead of one-highlight-at-a-time on hover. It floats in the whitespace
just right of the editor column and is only mounted when there's room for it
(EditorCore measures the gap). Cards are absolutely positioned by a resolved
top (anchor + collision avoidance), so the container only anchors the column. */
.petal-rail {
position: absolute;
top: 0;
left: 100%;
margin-left: 32px;
width: 300px;
/* Below the companion mascot (z-40): where a stacked card reaches the
bottom-right corner it simply tucks behind the sleeping cat. */
z-index: 10;
}
.petal-rail-card {
position: absolute;
left: 0;
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-border); /* type color set inline */
border-radius: var(--radius-card);
box-shadow: var(--shadow-soft);
padding: 0.7rem 0.85rem;
/* `top` eases so re-stacking (accept/dismiss/edit) glides instead of jumping.
The entrance uses fill `backwards` so its translateY doesn't linger and fight
the active-state transform once it's done. */
transition: top 240ms cubic-bezier(0.2, 0.7, 0.3, 1), box-shadow 200ms ease,
transform 200ms ease, border-color 200ms ease;
animation: petal-suggestion-in 220ms ease backwards;
}
.petal-rail-card-active {
transform: translateX(-5px);
box-shadow: 0 8px 26px rgba(180, 130, 160, 0.24);
border-top-color: var(--color-accent);
border-right-color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
.petal-rail-x {
color: var(--color-muted);
font-size: 0.72rem;
line-height: 1;
padding: 2px 5px;
border-radius: var(--radius-pill);
}
.petal-rail-x:hover {
background: var(--color-surface-alt);
color: var(--color-plum);
}
/* --- Find & Replace --------------------------------------------------------- /* --- Find & Replace ---------------------------------------------------------
In-document search (Ctrl/Cmd+F). Every match gets a soft honey wash; the In-document search (Ctrl/Cmd+F). Every match gets a soft honey wash; the
current match is brighter with a rose ring so it stands out as you step current match is brighter with a rose ring so it stands out as you step
@@ -319,9 +380,17 @@ button, a, input {
The cozy corner mascot. Gently bobs while awake, settles and sways slowly The cozy corner mascot. Gently bobs while awake, settles and sways slowly
while napping; its speech bubble pops in; little zzz drift up when asleep. */ while napping; its speech bubble pops in; little zzz drift up when asleep. */
.petal-companion { .petal-companion {
/* Mascot size scales with the viewport width: ~original on a laptop, up to
~2× on a large desktop. Tune the middle (vw) term to taste. */
--petal-companion-size: clamp(10rem, 17vw, 20rem);
animation: petal-bob 3.2s ease-in-out infinite; animation: petal-bob 3.2s ease-in-out infinite;
transition: transform 200ms ease; transition: transform 200ms ease;
} }
/* The Lottie art sits inside the round badge with a little breathing room. */
.petal-companion-art {
width: 88%;
height: 88%;
}
.petal-companion:hover { .petal-companion:hover {
transform: translateY(-2px) scale(1.04); transform: translateY(-2px) scale(1.04);
} }
@@ -347,10 +416,10 @@ button, a, input {
} }
.petal-zzz { .petal-zzz {
top: 6px; top: calc(var(--petal-companion-size) * 0.04);
right: 14px; right: calc(var(--petal-companion-size) * 0.1);
font-weight: 800; font-weight: 800;
font-size: 1.1rem; font-size: calc(var(--petal-companion-size) * 0.12);
animation: petal-zzz 2.4s ease-in-out infinite; animation: petal-zzz 2.4s ease-in-out infinite;
} }
@keyframes petal-zzz { @keyframes petal-zzz {