Compare commits
7 Commits
feat/write
...
feat/sugge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e2bcc777 | ||
|
|
035dcf5d25 | ||
|
|
5120b1e5a2 | ||
|
|
adc0cdff1c | ||
|
|
9d6698dcc6 | ||
|
|
6783ce7a51 | ||
|
|
3ac6382696 |
14
.env.example
14
.env.example
@@ -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
|
||||||
|
|||||||
@@ -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
65
deploy/README.md
Normal 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
17
deploy/piper-zh.service
Normal 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
17
deploy/piper.service
Normal 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
43
deploy/setup-piper.sh
Executable 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; }
|
||||||
@@ -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", ""),
|
||||||
|
|||||||
@@ -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
23
internal/llm/translate.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +155,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 +177,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 +203,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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
57
internal/suggestions/translate.go
Normal file
57
internal/suggestions/translate.go
Normal 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
264
internal/tts/handler.go
Normal 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
|
||||||
|
}
|
||||||
170
internal/tts/handler_test.go
Normal file
170
internal/tts/handler_test.go
Normal 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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)' }}>
|
||||||
|
|||||||
1
web/src/components/Companion/animations/butterfly.json
Normal file
1
web/src/components/Companion/animations/butterfly.json
Normal file
File diff suppressed because one or more lines are too long
1
web/src/components/Companion/animations/parrot.json
Normal file
1
web/src/components/Companion/animations/parrot.json
Normal file
File diff suppressed because one or more lines are too long
1
web/src/components/Companion/animations/wiggle-dog.json
Normal file
1
web/src/components/Companion/animations/wiggle-dog.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
54
web/src/components/Editor/SuggestionHighlight.test.ts
Normal file
54
web/src/components/Editor/SuggestionHighlight.test.ts
Normal 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('“He’s 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
211
web/src/components/Editor/SuggestionRail.tsx
Normal file
211
web/src/components/Editor/SuggestionRail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
12
web/src/components/Editor/suggestionMeta.ts
Normal file
12
web/src/components/Editor/suggestionMeta.ts
Normal 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' },
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user