Merge: editor tone, word lookup, expanded stats
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"gitea.parodia.dev/drwily/petal/internal/config"
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/docs"
|
||||
"gitea.parodia.dev/drwily/petal/internal/lexicon"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
"gitea.parodia.dev/drwily/petal/internal/suggestions"
|
||||
"gitea.parodia.dev/drwily/petal/web"
|
||||
@@ -67,6 +68,9 @@ func main() {
|
||||
|
||||
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
|
||||
api.Mount("/suggestions", sug.Routes())
|
||||
|
||||
// Offline word lookups (definition + synonyms) for the right-click popover.
|
||||
api.Mount("/word", lexicon.NewHandler().Routes())
|
||||
})
|
||||
|
||||
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
|
||||
|
||||
@@ -169,6 +169,13 @@ CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
||||
CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Per-document tone: guides the grammar-checkpoint LLM so advice fits
|
||||
// the writer's target register (academic essay vs casual journal).
|
||||
// 'general' means no specific tone steering.
|
||||
name: "0002_document_tone",
|
||||
stmt: `ALTER TABLE documents ADD COLUMN tone TEXT NOT NULL DEFAULT 'general';`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ type Document struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"` // Tiptap JSON
|
||||
ContentText string `json:"content_text"` // plain text for the LLM
|
||||
Tone string `json:"tone"` // target writing tone; steers LLM advice
|
||||
WordCount int `json:"word_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@@ -81,11 +81,11 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
||||
var doc db.Document
|
||||
err := h.DB.QueryRow(
|
||||
`INSERT INTO documents (user_id) VALUES (?)
|
||||
RETURNING id, user_id, title, content, content_text, word_count, created_at, updated_at`,
|
||||
RETURNING id, user_id, title, content, content_text, tone, word_count, created_at, updated_at`,
|
||||
db.LocalUserID,
|
||||
).Scan(
|
||||
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||
&doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
@@ -115,6 +115,7 @@ type updateRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Content *string `json:"content"`
|
||||
ContentText *string `json:"content_text"`
|
||||
Tone *string `json:"tone"`
|
||||
WordCount *int `json:"word_count"`
|
||||
}
|
||||
|
||||
@@ -134,10 +135,11 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
SET title = COALESCE(?, title),
|
||||
content = COALESCE(?, content),
|
||||
content_text = COALESCE(?, content_text),
|
||||
tone = COALESCE(?, tone),
|
||||
word_count = COALESCE(?, word_count),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
req.Title, req.Content, req.ContentText, req.WordCount, id, db.LocalUserID,
|
||||
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
@@ -177,13 +179,13 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) fetch(id string) (db.Document, error) {
|
||||
var doc db.Document
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT id, user_id, title, content, content_text, word_count, created_at, updated_at
|
||||
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
|
||||
FROM documents
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
id, db.LocalUserID,
|
||||
).Scan(
|
||||
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||
&doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
)
|
||||
return doc, err
|
||||
}
|
||||
|
||||
21
internal/lexicon/data.go
Normal file
21
internal/lexicon/data.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Package lexicon serves offline word lookups — a definition and a list of
|
||||
// synonyms for a single word — from two public-domain datasets compiled into the
|
||||
// binary. Definitions come from the Wordset dictionary (modern, concise glosses
|
||||
// with a part of speech and example, which read kindly for an ESL writer);
|
||||
// synonyms come from the Moby Thesaurus. Both are gzipped JSON, decompressed
|
||||
// lazily on first use so a writer who never right-clicks a word pays nothing.
|
||||
package lexicon
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// definitionsGz is the gzipped Wordset definitions map: word → [[pos, def,
|
||||
// example], …], lowercase keys. Built by the data-prep step (see commit notes).
|
||||
//
|
||||
//go:embed data/definitions.json.gz
|
||||
var definitionsGz []byte
|
||||
|
||||
// synonymsGz is the gzipped Moby thesaurus map: headword → [synonym, …],
|
||||
// lowercase keys, capped per word to keep the popover (and the binary) small.
|
||||
//
|
||||
//go:embed data/synonyms.json.gz
|
||||
var synonymsGz []byte
|
||||
BIN
internal/lexicon/data/definitions.json.gz
Normal file
BIN
internal/lexicon/data/definitions.json.gz
Normal file
Binary file not shown.
BIN
internal/lexicon/data/synonyms.json.gz
Normal file
BIN
internal/lexicon/data/synonyms.json.gz
Normal file
Binary file not shown.
50
internal/lexicon/handlers.go
Normal file
50
internal/lexicon/handlers.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package lexicon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Handler serves the word-lookup endpoint backed by a single shared Lexicon.
|
||||
type Handler struct {
|
||||
Lex *Lexicon
|
||||
}
|
||||
|
||||
// New constructs a Handler with a fresh (lazily-loaded) Lexicon.
|
||||
func NewHandler() *Handler { return &Handler{Lex: New()} }
|
||||
|
||||
// Routes returns the router mounted at /api/word. The word is a path segment so
|
||||
// "/api/word/happy" reads naturally; it's URL-decoded to tolerate the rare
|
||||
// punctuated token.
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/{word}", h.lookup)
|
||||
return r
|
||||
}
|
||||
|
||||
// lookup returns the definition + synonyms for one word. A word found in neither
|
||||
// dataset still returns 200 with empty lists, so the popover can show a friendly
|
||||
// "nothing found" rather than an error state.
|
||||
func (h *Handler) lookup(w http.ResponseWriter, r *http.Request) {
|
||||
word := chi.URLParam(r, "word")
|
||||
if decoded, err := url.PathUnescape(word); err == nil {
|
||||
word = decoded
|
||||
}
|
||||
|
||||
res, err := h.Lex.Lookup(word)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Word lookups are static for the life of the build; let the browser cache
|
||||
// them so repeated right-clicks on the same word are instant.
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_ = json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
205
internal/lexicon/lexicon.go
Normal file
205
internal/lexicon/lexicon.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package lexicon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Meaning is one sense of a word: its part of speech, the gloss, and an optional
|
||||
// usage example. The frontend renders a few of these in the word popover.
|
||||
type Meaning struct {
|
||||
PartOfSpeech string `json:"part_of_speech"`
|
||||
Definition string `json:"definition"`
|
||||
Example string `json:"example,omitempty"`
|
||||
}
|
||||
|
||||
// Result is the full lookup for one word. Either list may be empty (the word
|
||||
// isn't a headword in that dataset); the frontend handles a partial or empty
|
||||
// result gracefully.
|
||||
type Result struct {
|
||||
Word string `json:"word"`
|
||||
Definitions []Meaning `json:"definitions"`
|
||||
Synonyms []string `json:"synonyms"`
|
||||
}
|
||||
|
||||
// maxSynonyms caps how many synonyms we hand the popover, even though the dataset
|
||||
// stores up to ~50 per word — a long flat wall of words overwhelms more than it
|
||||
// helps, especially for an ESL reader scanning for the right fit.
|
||||
const maxSynonyms = 16
|
||||
|
||||
// maxDefinitions caps the senses shown so the popover stays a glance, not an
|
||||
// essay.
|
||||
const maxDefinitions = 4
|
||||
|
||||
// Lexicon holds the lazily-loaded datasets. The maps are populated once, on the
|
||||
// first Lookup, behind a sync.Once so startup stays instant and a load error is
|
||||
// remembered rather than retried on every request.
|
||||
type Lexicon struct {
|
||||
once sync.Once
|
||||
loadErr error
|
||||
defs map[string][][]string // word → [[pos, def, example], …]
|
||||
synonyms map[string][]string // word → [synonym, …]
|
||||
}
|
||||
|
||||
// New returns a Lexicon. The datasets aren't read until the first Lookup.
|
||||
func New() *Lexicon { return &Lexicon{} }
|
||||
|
||||
func (l *Lexicon) load() {
|
||||
l.once.Do(func() {
|
||||
if err := gunzipJSON(definitionsGz, &l.defs); err != nil {
|
||||
l.loadErr = fmt.Errorf("load definitions: %w", err)
|
||||
return
|
||||
}
|
||||
if err := gunzipJSON(synonymsGz, &l.synonyms); err != nil {
|
||||
l.loadErr = fmt.Errorf("load synonyms: %w", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Lookup returns the definition senses and synonyms for word. It first tries the
|
||||
// word as written (lowercased), then a few simple morphological reductions
|
||||
// (plurals, -ed/-ing/-ly) so "running" or "happily" still resolve. A word found
|
||||
// in neither dataset yields a Result with empty lists (not an error).
|
||||
func (l *Lexicon) Lookup(word string) (Result, error) {
|
||||
l.load()
|
||||
if l.loadErr != nil {
|
||||
return Result{}, l.loadErr
|
||||
}
|
||||
|
||||
norm := strings.ToLower(strings.TrimSpace(word))
|
||||
res := Result{Word: word, Definitions: []Meaning{}, Synonyms: []string{}}
|
||||
if norm == "" {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
if raw := lookupDefs(l.defs, norm); raw != nil {
|
||||
for _, m := range raw {
|
||||
res.Definitions = append(res.Definitions, toMeaning(m))
|
||||
if len(res.Definitions) >= maxDefinitions {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if syns := lookupSyns(l.synonyms, norm); syns != nil {
|
||||
if len(syns) > maxSynonyms {
|
||||
syns = syns[:maxSynonyms]
|
||||
}
|
||||
res.Synonyms = append(res.Synonyms, syns...)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// lookupDefs / lookupSyns walk the candidate forms of a word and return the
|
||||
// first dataset hit. They're separate (rather than a generic helper) only
|
||||
// because the two maps have different value types.
|
||||
func lookupDefs(m map[string][][]string, word string) [][]string {
|
||||
for _, c := range candidates(word) {
|
||||
if v, ok := m[c]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupSyns(m map[string][]string, word string) []string {
|
||||
for _, c := range candidates(word) {
|
||||
if v, ok := m[c]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// candidates returns the lemma forms to try, in priority order: the word itself,
|
||||
// then conservative de-inflections. This is deliberately lightweight — a full
|
||||
// stemmer would over-reduce ("business" → "busy") and surface wrong entries; a
|
||||
// handful of common English suffix rules covers the everyday cases without a
|
||||
// dependency. Duplicates are fine (map lookup is cheap); order is what matters.
|
||||
func candidates(word string) []string {
|
||||
out := []string{word}
|
||||
add := func(s string) {
|
||||
if len(s) >= 2 && s != word {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(word, "ies"): // studies → study
|
||||
add(word[:len(word)-3] + "y")
|
||||
case strings.HasSuffix(word, "es"): // boxes → box, wishes → wish
|
||||
add(word[:len(word)-2])
|
||||
add(word[:len(word)-1])
|
||||
case strings.HasSuffix(word, "s"): // cats → cat
|
||||
add(word[:len(word)-1])
|
||||
}
|
||||
|
||||
if strings.HasSuffix(word, "ing") { // running → run, making → make
|
||||
stem := word[:len(word)-3]
|
||||
add(stem)
|
||||
add(stem + "e")
|
||||
add(undouble(stem))
|
||||
}
|
||||
if strings.HasSuffix(word, "ed") { // hoped → hope, stopped → stop
|
||||
stem := word[:len(word)-2]
|
||||
add(stem)
|
||||
add(word[:len(word)-1])
|
||||
add(undouble(stem))
|
||||
}
|
||||
if strings.HasSuffix(word, "ly") { // happily handled above via ies path? no — quickly → quick
|
||||
add(word[:len(word)-2])
|
||||
}
|
||||
if strings.HasSuffix(word, "ily") { // happily → happy
|
||||
add(word[:len(word)-3] + "y")
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// undouble collapses a doubled final consonant (stopp → stop, runn → run) so the
|
||||
// -ed/-ing stems of doubled-consonant verbs resolve to their base form.
|
||||
func undouble(stem string) string {
|
||||
n := len(stem)
|
||||
if n >= 2 && stem[n-1] == stem[n-2] {
|
||||
return stem[:n-1]
|
||||
}
|
||||
return stem
|
||||
}
|
||||
|
||||
// toMeaning maps a compact [pos, def, example] triple from the dataset onto the
|
||||
// JSON-friendly Meaning. The dataset always stores three elements, but we guard
|
||||
// the length so a malformed row can't panic.
|
||||
func toMeaning(m []string) Meaning {
|
||||
var out Meaning
|
||||
if len(m) > 0 {
|
||||
out.PartOfSpeech = m[0]
|
||||
}
|
||||
if len(m) > 1 {
|
||||
out.Definition = m[1]
|
||||
}
|
||||
if len(m) > 2 {
|
||||
out.Example = m[2]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// gunzipJSON decompresses gz and decodes the JSON into v.
|
||||
func gunzipJSON(gz []byte, v any) error {
|
||||
r, err := gzip.NewReader(bytes.NewReader(gz))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
76
internal/lexicon/lexicon_test.go
Normal file
76
internal/lexicon/lexicon_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package lexicon
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLookupKnownWord(t *testing.T) {
|
||||
l := New()
|
||||
res, err := l.Lookup("happy")
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup: %v", err)
|
||||
}
|
||||
if len(res.Definitions) == 0 {
|
||||
t.Errorf("expected definitions for %q, got none", "happy")
|
||||
}
|
||||
if len(res.Synonyms) == 0 {
|
||||
t.Errorf("expected synonyms for %q, got none", "happy")
|
||||
}
|
||||
if len(res.Synonyms) > maxSynonyms {
|
||||
t.Errorf("synonyms not capped: got %d, want <= %d", len(res.Synonyms), maxSynonyms)
|
||||
}
|
||||
if len(res.Definitions) > maxDefinitions {
|
||||
t.Errorf("definitions not capped: got %d, want <= %d", len(res.Definitions), maxDefinitions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupMorphology(t *testing.T) {
|
||||
l := New()
|
||||
// Inflected forms should resolve to their base entry via candidates().
|
||||
for _, w := range []string{"running", "studies", "boxes", "quickly", "stopped"} {
|
||||
res, err := l.Lookup(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup(%q): %v", w, err)
|
||||
}
|
||||
if len(res.Definitions) == 0 && len(res.Synonyms) == 0 {
|
||||
t.Errorf("expected some result for inflected %q, got nothing", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupUnknownWord(t *testing.T) {
|
||||
l := New()
|
||||
res, err := l.Lookup("zzzxqqq")
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup: %v", err)
|
||||
}
|
||||
if len(res.Definitions) != 0 || len(res.Synonyms) != 0 {
|
||||
t.Errorf("expected empty result for nonsense word, got %+v", res)
|
||||
}
|
||||
// Empty result must still serialize as [] not null for the frontend.
|
||||
if res.Definitions == nil || res.Synonyms == nil {
|
||||
t.Errorf("empty slices must be non-nil for JSON []: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidates(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"cats": "cat",
|
||||
"studies": "study",
|
||||
"running": "run",
|
||||
"hoped": "hope",
|
||||
"quickly": "quick",
|
||||
"happily": "happy",
|
||||
}
|
||||
for word, want := range cases {
|
||||
got := candidates(word)
|
||||
found := false
|
||||
for _, c := range got {
|
||||
if c == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("candidates(%q) = %v, missing expected base %q", word, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,9 @@ type checkpointResponse struct {
|
||||
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
|
||||
// applies the latency-guard truncation and the checkpoint sampling parameters
|
||||
// from the spec.
|
||||
func RunCheckpoint(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
|
||||
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
|
||||
raw, err := client.Complete(ctx, CompletionRequest{
|
||||
Messages: CheckpointMessages(TruncateDoc(contentText)),
|
||||
Messages: CheckpointMessages(TruncateDoc(contentText), tone),
|
||||
MaxTokens: 1024,
|
||||
Temperature: 0.3,
|
||||
RepetitionPenalty: 1.15,
|
||||
|
||||
@@ -8,7 +8,7 @@ const checkpointSystemPrompt = `You are a warm, encouraging writing assistant he
|
||||
`Analyze the text below and identify up to 5 issues: grammar errors, unnatural phrasing, ` +
|
||||
`incorrect idiom usage, or unclear sentences that are common ESL patterns.
|
||||
|
||||
Be specific, friendly, and explain WHY each suggestion improves the writing.
|
||||
Be specific, friendly, and explain WHY each suggestion improves the writing.%s
|
||||
|
||||
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||
{
|
||||
@@ -24,11 +24,32 @@ Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||
|
||||
If the writing looks good, return: {"suggestions": []}`
|
||||
|
||||
// toneGuidance returns a sentence steering the checkpoint toward the writer's
|
||||
// chosen tone, or "" for the neutral default. The clause is appended to the
|
||||
// checkpoint instructions so the model's phrasing suggestions fit the target
|
||||
// register (e.g. an academic essay vs a casual journal). Unknown values fall
|
||||
// back to no steering, so a stray tone string is harmless.
|
||||
func toneGuidance(tone string) string {
|
||||
clause, ok := map[string]string{
|
||||
"academic": "formal, academic, and objective — suited to a school essay or research paper",
|
||||
"professional": "polished and professional — suited to a workplace email or report",
|
||||
"casual": "relaxed, friendly, and conversational",
|
||||
"humorous": "light, playful, and good-humored",
|
||||
"creative": "vivid, expressive, and imaginative — suited to a story or personal narrative",
|
||||
"persuasive": "confident and persuasive — suited to an argument or opinion piece",
|
||||
}[tone]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return "\n\nThe writer wants this document to read as " + clause + ". When phrasing could " +
|
||||
"be improved, prefer suggestions that fit that tone, and gently flag wording that clashes with it."
|
||||
}
|
||||
|
||||
// CheckpointMessages builds the message array for a grammar checkpoint over the
|
||||
// given (already-truncated) document text.
|
||||
func CheckpointMessages(contentText string) []Message {
|
||||
// given (already-truncated) document text, steered toward the document's tone.
|
||||
func CheckpointMessages(contentText, tone string) []Message {
|
||||
return []Message{
|
||||
{Role: "system", Content: checkpointSystemPrompt},
|
||||
{Role: "system", Content: fmt.Sprintf(checkpointSystemPrompt, toneGuidance(tone))},
|
||||
{Role: "user", Content: contentText},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ const VoiceInterval = 20 * time.Second
|
||||
// for a Tier-1 voice pass and parses the JSON result. It reuses the checkpoint's
|
||||
// tolerant parser and a larger token budget, since one pass may flag several
|
||||
// passages. Each flag carries a null replacement (awareness-only).
|
||||
func RunVoice(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
|
||||
// The tone argument is accepted for a uniform pass signature but ignored: voice
|
||||
// consistency is judged against the document's own established voice, not an
|
||||
// externally-chosen register.
|
||||
func RunVoice(ctx context.Context, client LLMClient, contentText, _ string) ([]RawSuggestion, error) {
|
||||
raw, err := client.Complete(ctx, CompletionRequest{
|
||||
Messages: VoiceMessages(contentText),
|
||||
MaxTokens: 2048,
|
||||
|
||||
@@ -69,8 +69,9 @@ func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// pass is the signature shared by the grammar checkpoint and the voice pass:
|
||||
// given the document text it returns the model's raw suggestions.
|
||||
type pass func(ctx context.Context, client llm.LLMClient, contentText string) ([]llm.RawSuggestion, error)
|
||||
// given the document text and the document's tone it returns the model's raw
|
||||
// suggestions. The voice pass ignores tone (see llm.RunVoice).
|
||||
type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string) ([]llm.RawSuggestion, error)
|
||||
|
||||
// runPass is the shared body for both LLM passes. It loads the document text,
|
||||
// enforces the pass's per-document rate limit, runs the model, swaps in the
|
||||
@@ -79,11 +80,11 @@ type pass func(ctx context.Context, client llm.LLMClient, contentText string) ([
|
||||
func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.RateLimiter, run pass, scope pendingScope) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
var contentText string
|
||||
var contentText, tone string
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT content_text FROM documents WHERE id = ? AND user_id = ?`,
|
||||
`SELECT content_text, tone FROM documents WHERE id = ? AND user_id = ?`,
|
||||
docID, db.LocalUserID,
|
||||
).Scan(&contentText)
|
||||
).Scan(&contentText, &tone)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
errorJSON(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
@@ -111,7 +112,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := run(r.Context(), h.Client, contentText)
|
||||
raw, err := run(r.Context(), h.Client, contentText, tone)
|
||||
if err != nil {
|
||||
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCheckpoint } from './hooks/useCheckpoint'
|
||||
import { useSpellChecker } from './hooks/useSpellChecker'
|
||||
import { DocList } from './components/DocList/DocList'
|
||||
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||
import { ToneSelect } from './components/Editor/ToneSelect'
|
||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||
@@ -16,6 +17,10 @@ export default function App() {
|
||||
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
const [wordCount, setWordCount] = useState(0)
|
||||
// The current document's target tone (steers checkpoint advice) and its live
|
||||
// plain text (drives the expanded writing-stats panel in the StatusBar).
|
||||
const [tone, setTone] = useState('general')
|
||||
const [docText, setDocText] = useState('')
|
||||
const [ready, setReady] = useState(false)
|
||||
// Distraction-free mode: entered on editor focus, collapses the doc-list
|
||||
// sidebar. Escape or a click outside the editor canvas restores it.
|
||||
@@ -49,6 +54,8 @@ export default function App() {
|
||||
setCurrentDoc(doc)
|
||||
setTitle(doc.title)
|
||||
setWordCount(doc.word_count)
|
||||
setTone(doc.tone || 'general')
|
||||
setDocText(doc.content_text)
|
||||
},
|
||||
[saveNow],
|
||||
)
|
||||
@@ -68,6 +75,8 @@ export default function App() {
|
||||
setCurrentDoc(fresh)
|
||||
setTitle(fresh.title)
|
||||
setWordCount(0)
|
||||
setTone(fresh.tone || 'general')
|
||||
setDocText('')
|
||||
} else {
|
||||
setDocs(list)
|
||||
await openDoc(list[0].id)
|
||||
@@ -90,6 +99,8 @@ export default function App() {
|
||||
setCurrentDoc(fresh)
|
||||
setTitle(fresh.title)
|
||||
setWordCount(0)
|
||||
setTone(fresh.tone || 'general')
|
||||
setDocText('')
|
||||
}, [saveNow])
|
||||
|
||||
const handleDelete = useCallback(
|
||||
@@ -104,6 +115,8 @@ export default function App() {
|
||||
setCurrentDoc(null)
|
||||
setTitle('')
|
||||
setWordCount(0)
|
||||
setTone('general')
|
||||
setDocText('')
|
||||
}
|
||||
}
|
||||
return remaining
|
||||
@@ -126,6 +139,7 @@ export default function App() {
|
||||
const handleEditorChange = useCallback(
|
||||
(change: EditorChange) => {
|
||||
setWordCount(change.word_count)
|
||||
setDocText(change.content_text)
|
||||
setEditTick((n) => n + 1)
|
||||
if (currentDoc) {
|
||||
patchSummary(currentDoc.id, { word_count: change.word_count })
|
||||
@@ -136,6 +150,20 @@ export default function App() {
|
||||
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
|
||||
)
|
||||
|
||||
// Changing the tone persists it and re-runs the checkpoint so Petal's advice
|
||||
// re-tunes to the new register. The auto-save (1.5s) lands before the
|
||||
// checkpoint debounce (4s), so the server reads the updated tone.
|
||||
const handleToneChange = useCallback(
|
||||
(value: string) => {
|
||||
setTone(value)
|
||||
if (currentDoc) {
|
||||
schedule({ tone: value })
|
||||
scheduleCheckpoint()
|
||||
}
|
||||
},
|
||||
[currentDoc, schedule, scheduleCheckpoint],
|
||||
)
|
||||
|
||||
// Accept applies the replacement in the editor (handled in EditorCore) and
|
||||
// marks the suggestion accepted; dismiss just rejects it. Both drop it locally.
|
||||
const handleAccept = useCallback(
|
||||
@@ -209,14 +237,17 @@ export default function App() {
|
||||
className="flex flex-1 flex-col overflow-y-auto px-6 py-8"
|
||||
>
|
||||
<div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Untitled"
|
||||
aria-label="Document title"
|
||||
className="mb-5 w-full bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
|
||||
style={{ fontFamily: 'var(--font-ui)' }}
|
||||
/>
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Untitled"
|
||||
aria-label="Document title"
|
||||
className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
|
||||
style={{ fontFamily: 'var(--font-ui)' }}
|
||||
/>
|
||||
<ToneSelect value={tone} onChange={handleToneChange} />
|
||||
</div>
|
||||
<EditorCore
|
||||
key={currentDoc.id}
|
||||
docId={currentDoc.id}
|
||||
@@ -236,6 +267,7 @@ export default function App() {
|
||||
<div onMouseDown={handleChromeDown}>
|
||||
<StatusBar
|
||||
wordCount={wordCount}
|
||||
text={docText}
|
||||
saveStatus={status}
|
||||
checking={checking}
|
||||
voicing={voicing}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface Document {
|
||||
title: string
|
||||
content: string // Tiptap JSON (stringified)
|
||||
content_text: string // flattened plain text
|
||||
tone: string // target writing tone; steers LLM advice
|
||||
word_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -25,9 +26,25 @@ export interface DocUpdate {
|
||||
title?: string
|
||||
content?: string
|
||||
content_text?: string
|
||||
tone?: string
|
||||
word_count?: number
|
||||
}
|
||||
|
||||
// One sense of a word from the offline dictionary.
|
||||
export interface WordMeaning {
|
||||
part_of_speech: string
|
||||
definition: string
|
||||
example?: string
|
||||
}
|
||||
|
||||
// The offline lookup for one word: a few definition senses and a list of
|
||||
// synonyms. Either list may be empty when the word isn't a headword.
|
||||
export interface WordInfo {
|
||||
word: string
|
||||
definitions: WordMeaning[]
|
||||
synonyms: string[]
|
||||
}
|
||||
|
||||
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
|
||||
|
||||
// A single LLM-proposed edit. `original` is the source of truth for placement —
|
||||
@@ -83,6 +100,9 @@ export const api = {
|
||||
dismissSuggestion: (id: string) =>
|
||||
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
||||
|
||||
// Offline word lookup (definition + synonyms) for the right-click popover.
|
||||
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
|
||||
|
||||
// Current deployed build id — changes whenever a new frontend ships. The
|
||||
// app polls this to offer a refresh. Bypasses any cache so the answer is live.
|
||||
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
|
||||
|
||||
@@ -10,7 +10,8 @@ import { SuggestionCard } from './SuggestionCard'
|
||||
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
|
||||
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
||||
import { MisspellCard } from './MisspellCard'
|
||||
import type { Suggestion } from '../../api/client'
|
||||
import { WordCard } from './WordCard'
|
||||
import { api, type Suggestion, type WordInfo } from '../../api/client'
|
||||
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||
|
||||
export interface EditorChange {
|
||||
@@ -50,6 +51,19 @@ interface MisspellState {
|
||||
left: number
|
||||
}
|
||||
|
||||
// The open right-click word popover (definition + synonyms), or null. `info` is
|
||||
// null while the offline lookup is in flight (`loading`); the card shows a
|
||||
// looking-up state until it resolves.
|
||||
interface WordInfoState {
|
||||
word: string
|
||||
from: number
|
||||
to: number
|
||||
top: number
|
||||
left: number
|
||||
loading: boolean
|
||||
info: WordInfo | null
|
||||
}
|
||||
|
||||
// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
|
||||
// spray up-and-out from a point; each reads its direction from --dx/--dy.
|
||||
const CONFETTI_DOTS = [
|
||||
@@ -112,6 +126,10 @@ export function EditorCore({
|
||||
const [hover, setHover] = useState<HoverState | null>(null)
|
||||
// The open spelling popover (click a red-underlined word), or null.
|
||||
const [misspell, setMisspell] = useState<MisspellState | null>(null)
|
||||
// The open right-click word popover (definition + synonyms), or null.
|
||||
const [wordInfo, setWordInfo] = useState<WordInfoState | null>(null)
|
||||
// Token to discard a word lookup whose popover has since closed/changed.
|
||||
const wordReqRef = useRef(0)
|
||||
// Transient confetti burst played at the last accept location.
|
||||
const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
|
||||
const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
@@ -137,8 +155,9 @@ export function EditorCore({
|
||||
},
|
||||
onFocus: () => onFocusMode?.(),
|
||||
onUpdate: ({ editor }) => {
|
||||
// Any edit shifts positions, stranding the spelling popover's anchor.
|
||||
// Any edit shifts positions, stranding the popover anchors.
|
||||
setMisspell(null)
|
||||
setWordInfo(null)
|
||||
onChange({
|
||||
content: JSON.stringify(editor.getJSON()),
|
||||
content_text: editor.getText(),
|
||||
@@ -154,6 +173,7 @@ export function EditorCore({
|
||||
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
|
||||
setHover(null)
|
||||
setMisspell(null)
|
||||
setWordInfo(null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [docId, editor])
|
||||
|
||||
@@ -186,6 +206,8 @@ export function EditorCore({
|
||||
Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth),
|
||||
)
|
||||
const top = elRect.bottom - wrapRect.top + 6
|
||||
// A suggestion card and the word popover shouldn't stack.
|
||||
setWordInfo(null)
|
||||
setHover((prev) => {
|
||||
// Moving to a different highlight resets any Ask Petal pin.
|
||||
if (prev && prev.suggestion.id !== suggestion.id) setPinned(false)
|
||||
@@ -284,6 +306,7 @@ export function EditorCore({
|
||||
const top = elRect.bottom - wrapRect.top + 6
|
||||
// Opening a spelling popover supersedes any AI-suggestion hover card.
|
||||
closeCard()
|
||||
setWordInfo(null)
|
||||
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||
},
|
||||
[editor, spellChecker, closeCard],
|
||||
@@ -304,6 +327,72 @@ export function EditorCore({
|
||||
setMisspell(null)
|
||||
}, [misspell, onAddWord])
|
||||
|
||||
// Right-click a word to look it up: resolve the exact word span under the
|
||||
// pointer, anchor a popover beneath it, and kick off the offline lookup. The
|
||||
// card opens immediately in a loading state and fills in when the (local)
|
||||
// lookup returns. Right-clicking off any word falls through to the native menu.
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!editor) return
|
||||
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||
if (!coords) return
|
||||
const range = wordAt(editor.state.doc, coords.pos)
|
||||
if (!range) return
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) return
|
||||
e.preventDefault()
|
||||
// Anchor under the word itself (not the click point) so the card lines up
|
||||
// with the text the way the spelling popover does.
|
||||
const start = editor.view.coordsAtPos(range.from)
|
||||
const end = editor.view.coordsAtPos(range.to)
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const cardWidth = 300
|
||||
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - cardWidth))
|
||||
const top = end.bottom - wrapRect.top + 6
|
||||
// Opening a word lookup supersedes any suggestion/spelling card.
|
||||
closeCard()
|
||||
setMisspell(null)
|
||||
const token = ++wordReqRef.current
|
||||
setWordInfo({ word: range.word, from: range.from, to: range.to, top, left, loading: true, info: null })
|
||||
api
|
||||
.lookupWord(range.word)
|
||||
.then((info) => {
|
||||
if (token === wordReqRef.current) {
|
||||
setWordInfo((w) => (w ? { ...w, loading: false, info } : null))
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('word lookup failed', err)
|
||||
if (token === wordReqRef.current) {
|
||||
setWordInfo((w) => (w ? { ...w, loading: false, info: null } : null))
|
||||
}
|
||||
})
|
||||
},
|
||||
[editor, closeCard],
|
||||
)
|
||||
|
||||
const replaceWord = useCallback(
|
||||
(synonym: string) => {
|
||||
if (editor && wordInfo) {
|
||||
editor.chain().focus().insertContentAt({ from: wordInfo.from, to: wordInfo.to }, synonym).run()
|
||||
}
|
||||
setWordInfo(null)
|
||||
},
|
||||
[editor, wordInfo],
|
||||
)
|
||||
|
||||
// A pointer-down outside the word popover (and not on another word, which would
|
||||
// reopen it via the context menu) closes it.
|
||||
useEffect(() => {
|
||||
if (!wordInfo) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.petal-word-card')) return
|
||||
setWordInfo(null)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [wordInfo])
|
||||
|
||||
// A pointer-down outside the popover (and not on another misspelling, which
|
||||
// would reopen it) closes the spelling card.
|
||||
useEffect(() => {
|
||||
@@ -342,9 +431,19 @@ export function EditorCore({
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
onClick={handleSpellClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<EditorContent editor={editor} className="h-full" />
|
||||
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
||||
{wordInfo && (
|
||||
<WordCard
|
||||
word={wordInfo.word}
|
||||
info={wordInfo.info}
|
||||
loading={wordInfo.loading}
|
||||
style={{ top: wordInfo.top, left: wordInfo.left }}
|
||||
onReplace={replaceWord}
|
||||
/>
|
||||
)}
|
||||
{misspell && (
|
||||
<MisspellCard
|
||||
word={misspell.word}
|
||||
|
||||
119
web/src/components/Editor/ToneSelect.tsx
Normal file
119
web/src/components/Editor/ToneSelect.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
// ToneSelect lets the writer set the document's target tone, which steers the
|
||||
// grammar-checkpoint LLM toward the right register (an academic essay vs a casual
|
||||
// journal). A small custom dropdown (not a native <select>) so it can carry the
|
||||
// bilingual zh·en labels and emoji that match Petal's chrome — the writer uses
|
||||
// Mandarin and English. The `value` strings mirror the backend's tone keys.
|
||||
|
||||
export interface ToneOption {
|
||||
value: string
|
||||
emoji: string
|
||||
zh: string
|
||||
en: string
|
||||
}
|
||||
|
||||
// Keep these `value`s in sync with llm.toneGuidance on the server. 'general'
|
||||
// means no steering (Petal's default friendly ESL advice).
|
||||
export const TONES: ToneOption[] = [
|
||||
{ value: 'general', emoji: '🌸', zh: '通用', en: 'General' },
|
||||
{ value: 'academic', emoji: '🎓', zh: '学术', en: 'Academic' },
|
||||
{ value: 'professional', emoji: '💼', zh: '专业', en: 'Professional' },
|
||||
{ value: 'casual', emoji: '☕', zh: '轻松', en: 'Casual' },
|
||||
{ value: 'humorous', emoji: '😄', zh: '幽默', en: 'Humorous' },
|
||||
{ value: 'creative', emoji: '🎨', zh: '创意', en: 'Creative' },
|
||||
{ value: 'persuasive', emoji: '📣', zh: '说服', en: 'Persuasive' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function ToneSelect({ value, onChange }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const current = TONES.find((t) => t.value === value) ?? TONES[0]
|
||||
|
||||
// Click outside closes the menu.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Document tone"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-plum)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
title="Set the tone — Petal tailors its advice to match"
|
||||
>
|
||||
<span aria-hidden>{current.emoji}</span>
|
||||
<span>{current.zh}</span>
|
||||
<span style={{ color: 'var(--color-muted)' }}>· {current.en}</span>
|
||||
<span aria-hidden style={{ color: 'var(--color-muted)' }}>
|
||||
⌄
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
className="petal-word-card absolute right-0 z-30 mt-1.5 p-1.5"
|
||||
style={{
|
||||
width: 200,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
>
|
||||
{TONES.map((t) => {
|
||||
const active = t.value === value
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => {
|
||||
onChange(t.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold"
|
||||
style={{
|
||||
background: active ? 'var(--color-surface-alt)' : 'transparent',
|
||||
color: 'var(--color-plum)',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = active ? 'var(--color-surface-alt)' : 'transparent')
|
||||
}
|
||||
>
|
||||
<span aria-hidden>{t.emoji}</span>
|
||||
<span>{t.zh}</span>
|
||||
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||||
{t.en}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
web/src/components/Editor/WordCard.tsx
Normal file
116
web/src/components/Editor/WordCard.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { WordInfo } from '../../api/client'
|
||||
|
||||
// WordCard is the right-click popover for any word: its dictionary definition(s)
|
||||
// on top and tappable synonym pills below. Clicking a synonym replaces the word
|
||||
// in place. Both datasets are offline, so this opens instantly and fills in as
|
||||
// the (local) lookup returns. Labels are bilingual (zh-first, en subtitle) to
|
||||
// match the rest of Petal's chrome — the writer uses Mandarin and English.
|
||||
|
||||
interface Props {
|
||||
word: string
|
||||
info: WordInfo | null
|
||||
loading: boolean
|
||||
style: React.CSSProperties
|
||||
onReplace: (synonym: string) => void
|
||||
}
|
||||
|
||||
export function WordCard({ word, info, loading, style, onReplace }: Props) {
|
||||
const definitions = info?.definitions ?? []
|
||||
const synonyms = info?.synonyms ?? []
|
||||
const empty = !loading && definitions.length === 0 && synonyms.length === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={`Definition and synonyms for ${word}`}
|
||||
className="petal-word-card absolute z-20 p-3.5 text-sm"
|
||||
style={{
|
||||
width: 300,
|
||||
maxHeight: 340,
|
||||
overflowY: 'auto',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||
style={{ background: 'var(--color-lavender)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
词语 · Word
|
||||
</span>
|
||||
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
||||
{word}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="mt-3 inline-flex items-center gap-1.5" style={{ color: 'var(--color-muted)' }}>
|
||||
<span
|
||||
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
查找中… · Looking up…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{definitions.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
释义 · Definition
|
||||
</p>
|
||||
<ol className="space-y-1.5">
|
||||
{definitions.map((m, i) => (
|
||||
<li key={i} className="leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||
{m.part_of_speech && (
|
||||
<span className="mr-1 italic" style={{ color: 'var(--color-accent-hover)' }}>
|
||||
{m.part_of_speech}
|
||||
</span>
|
||||
)}
|
||||
{m.definition}
|
||||
{m.example && (
|
||||
<span className="mt-0.5 block italic" style={{ color: 'var(--color-muted)' }}>
|
||||
“{m.example}”
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{synonyms.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-1.5 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
近义词 · Synonyms <span className="font-normal">(点击替换 · tap to swap)</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{synonyms.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => onReplace(s)}
|
||||
className="rounded-full px-3 py-1 text-xs font-semibold"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{empty && (
|
||||
<p className="mt-3 leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
没有找到这个词 · Nothing found for this word
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
web/src/components/StatusBar/StatsPanel.tsx
Normal file
81
web/src/components/StatusBar/StatsPanel.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from 'react'
|
||||
import { computeStats, gradeBand } from './stats'
|
||||
|
||||
// StatsPanel is the popover that opens above the word count: a small grid of
|
||||
// writing statistics computed from the live document. Bilingual zh·en labels to
|
||||
// match Petal's chrome. Reading level shows a friendly band, not just a number.
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
wordCount: number
|
||||
}
|
||||
|
||||
interface Row {
|
||||
zh: string
|
||||
en: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function StatsPanel({ text, wordCount }: Props) {
|
||||
const rows = useMemo<Row[]>(() => {
|
||||
const s = computeStats(text, wordCount)
|
||||
const band = gradeBand(s.gradeLevel)
|
||||
const fmt = (n: number, d = 0) =>
|
||||
n.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d })
|
||||
return [
|
||||
{ zh: '字数', en: 'Words', value: fmt(s.words) },
|
||||
{ zh: '字符', en: 'Characters', value: fmt(s.characters) },
|
||||
{ zh: '句子', en: 'Sentences', value: fmt(s.sentences) },
|
||||
{ zh: '段落', en: 'Paragraphs', value: fmt(s.paragraphs) },
|
||||
{ zh: '页数', en: 'Pages', value: `~${fmt(Math.max(s.pages, s.words > 0 ? 0.1 : 0), 1)}` },
|
||||
{ zh: '阅读时间', en: 'Reading time', value: readingTime(s.readingTimeMin) },
|
||||
{ zh: '平均词长', en: 'Avg word length', value: `${fmt(s.avgWordLength, 1)}` },
|
||||
{ zh: '词汇丰富度', en: 'Word variety', value: `${fmt(s.variety * 100)}%` },
|
||||
{
|
||||
zh: '阅读难度',
|
||||
en: 'Reading level',
|
||||
value: s.words > 0 ? `${band.en} · ${fmt(s.gradeLevel, 1)}` : '—',
|
||||
},
|
||||
]
|
||||
}, [text, wordCount])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Writing statistics"
|
||||
className="petal-word-card absolute bottom-7 left-0 z-30 p-3.5"
|
||||
style={{
|
||||
width: 268,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
color: 'var(--color-plum)',
|
||||
}}
|
||||
>
|
||||
<p className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
写作统计 · Writing stats
|
||||
</p>
|
||||
<dl className="space-y-1.5">
|
||||
{rows.map((r) => (
|
||||
<div key={r.en} className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<dt style={{ color: 'var(--color-muted)' }}>
|
||||
<span className="font-semibold" style={{ color: 'var(--color-plum)' }}>
|
||||
{r.zh}
|
||||
</span>{' '}
|
||||
{r.en}
|
||||
</dt>
|
||||
<dd className="font-bold tabular-nums">{r.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// readingTime renders minutes as a friendly "< 1 min" / "N min" string.
|
||||
function readingTime(min: number): string {
|
||||
if (min <= 0) return '0 min'
|
||||
if (min < 1) return '< 1 min'
|
||||
return `${Math.round(min)} min`
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||
import { StatsPanel } from './StatsPanel'
|
||||
|
||||
interface Props {
|
||||
wordCount: number
|
||||
// Live plain text of the document, for the expanded stats panel.
|
||||
text: string
|
||||
saveStatus: SaveStatus
|
||||
// True while a grammar checkpoint is in flight — shows the breathing rose dot.
|
||||
checking: boolean
|
||||
@@ -20,16 +24,44 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||
// StatusBar is the slim footer: word count on the left, save state and the
|
||||
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
||||
// circle that breathes while a check is in flight (spec → Signature animations).
|
||||
export function StatusBar({ wordCount, saveStatus, checking, voicing }: Props) {
|
||||
export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Props) {
|
||||
const label = SAVE_LABEL[saveStatus]
|
||||
// The expanded stats panel toggles open when the word count is clicked.
|
||||
const [statsOpen, setStatsOpen] = useState(false)
|
||||
const statsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!statsOpen) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!statsRef.current?.contains(e.target as Node)) setStatsOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [statsOpen])
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="flex h-9 shrink-0 items-center gap-3 px-6 text-xs"
|
||||
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
<span>
|
||||
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
||||
</span>
|
||||
<div className="relative" ref={statsRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatsOpen((o) => !o)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={statsOpen}
|
||||
className="rounded-full px-1.5 py-0.5 font-semibold transition-colors"
|
||||
style={{ color: statsOpen ? 'var(--color-accent-hover)' : 'inherit' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.color = statsOpen ? 'var(--color-accent-hover)' : 'inherit')
|
||||
}
|
||||
title="Writing stats"
|
||||
>
|
||||
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
||||
</button>
|
||||
{statsOpen && <StatsPanel text={text} wordCount={wordCount} />}
|
||||
</div>
|
||||
{checking && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
|
||||
92
web/src/components/StatusBar/stats.ts
Normal file
92
web/src/components/StatusBar/stats.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Writing statistics computed from the document's plain text. These power the
|
||||
// expanded stats panel that opens when the writer clicks the word count. The
|
||||
// reading-level formulas are English-centric (Flesch); a document mixing in
|
||||
// Mandarin still gets sensible counts, with the level treated as approximate.
|
||||
|
||||
export interface WritingStats {
|
||||
words: number
|
||||
characters: number
|
||||
charactersNoSpaces: number
|
||||
sentences: number
|
||||
paragraphs: number
|
||||
pages: number
|
||||
avgWordLength: number // letters per English word
|
||||
uniqueWords: number
|
||||
variety: number // type-token ratio, 0–1 (unique ÷ total English words)
|
||||
readingTimeMin: number
|
||||
gradeLevel: number // Flesch–Kincaid grade
|
||||
}
|
||||
|
||||
// English word tokens (letters with internal apostrophes/hyphens) — used for the
|
||||
// letter-length, syllable, and variety measures, which only make sense for
|
||||
// alphabetic words.
|
||||
const ENGLISH_WORD_RE = /[A-Za-z]+(?:['’-][A-Za-z]+)*/g
|
||||
// Sentence terminators, including the CJK fullwidth forms.
|
||||
const SENTENCE_RE = /[.!?。!?]+/g
|
||||
// Words per page (a rough double-spaced manuscript page) and reading speed.
|
||||
const WORDS_PER_PAGE = 250
|
||||
const WORDS_PER_MINUTE = 200
|
||||
|
||||
// countSyllables is the common vowel-group heuristic: count vowel runs, drop a
|
||||
// trailing silent "e"/"es"/"ed", and floor at one. Not perfect, but plenty
|
||||
// accurate for an at-a-glance reading level.
|
||||
function countSyllables(word: string): number {
|
||||
const w = word.toLowerCase().replace(/[^a-z]/g, '')
|
||||
if (w.length === 0) return 0
|
||||
if (w.length <= 3) return 1
|
||||
const trimmed = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '').replace(/^y/, '')
|
||||
const groups = trimmed.match(/[aeiouy]{1,2}/g)
|
||||
return groups ? groups.length : 1
|
||||
}
|
||||
|
||||
export function computeStats(text: string, wordCount: number): WritingStats {
|
||||
const trimmed = text.trim()
|
||||
const characters = [...text].length
|
||||
const charactersNoSpaces = text.replace(/\s/g, '').length
|
||||
|
||||
const sentenceMatches = trimmed.match(SENTENCE_RE)
|
||||
const sentences = sentenceMatches ? sentenceMatches.length : trimmed ? 1 : 0
|
||||
|
||||
const paragraphBlocks = trimmed.split(/\n{2,}/).filter((p) => p.trim().length > 0)
|
||||
const paragraphs = paragraphBlocks.length
|
||||
|
||||
const englishWords = trimmed.match(ENGLISH_WORD_RE) ?? []
|
||||
const letters = englishWords.reduce((sum, w) => sum + w.length, 0)
|
||||
const avgWordLength = englishWords.length > 0 ? letters / englishWords.length : 0
|
||||
|
||||
const unique = new Set(englishWords.map((w) => w.toLowerCase()))
|
||||
const uniqueWords = unique.size
|
||||
const variety = englishWords.length > 0 ? uniqueWords / englishWords.length : 0
|
||||
|
||||
const syllables = englishWords.reduce((sum, w) => sum + countSyllables(w), 0)
|
||||
// Flesch–Kincaid grade level; needs at least one sentence and word to be real.
|
||||
let gradeLevel = 0
|
||||
if (englishWords.length > 0 && sentences > 0) {
|
||||
gradeLevel =
|
||||
0.39 * (englishWords.length / sentences) + 11.8 * (syllables / englishWords.length) - 15.59
|
||||
if (gradeLevel < 0) gradeLevel = 0
|
||||
}
|
||||
|
||||
return {
|
||||
words: wordCount,
|
||||
characters,
|
||||
charactersNoSpaces,
|
||||
sentences,
|
||||
paragraphs,
|
||||
pages: wordCount / WORDS_PER_PAGE,
|
||||
avgWordLength,
|
||||
uniqueWords,
|
||||
variety,
|
||||
readingTimeMin: wordCount / WORDS_PER_MINUTE,
|
||||
gradeLevel,
|
||||
}
|
||||
}
|
||||
|
||||
// gradeBand turns a Flesch–Kincaid grade into a friendly bilingual descriptor —
|
||||
// far more useful to an ESL writer than a bare number.
|
||||
export function gradeBand(grade: number): { zh: string; en: string } {
|
||||
if (grade <= 5) return { zh: '简单', en: 'Easy' }
|
||||
if (grade <= 8) return { zh: '标准', en: 'Standard' }
|
||||
if (grade <= 12) return { zh: '偏难', en: 'Fairly hard' }
|
||||
return { zh: '较难', en: 'Advanced' }
|
||||
}
|
||||
@@ -156,6 +156,13 @@ button, a, input {
|
||||
animation: petal-suggestion-in 200ms ease both;
|
||||
}
|
||||
|
||||
/* --- Word lookup popover ----------------------------------------------------
|
||||
Right-click a word for its dictionary definition and synonyms. Same soft card
|
||||
treatment as the spelling popover; synonym pills swap the word on click. */
|
||||
.petal-word-card {
|
||||
animation: petal-suggestion-in 200ms ease both;
|
||||
}
|
||||
|
||||
/* --- Accept confetti --------------------------------------------------------
|
||||
A tiny CSS-only burst played where a suggestion is accepted: four colored
|
||||
dots spray up-and-out, then fade. No JS animation — each dot reads its
|
||||
|
||||
Reference in New Issue
Block a user