diff --git a/cmd/server/main.go b/cmd/server/main.go index e130e23..dd51762 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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). diff --git a/internal/db/db.go b/internal/db/db.go index 1904f8b..b3cfce5 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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';`, + }, } } diff --git a/internal/db/models.go b/internal/db/models.go index 2b57480..a3daa5e 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -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"` diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go index 9e6e63a..9f2858d 100644 --- a/internal/docs/handlers.go +++ b/internal/docs/handlers.go @@ -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 } diff --git a/internal/lexicon/data.go b/internal/lexicon/data.go new file mode 100644 index 0000000..3417921 --- /dev/null +++ b/internal/lexicon/data.go @@ -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 diff --git a/internal/lexicon/data/definitions.json.gz b/internal/lexicon/data/definitions.json.gz new file mode 100644 index 0000000..3bcafcb Binary files /dev/null and b/internal/lexicon/data/definitions.json.gz differ diff --git a/internal/lexicon/data/synonyms.json.gz b/internal/lexicon/data/synonyms.json.gz new file mode 100644 index 0000000..1d904e9 Binary files /dev/null and b/internal/lexicon/data/synonyms.json.gz differ diff --git a/internal/lexicon/handlers.go b/internal/lexicon/handlers.go new file mode 100644 index 0000000..0554bcb --- /dev/null +++ b/internal/lexicon/handlers.go @@ -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) +} diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go new file mode 100644 index 0000000..aeb4af8 --- /dev/null +++ b/internal/lexicon/lexicon.go @@ -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) +} diff --git a/internal/lexicon/lexicon_test.go b/internal/lexicon/lexicon_test.go new file mode 100644 index 0000000..29d52b1 --- /dev/null +++ b/internal/lexicon/lexicon_test.go @@ -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) + } + } +} diff --git a/internal/llm/checkpoint.go b/internal/llm/checkpoint.go index 061eb95..714c16e 100644 --- a/internal/llm/checkpoint.go +++ b/internal/llm/checkpoint.go @@ -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, diff --git a/internal/llm/prompts.go b/internal/llm/prompts.go index caed85b..8a9179e 100644 --- a/internal/llm/prompts.go +++ b/internal/llm/prompts.go @@ -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}, } } diff --git a/internal/llm/voice.go b/internal/llm/voice.go index 14d06f0..fdb5512 100644 --- a/internal/llm/voice.go +++ b/internal/llm/voice.go @@ -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, diff --git a/internal/suggestions/handlers.go b/internal/suggestions/handlers.go index 42be992..8c48e22 100644 --- a/internal/suggestions/handlers.go +++ b/internal/suggestions/handlers.go @@ -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 diff --git a/web/src/App.tsx b/web/src/App.tsx index 08e95e7..d57f11f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(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" >
- 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)' }} - /> +
+ 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)' }} + /> + +
req(`/suggestions/${id}/dismiss`, { method: 'POST' }), + // Offline word lookup (definition + synonyms) for the right-click popover. + lookupWord: (word: string) => req(`/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' }), diff --git a/web/src/components/Editor/EditorCore.tsx b/web/src/components/Editor/EditorCore.tsx index 5badff3..917b211 100644 --- a/web/src/components/Editor/EditorCore.tsx +++ b/web/src/components/Editor/EditorCore.tsx @@ -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(null) // The open spelling popover (click a red-underlined word), or null. const [misspell, setMisspell] = useState(null) + // The open right-click word popover (definition + synonyms), or null. + const [wordInfo, setWordInfo] = useState(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>(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} > {confetti && } + {wordInfo && ( + + )} {misspell && ( ) 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(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 ( +
+ + + {open && ( +
+ {TONES.map((t) => { + const active = t.value === value + return ( + + ) + })} +
+ )} +
+ ) +} diff --git a/web/src/components/Editor/WordCard.tsx b/web/src/components/Editor/WordCard.tsx new file mode 100644 index 0000000..e73dfcc --- /dev/null +++ b/web/src/components/Editor/WordCard.tsx @@ -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 ( +
+
+ + 词语 · Word + + + {word} + +
+ + {loading && ( +
+ + 查找中… · Looking up… +
+ )} + + {definitions.length > 0 && ( +
+

+ 释义 · Definition +

+
    + {definitions.map((m, i) => ( +
  1. + {m.part_of_speech && ( + + {m.part_of_speech} + + )} + {m.definition} + {m.example && ( + + “{m.example}” + + )} +
  2. + ))} +
+
+ )} + + {synonyms.length > 0 && ( +
+

+ 近义词 · Synonyms (点击替换 · tap to swap) +

+
+ {synonyms.map((s) => ( + + ))} +
+
+ )} + + {empty && ( +

+ 没有找到这个词 · Nothing found for this word +

+ )} +
+ ) +} diff --git a/web/src/components/StatusBar/StatsPanel.tsx b/web/src/components/StatusBar/StatsPanel.tsx new file mode 100644 index 0000000..986e5c7 --- /dev/null +++ b/web/src/components/StatusBar/StatsPanel.tsx @@ -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(() => { + 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 ( +
+

+ 写作统计 · Writing stats +

+
+ {rows.map((r) => ( +
+
+ + {r.zh} + {' '} + {r.en} +
+
{r.value}
+
+ ))} +
+
+ ) +} + +// 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` +} diff --git a/web/src/components/StatusBar/StatusBar.tsx b/web/src/components/StatusBar/StatusBar.tsx index f7c40ed..bef23e9 100644 --- a/web/src/components/StatusBar/StatusBar.tsx +++ b/web/src/components/StatusBar/StatusBar.tsx @@ -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 = { // 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(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 (