1 Commits

Author SHA1 Message Date
0d59b645bb Delete petal-spec.md 2026-06-26 06:00:21 +00:00
24 changed files with 32 additions and 1799 deletions

View File

@@ -15,7 +15,6 @@ import (
"gitea.parodia.dev/drwily/petal/internal/config" "gitea.parodia.dev/drwily/petal/internal/config"
"gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/docs" "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/llm"
"gitea.parodia.dev/drwily/petal/internal/suggestions" "gitea.parodia.dev/drwily/petal/internal/suggestions"
"gitea.parodia.dev/drwily/petal/web" "gitea.parodia.dev/drwily/petal/web"
@@ -68,9 +67,6 @@ func main() {
// Per-suggestion actions (accept/dismiss) under /api/suggestions. // Per-suggestion actions (accept/dismiss) under /api/suggestions.
api.Mount("/suggestions", sug.Routes()) 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). // Everything else: serve the embedded SPA (with index.html fallback for client routing).

View File

@@ -169,13 +169,6 @@ CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(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';`,
},
} }
} }

View File

@@ -21,7 +21,6 @@ type Document struct {
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` // Tiptap JSON Content string `json:"content"` // Tiptap JSON
ContentText string `json:"content_text"` // plain text for the LLM 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"` WordCount int `json:"word_count"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`

View File

@@ -81,11 +81,11 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
var doc db.Document var doc db.Document
err := h.DB.QueryRow( err := h.DB.QueryRow(
`INSERT INTO documents (user_id) VALUES (?) `INSERT INTO documents (user_id) VALUES (?)
RETURNING id, user_id, title, content, content_text, tone, word_count, created_at, updated_at`, RETURNING id, user_id, title, content, content_text, word_count, created_at, updated_at`,
db.LocalUserID, db.LocalUserID,
).Scan( ).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText, &doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
) )
if err != nil { if err != nil {
serverError(w, err) serverError(w, err)
@@ -115,7 +115,6 @@ type updateRequest struct {
Title *string `json:"title"` Title *string `json:"title"`
Content *string `json:"content"` Content *string `json:"content"`
ContentText *string `json:"content_text"` ContentText *string `json:"content_text"`
Tone *string `json:"tone"`
WordCount *int `json:"word_count"` WordCount *int `json:"word_count"`
} }
@@ -135,11 +134,10 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
SET title = COALESCE(?, title), SET title = COALESCE(?, title),
content = COALESCE(?, content), content = COALESCE(?, content),
content_text = COALESCE(?, content_text), content_text = COALESCE(?, content_text),
tone = COALESCE(?, tone),
word_count = COALESCE(?, word_count), word_count = COALESCE(?, word_count),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?`, WHERE id = ? AND user_id = ?`,
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID, req.Title, req.Content, req.ContentText, req.WordCount, id, db.LocalUserID,
) )
if err != nil { if err != nil {
serverError(w, err) serverError(w, err)
@@ -179,13 +177,13 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
func (h *Handler) fetch(id string) (db.Document, error) { func (h *Handler) fetch(id string) (db.Document, error) {
var doc db.Document var doc db.Document
err := h.DB.QueryRow( err := h.DB.QueryRow(
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at `SELECT id, user_id, title, content, content_text, word_count, created_at, updated_at
FROM documents FROM documents
WHERE id = ? AND user_id = ?`, WHERE id = ? AND user_id = ?`,
id, db.LocalUserID, id, db.LocalUserID,
).Scan( ).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText, &doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
) )
return doc, err return doc, err
} }

View File

@@ -1,21 +0,0 @@
// 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

View File

@@ -1,50 +0,0 @@
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)
}

View File

@@ -1,205 +0,0 @@
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)
}

View File

@@ -1,76 +0,0 @@
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)
}
}
}

View File

@@ -31,9 +31,9 @@ type checkpointResponse struct {
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It // RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
// applies the latency-guard truncation and the checkpoint sampling parameters // applies the latency-guard truncation and the checkpoint sampling parameters
// from the spec. // from the spec.
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) { func RunCheckpoint(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
raw, err := client.Complete(ctx, CompletionRequest{ raw, err := client.Complete(ctx, CompletionRequest{
Messages: CheckpointMessages(TruncateDoc(contentText), tone), Messages: CheckpointMessages(TruncateDoc(contentText)),
MaxTokens: 1024, MaxTokens: 1024,
Temperature: 0.3, Temperature: 0.3,
RepetitionPenalty: 1.15, RepetitionPenalty: 1.15,

View File

@@ -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, ` + `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. `incorrect idiom usage, or unclear sentences that are common ESL patterns.
Be specific, friendly, and explain WHY each suggestion improves the writing.%s Be specific, friendly, and explain WHY each suggestion improves the writing.
Respond ONLY with valid JSON. No preamble, no markdown fences. Format: Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
{ {
@@ -24,32 +24,11 @@ Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
If the writing looks good, return: {"suggestions": []}` 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 // CheckpointMessages builds the message array for a grammar checkpoint over the
// given (already-truncated) document text, steered toward the document's tone. // given (already-truncated) document text.
func CheckpointMessages(contentText, tone string) []Message { func CheckpointMessages(contentText string) []Message {
return []Message{ return []Message{
{Role: "system", Content: fmt.Sprintf(checkpointSystemPrompt, toneGuidance(tone))}, {Role: "system", Content: checkpointSystemPrompt},
{Role: "user", Content: contentText}, {Role: "user", Content: contentText},
} }
} }

View File

@@ -16,10 +16,7 @@ const VoiceInterval = 20 * time.Second
// for a Tier-1 voice pass and parses the JSON result. It reuses the checkpoint's // 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 // tolerant parser and a larger token budget, since one pass may flag several
// passages. Each flag carries a null replacement (awareness-only). // passages. Each flag carries a null replacement (awareness-only).
// The tone argument is accepted for a uniform pass signature but ignored: voice func RunVoice(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
// 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{ raw, err := client.Complete(ctx, CompletionRequest{
Messages: VoiceMessages(contentText), Messages: VoiceMessages(contentText),
MaxTokens: 2048, MaxTokens: 2048,

View File

@@ -69,9 +69,8 @@ func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
} }
// pass is the signature shared by the grammar checkpoint and the voice pass: // pass is the signature shared by the grammar checkpoint and the voice pass:
// given the document text and the document's tone it returns the model's raw // given the document text it returns the model's raw suggestions.
// suggestions. The voice pass ignores tone (see llm.RunVoice). type pass func(ctx context.Context, client llm.LLMClient, contentText string) ([]llm.RawSuggestion, error)
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, // 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 // enforces the pass's per-document rate limit, runs the model, swaps in the
@@ -80,11 +79,11 @@ type pass func(ctx context.Context, client llm.LLMClient, contentText, tone stri
func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.RateLimiter, run pass, scope pendingScope) { func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.RateLimiter, run pass, scope pendingScope) {
docID := chi.URLParam(r, "id") docID := chi.URLParam(r, "id")
var contentText, tone string var contentText string
err := h.DB.QueryRow( err := h.DB.QueryRow(
`SELECT content_text, tone FROM documents WHERE id = ? AND user_id = ?`, `SELECT content_text FROM documents WHERE id = ? AND user_id = ?`,
docID, db.LocalUserID, docID, db.LocalUserID,
).Scan(&contentText, &tone) ).Scan(&contentText)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "document not found") errorJSON(w, http.StatusNotFound, "document not found")
return return
@@ -112,7 +111,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
return return
} }
raw, err := run(r.Context(), h.Client, contentText, tone) raw, err := run(r.Context(), h.Client, contentText)
if err != nil { if err != nil {
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error()) errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
return return

View File

@@ -1,778 +0,0 @@
# Petal — AI Writing Assistant
## Project Specification for Claude Code
---
## Overview
Petal is a self-hosted, privacy-first writing editor for an ESL user. It replaces Grammarly with a warm, bubbly web app that combines Tiptap rich text editing, auto-save cloud storage, and periodic AI-powered grammar/ESL suggestions backed by a local vLLM inference endpoint.
Single-user initially (wife's Authentik account). Deployed to `write.parodia.dev`.
---
## Tech Stack
| Layer | Choice | Why |
|---|---|---|
| Frontend | React + Vite | Tiptap is React-native; best extension ecosystem |
| Editor | Tiptap v2 | ProseMirror with sane API; supports custom marks/decorations |
| Styling | Tailwind v4 | Utility-first; pairs well with custom design tokens |
| Backend | Go + chi | Consistent with rest of stack (PGX Comics, Veola) |
| Database | SQLite (modernc — pure Go, no cgo) | Simple, single-binary friendly |
| Spell check | nspell (JS, browser-side) | Zero latency, no backend call, loads hunspell dictionaries |
| AI suggestions | vLLM HTTP endpoint | Already running; configurable model + base URL |
| Auth | Authentik OIDC | Already running on parodia.dev |
| Deployment | Docker, Traefik | Consistent with existing infra |
---
## Design System
**Aesthetic:** soft, warm, bubbly. The UI should feel like a cozy stationery app, not a productivity tool.
### Color Palette
```
--color-bg: #FDF6F0 /* warm cream canvas */
--color-surface: #FFFFFF /* card/panel surfaces */
--color-surface-alt: #FFF0F5 /* rose-tinted alt surface */
--color-border: #F0E0EB /* soft pink-grey border */
--color-text: #3D2E39 /* warm dark plum, not harsh black */
--color-text-muted: #9B8CA3 /* muted lavender-grey */
--color-accent: #E8A0BF /* soft rose — primary interactive */
--color-accent-hover: #D98AAF /* slightly deeper on hover */
--color-mint: #A8D8C8 /* suggestion type: grammar */
--color-peach: #F4B8A0 /* suggestion type: phrasing */
--color-lavender: #C5B4E8 /* suggestion type: idiom */
--color-sky: #A8CCE8 /* suggestion type: clarity */
--color-honey: #CE9B4F /* suggestion type: voice — deepened amber for underline contrast on cream */
--color-success: #8FCFA8 /* saved, accepted */
--color-shadow: rgba(180, 130, 160, 0.12) /* warm-tinted shadow */
```
### Typography
- **UI / Headings:** Nunito (Google Fonts) — rounded, bubbly, friendly
- **Editor body:** Lora (Google Fonts) — warm serif, reads beautifully at paragraph length
- **Monospace / data:** JetBrains Mono (used sparingly for word counts etc.)
### Shape Language
- Border radius: `16px` on cards, `24px` on modals, `999px` on buttons/pills, `12px` on input fields
- Shadows: `0 4px 20px var(--color-shadow)` — soft, warm, lifted
- Transitions: `200ms ease` across all interactive elements
### Signature Element
Suggestion decorations animate in with a gentle fade + slight upward float (translateY 4px → 0). The acceptance animation plays a tiny confetti burst (CSS only, 34 colored dots). The pulsing LLM checkpoint indicator is a soft rose dot that breathes (opacity 0.4 → 1 → 0.4 in 2s).
---
## Directory Structure
```
petal/
├── cmd/
│ └── server/
│ └── main.go # Entry point; embeds /web/dist
├── internal/
│ ├── config/
│ │ └── config.go # Env var loading
│ ├── db/
│ │ ├── db.go # SQLite init + migrations
│ │ └── models.go # Document, Suggestion, User types
│ ├── auth/
│ │ ├── oidc.go # Authentik OIDC flow
│ │ └── middleware.go # Session auth middleware
│ ├── docs/
│ │ └── handlers.go # Document CRUD handlers
│ ├── plagiarism/
│ │ ├── copyleaks.go # Copyleaks API client
│ │ └── voice.go # Local LLM voice consistency check
│ └── llm/
│ ├── client.go # LLMClient interface + factory function
│ ├── vllm.go # vLLM concrete implementation (OpenAI-compat)
│ ├── ollama.go # Ollama concrete implementation (native API)
│ ├── checkpoint.go # Checkpoint trigger + debounce logic
│ └── prompts.go # System prompts
├── web/
│ ├── src/
│ │ ├── components/
│ │ │ ├── Editor/
│ │ │ │ ├── EditorCore.tsx # Tiptap instance + config
│ │ │ │ ├── SuggestionMark.tsx # Custom Tiptap mark extension
│ │ │ │ ├── SuggestionCard.tsx # Hover card (suggestion + explanation)
│ │ │ │ └── CheckpointIndicator.tsx
│ │ │ ├── DocList/
│ │ │ │ ├── DocList.tsx # Document browser sidebar
│ │ │ │ └── DocListItem.tsx
│ │ │ ├── Toolbar/
│ │ │ │ └── Toolbar.tsx # Formatting toolbar
│ │ │ └── StatusBar/
│ │ │ └── StatusBar.tsx # Word count, save status, checkpoint dot
│ │ ├── hooks/
│ │ │ ├── useAutoSave.ts # Debounced save (1.5s)
│ │ │ └── useCheckpoint.ts # LLM checkpoint trigger (4s pause)
│ │ ├── api/
│ │ │ └── client.ts # Fetch wrappers for all API routes
│ │ ├── App.tsx
│ │ └── main.tsx
│ ├── index.html
│ ├── package.json
│ ├── vite.config.ts
│ └── tailwind.config.ts
├── Dockerfile
├── docker-compose.yml
├── .env.example
└── README.md
```
---
## Database Schema
```sql
CREATE TABLE users (
id TEXT PRIMARY KEY, -- Authentik subject claim
email TEXT NOT NULL,
display_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE documents (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL DEFAULT 'Untitled',
content TEXT NOT NULL DEFAULT '{}', -- Tiptap JSON
content_text TEXT NOT NULL DEFAULT '', -- Plain text for LLM
word_count INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE suggestions (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
from_pos INTEGER NOT NULL,
to_pos INTEGER NOT NULL,
original TEXT NOT NULL,
replacement TEXT NOT NULL,
explanation TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice')),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE plagiarism_reports (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
backend TEXT NOT NULL DEFAULT 'copyleaks',
similarity_pct REAL, -- overall similarity score 0100
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending','complete','error')),
result_json TEXT, -- full Copyleaks response, stored raw
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
```
---
## API Routes
All routes under `/api/`. Auth middleware validates session cookie on all except `/api/auth/*`.
```
GET /api/me → current user info
POST /api/auth/login → redirect to Authentik
GET /api/auth/callback → OIDC callback, set session cookie
POST /api/auth/logout → clear session
GET /api/docs → list user's documents (id, title, word_count, updated_at)
POST /api/docs → create new document → returns full document
GET /api/docs/:id → get document + pending suggestions
PUT /api/docs/:id → update document content (auto-save endpoint)
DELETE /api/docs/:id → delete document
POST /api/docs/:id/check → run grammar checkpoint (returns new suggestions)
POST /api/docs/:id/voice → run voice-consistency pass (returns voice suggestions)
POST /api/docs/:id/plagiarism → trigger Copyleaks check (returns report id)
GET /api/docs/:id/plagiarism/latest → get latest report for document
PUT /api/suggestions/:id → update suggestion status (accepted/rejected)
DELETE /api/docs/:id/suggestions → clear all suggestions for a doc
POST /api/suggestions/:id/chat → Ask Petal conversational follow-up (SSE stream)
```
---
## Plagiarism Detection
### Architecture: Two Tiers
**Tier 1 — Voice Consistency Check (local, LLM, separate pass)**
The LLM reviews the document for passages that feel tonally inconsistent with the rest of her writing — formulaic phrasing, suspiciously polished sentences, register shifts. Surfaces these as a new suggestion type (`type: "voice"`) with a warm honey-amber decoration (`--color-honey`, distinct from the lavender used for `idiom`; deepened from a pale yellow so the underline stays legible against the cream canvas). This is not plagiarism detection; it's a writing consistency tool. It runs locally with no privacy cost and is useful for ESL writers who may have paraphrased too closely from a source.
**This is a distinct pass from the grammar checkpoint — do not bundle them.** Voice-consistency detection is inherently whole-document (it compares a passage against the established voice *everywhere else*), so it needs the full document as context and the full window is available (see Context Window Management). That makes it too expensive to fire on the fast 4s checkpoint cadence. Run it instead either:
- on a slow cadence (every 3060s of idle), separate from the grammar checkpoint debounce, or
- on an explicit "Check my voice" action in the toolbar.
Send the full `content_text` (no trailing-window truncation — the model window is 256K, the document will fit). Use a higher `max_tokens` than the grammar checkpoint since it may flag several passages. The grammar checkpoint stays fast and local-context; voice runs slow and whole-document. Each uses the context size and cadence appropriate to its job.
**Tier 2 — Academic Plagiarism Check (Copyleaks, opt-in, manual)**
A dedicated "Check for plagiarism" button in the editor toolbar. On first use, shows a consent modal explaining that document text will be sent to Copyleaks servers for comparison against web and academic databases. User must explicitly confirm. After confirmation, preference is saved per-user and the modal does not reappear.
Copyleaks checks against web content and academic paper databases — the same corpus type schools use with Turnitin. Not identical to Turnitin, but meaningful for pre-submission review.
### Copyleaks Integration
**Env vars:**
```
COPYLEAKS_ENABLED=false # off by default
COPYLEAKS_API_KEY=
COPYLEAKS_EMAIL= # account email, required by their API
```
**API flow:**
Copyleaks uses an async model — you submit text, get a scan ID, then poll or receive webhook callback when complete.
```
1. POST https://api.copyleaks.com/v3/education/submit/url/{scanId}
(or text submission endpoint)
→ 201 Accepted, scanId stored in plagiarism_reports
2. Copyleaks calls webhook: POST /api/webhooks/copyleaks
→ Go handler updates plagiarism_reports with result_json + similarity_pct + status=complete
3. Frontend polls GET /api/docs/:id/plagiarism/latest every 5s
while status=pending, renders results when complete
```
**Webhook endpoint:** `POST /api/webhooks/copyleaks` — must be publicly reachable (it is, via write.parodia.dev). No auth middleware on this route; validate using Copyleaks HMAC signature header instead.
**Results display (`PlagiarismPanel` component):**
- Slide-in right panel (same pattern as AskPetal expanded card but full height)
- Top: large similarity percentage with color coding (green <15%, amber 1530%, red >30%)
- Below: list of matched sources with URL, matched percentage, and matched passage
- Matched passages highlighted in the editor with a new red-tinted decoration type
- "Last checked: X minutes ago" + "Re-check" button
- Privacy reminder at panel footer: "Text was sent to Copyleaks for this check"
### Voice Consistency Prompt Addition
Add to the checkpoint system prompt (after existing ESL instructions):
```
Also identify any passages (2+ sentences) that feel tonally inconsistent with
the surrounding writing — unusually formal, unusually polished, or phrased in
a way that differs from the writer's established voice elsewhere in the document.
Flag these with type "voice". Do not flag the first paragraph (no baseline yet).
```
Return format addition:
```json
{ "original": "...", "replacement": null, "explanation": "This passage sounds more formal than the rest of your writing — worth reviewing.", "type": "voice" }
```
`replacement` is `null` for voice flags — there is no correction, just awareness. The frontend SuggestionCard handles `null` replacement by hiding the replacement row and showing only the explanation + Dismiss.
---
## LLM Integration
### Client Config (env vars)
```
LLM_BACKEND=vllm # vllm | ollama — default: vllm
LLM_ENDPOINT=http://192.168.x.x:8000 # vLLM default port 8000, Ollama default 11434
LLM_MODEL=gemma4 # Checkpoint model — small, fast. Treat as config, never hardcode.
LLM_CHAT_MODEL=gemma4 # Ask Petal model — can be much larger. Defaults to LLM_MODEL if unset.
LLM_TIMEOUT=30s
```
**Model sizing guidance:**
Checkpoint runs every 4 seconds while the user types — latency is UX. Keep `LLM_MODEL` at 4B7B. Gemma4 is a solid default.
Ask Petal is a deliberate conversational turn with small input context (~2,000 tokens) and short output (24 sentences). Since the user asks questions in Mandarin, `LLM_CHAT_MODEL` should be a Chinese-native model — **Qwen3.5 9B** is the right pick. Native Mandarin capability rather than learned multilingual coverage makes a real difference for grammar explanation quality. With 64GB VRAM you have plenty of headroom for a larger Qwen3 variant.
If `LLM_CHAT_MODEL` is unset, fall back to `LLM_MODEL`. Single-model setups work fine.
### Backend Abstraction
Define a `LLMClient` interface in `internal/llm/client.go`. All handler code calls the interface only — never a concrete type directly.
```go
// internal/llm/client.go
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type CompletionRequest struct {
Messages []Message
MaxTokens int
Temperature float64
RepetitionPenalty float64
TopP float64
Stop []string
Stream bool
}
type LLMClient interface {
// Complete returns the full response in one shot (used for checkpoint JSON)
Complete(ctx context.Context, req CompletionRequest) (string, error)
// Stream returns a channel of text chunks (used for Ask Petal SSE)
Stream(ctx context.Context, req CompletionRequest) (<-chan string, error)
}
// Factory — selects backend from config
func NewLLMClient(cfg *config.Config) LLMClient {
// LLM_CHAT_MODEL falls back to LLM_MODEL if unset
chatModel := cfg.LLMChatModel
if chatModel == "" {
chatModel = cfg.LLMModel
}
switch cfg.LLMBackend {
case "ollama":
return &OllamaClient{
endpoint: cfg.LLMEndpoint,
checkpointModel: cfg.LLMModel,
chatModel: chatModel,
timeout: cfg.LLMTimeout,
}
default: // "vllm"
return &VLLMClient{
endpoint: cfg.LLMEndpoint,
checkpointModel: cfg.LLMModel,
chatModel: chatModel,
timeout: cfg.LLMTimeout,
}
}
}
```
**`internal/llm/vllm.go`** — OpenAI-compatible endpoint:
```
POST {endpoint}/v1/chat/completions
Content-Type: application/json
{
"model": "{model}",
"messages": [...],
"max_tokens": {MaxTokens},
"temperature": {Temperature},
"repetition_penalty": {RepetitionPenalty},
"top_p": {TopP},
"stop": [...],
"stream": {Stream}
}
```
Streaming: SSE, parse `data:` lines, extract `choices[0].delta.content`, stop on `data: [DONE]`.
**`internal/llm/ollama.go`** — Ollama native API:
```
POST {endpoint}/api/chat
Content-Type: application/json
{
"model": "{model}",
"messages": [...],
"stream": {Stream},
"options": {
"num_predict": {MaxTokens},
"temperature": {Temperature},
"repeat_penalty": {RepetitionPenalty},
"top_p": {TopP},
"stop": [...]
}
}
```
Streaming: newline-delimited JSON objects, extract `message.content`, stop when `done: true`.
**Parameter mapping cheatsheet:**
| CompletionRequest field | vLLM key | Ollama key |
|---|---|---|
| `MaxTokens` | `max_tokens` | `options.num_predict` |
| `Temperature` | `temperature` | `options.temperature` |
| `RepetitionPenalty` | `repetition_penalty` | `options.repeat_penalty` |
| `TopP` | `top_p` | `options.top_p` |
| `Stop` | `stop` | `options.stop` |
Both backends live behind the same interface — checkpoint.go and the Ask Petal handler call `client.Complete()` and `client.Stream()` with no awareness of which backend is active.
### Checkpoint Logic
- **Trigger:** user stops typing for 4 seconds (debounced in frontend via `useCheckpoint.ts`)
- **Rate limit:** backend enforces minimum 30s between checks per document
- **Payload:** full `content_text` of the document (not just the changed section — context matters for ESL)
- **Max tokens:** 1024 (suggestions are structured JSON, should be compact)
- **Visual feedback:** pulsing rose dot in StatusBar while check in flight
### System Prompt
```
You are a warm, encouraging writing assistant helping someone who speaks English as a second language.
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.
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
{
"suggestions": [
{
"original": "exact text from the document that needs fixing",
"replacement": "corrected version",
"explanation": "friendly one-sentence explanation",
"type": "grammar|phrasing|idiom|clarity"
}
]
}
If the writing looks good, return: {"suggestions": []}
```
### Ask Petal: Conversational Follow-Up
When a user doesn't understand a suggestion, they tap **"Ask Petal ✨"** inside the suggestion card. This opens a mini chat panel anchored to the suggestion card (expands downward, max-height 320px, scrollable). The conversation is maintained in React state — not persisted to the DB. Closing the card clears the history.
**API route:** `POST /api/suggestions/:id/chat`
**Request payload:**
```json
{
"messages": [
{ "role": "user", "content": "why is this wrong?" }
]
}
```
**Server behavior:** The Go handler fetches the suggestion record (original, replacement, explanation, type) and the surrounding paragraph from the parent document's `content_text`. It injects these as system context, then appends the user's message history and streams the response back via SSE.
**Response:** SSE stream, `data:` events containing response text chunks. Frontend accumulates into assistant message bubble.
**Ask Petal System Prompt:**
```
You are Petal, a warm and patient English writing tutor helping someone who is learning English
as a second language. You are currently discussing a specific writing suggestion.
Suggestion context:
- Original text: "{{original}}"
- Suggested replacement: "{{replacement}}"
- Issue type: {{type}}
- Initial explanation: "{{explanation}}"
- Surrounding paragraph: "{{paragraph}}"
The user wants to understand this suggestion better. Detect the language of the user's message
and respond in that same language. If they write in Mandarin Chinese, respond entirely in
Mandarin. If they write in English, respond in English. Never mix languages in a single response.
Explain clearly and kindly. Use simple language appropriate to the user's message. Give examples
when helpful. If they ask "why" (or "为什么"), explain the grammar rule or idiom behind it.
If they suggest an alternative phrasing, evaluate it honestly.
Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encouraging —
learning a language is hard and they're doing great.
```
**AskPetal Component (`web/src/components/Editor/AskPetal.tsx`):**
- Renders inside the expanded SuggestionCard
- Input field at bottom, message bubbles above (Petal messages: rose-tinted left-aligned; user messages: right-aligned lavender)
- "Ask Petal ✨" trigger button is a small pill link below the suggestion explanation
- Petal's first message pre-populates with the suggestion's explanation so context is immediate
- Streaming response renders token-by-token into the latest assistant bubble
- No conversation persistence — state lives in the SuggestionCard component
### LLM Sampling Parameters & Stability
vLLM is stateless per request. There are no server-side sessions — conversation history for Ask Petal is managed entirely at the app layer (client sends full message array each request). This is correct behavior; do not attempt to implement vLLM-side session persistence.
**Checkpoint requests (structured JSON output):**
```json
{
"model": "${LLM_MODEL}",
"messages": [...],
"max_tokens": 1024,
"temperature": 0.3,
"repetition_penalty": 1.15,
"top_p": 0.9,
"stop": ["```", "\n\n\n\n"]
}
```
**Ask Petal chat requests (conversational):**
```json
{
"model": "${LLM_MODEL}",
"messages": [...],
"max_tokens": 512,
"temperature": 0.7,
"repetition_penalty": 1.15,
"top_p": 0.92,
"stop": ["\n\n\n"]
}
```
**Why these values:**
- `temperature: 0.3` for checkpoint — low enough to produce structured JSON reliably, but not zero. Zero temperature means greedy decoding, which is paradoxically *more* prone to repetition loops in smaller models, not less.
- `temperature: 0.7` for chat — conversational range; allows natural variation without going incoherent.
- `repetition_penalty: 1.15` on both — primary defense against repetition breakdown. Applies a multiplicative penalty to already-seen tokens. 1.11.2 is the safe range; above 1.3 starts degrading output quality.
- `stop` sequences — catches runaway generation before it fills the output buffer. Triple newline is a reliable signal the model has looped past the end of a coherent response.
- `max_tokens: 512` for chat — enforces conciseness and prevents the model from rambling into a degraded state mid-response.
### Context Window Management
The model window is **not** the binding constraint here — Qwen 3.5 ships a 256K context window, and on a 64GB dual-GPU setup there is ample KV-cache headroom. The truncation caps below exist to protect **checkpoint latency** (prefill time scales with input length, and the grammar checkpoint must feel responsive), not memory. Documents will almost always fit uncut; the cap only fires on unusually long ones.
**Grammar checkpoint** (fast, latency-sensitive — keep input modest):
| Component | Token Budget |
|---|---|
| System prompt | ~300 |
| Document text | ~10,000 (hard cap; trailing window) |
| Response (JSON) | ~1,024 |
| Buffer | rest of window (ample) |
**Voice-consistency pass** (slow cadence — send the whole document, no trailing-window truncation; see Plagiarism Detection → Tier 1).
**Ask Petal chat:**
| Component | Token Budget |
|---|---|
| System prompt + suggestion context | ~600 |
| Conversation history (rolling) | ~3,000 |
| Latest user message | ~200 |
| Response | ~512 |
| Buffer | ~3,880 |
**Truncation logic (implement in `internal/llm/client.go`):**
```go
const maxDocChars = 40000 // ~10000 tokens at ~4 chars/token — grammar checkpoint only
const maxHistoryMsgs = 10 // 5 turns; drop oldest pairs first
// Document truncation (grammar checkpoint) — keep the recent end (user is actively writing there).
// The cap is a latency guard, not a window limit; the model window (256K) is far larger.
// The voice-consistency pass does NOT apply this truncation — it sends the full content_text.
if len(contentText) > maxDocChars {
contentText = contentText[len(contentText)-maxDocChars:]
}
// Conversation history truncation — drop oldest messages first
if len(messages) > maxHistoryMsgs {
messages = messages[len(messages)-maxHistoryMsgs:]
}
```
Always log a `WARN` when truncation fires so it's visible in production. Never fail silently.
The Go handler receives the LLM suggestions (which reference `original` text strings) and records the plain-text offsets via `strings.Index(content_text, original)` as `from_pos` / `to_pos`. **These stored offsets are for server-side use only** (paragraph extraction for Ask Petal context) and are *not* ProseMirror positions — see Note #6. The frontend anchors decorations by searching the live document for the `original` string in ProseMirror coordinates at render time, not by trusting a numeric position. This is the single most important correctness detail in the suggestion pipeline; get it wrong and every multi-block document mis-renders.
---
## Frontend: Tiptap Configuration
### Extensions to enable
```typescript
StarterKit, // Bold, italic, headings, lists, paragraphs
Underline,
TextAlign,
Placeholder.configure({ placeholder: 'Start writing...' }),
SpellChecker, // Custom extension wrapping nspell
SuggestionMark, // Custom extension for AI suggestion decorations
CharacterCount, // For word count in StatusBar
```
### SuggestionMark Extension
Custom Tiptap mark that:
- Accepts `{ suggestionId, type }` attributes
- **Anchors by string match, not stored position** — when suggestions arrive, walk the live ProseMirror document, find the `original` text in PM coordinates, and apply the mark over that range. Do not use the server's `from_pos`/`to_pos` (those are plain-text offsets, not PM positions — see Note #6). If `original` isn't found in the current document (already edited), skip that suggestion silently.
- Renders as a colored underline (color varies by type using CSS vars)
- On hover, shows `<SuggestionCard>` positioned absolutely above the text
- SuggestionCard shows: original → replacement, explanation, Accept button, Dismiss button
- Accept → calls `PUT /api/suggestions/:id` with `status: accepted`, applies replacement via Tiptap command
- Dismiss → calls `PUT /api/suggestions/:id` with `status: rejected`, removes mark
### useAutoSave Hook
```typescript
// Debounce 1500ms after last content change
// Calls PUT /api/docs/:id with { content, content_text, word_count }
// StatusBar shows: "Saving..." → "Saved just now" → fades to nothing after 3s
```
### useCheckpoint Hook
```typescript
// Debounce 4000ms after last content change
// Fires POST /api/docs/:id/check
// On response: dispatch suggestion decorations to Tiptap editor
// Sets checkpointActive state → StatusBar indicator
```
---
## Layout
```
┌─────────────────────────────────────────────────────────────┐
│ [🌸 Petal] [New Doc] [User Avatar] │ ← Header (48px)
├──────────────┬──────────────────────────────────────────────┤
│ │ │
│ Doc List │ Editor Canvas │
│ (260px) │ (centered, max-width 720px) │
│ │ │
│ [Doc 1] │ ┌─────────────────────────────┐ │
│ [Doc 2] ← │ │ [B] [I] [U] [H1] [H2] [≡] │ │ ← Toolbar
│ [Doc 3] │ └─────────────────────────────┘ │
│ │ │
│ + New │ Title (editable, large Nunito) │
│ │ │
│ │ Body text in Lora... │
│ │ ~~~~~~ suggestion underline ~~~~~~ │
│ │ │
│ │ │
├──────────────┴──────────────────────────────────────────────┤
│ 342 words · ● Checking... · Saved just now │ ← StatusBar (36px)
└─────────────────────────────────────────────────────────────┘
```
Distraction-free mode: clicking into the editor collapses the doc list sidebar (slides left), expands editor canvas full width. Click outside canvas or press Escape to restore.
---
## Environment Config
```bash
# .env.example
# Server
PORT=8080
BASE_URL=https://write.parodia.dev
SESSION_SECRET=change-me-to-random-64-char-string
# Database
DATABASE_PATH=/data/petal.db
# Authentik OIDC
AUTHENTIK_URL=https://auth.parodia.dev
AUTHENTIK_CLIENT_ID=petal
AUTHENTIK_CLIENT_SECRET=
# LLM
LLM_BACKEND=vllm # vllm | ollama
LLM_ENDPOINT=http://192.168.x.x:8000
LLM_MODEL=gemma4 # Checkpoint: small and fast (4B7B)
LLM_CHAT_MODEL=qwen3.5:9b # Ask Petal: Qwen3.5 9B recommended for native Mandarin support
LLM_TIMEOUT=30s
```
---
## Dockerfile
```dockerfile
# Stage 1: Build frontend
FROM node:22-alpine AS frontend
WORKDIR /app/web
COPY web/package*.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# Stage 2: Build Go binary
FROM golang:1.23-alpine AS backend
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
COPY --from=frontend /app/web/dist ./web/dist
RUN CGO_ENABLED=0 go build -o petal ./cmd/server
# Stage 3: Runtime
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=backend /app/petal .
VOLUME ["/data"]
EXPOSE 8080
CMD ["./petal"]
```
---
## docker-compose.yml
```yaml
services:
petal:
image: petal:latest
restart: unless-stopped
volumes:
- petal_data:/data
env_file: .env
labels:
- "traefik.enable=true"
- "traefik.http.routers.petal.rule=Host(`write.parodia.dev`)"
- "traefik.http.routers.petal.entrypoints=web-secure"
- "traefik.http.routers.petal.tls.certresolver=default"
- "traefik.http.services.petal.loadbalancer.server.port=8080"
networks:
- traefik
volumes:
petal_data:
networks:
traefik:
external: true
```
---
## v1 Scope (Ship This)
- [x] Tiptap rich text editor (bold, italic, underline, headings H1/H2, ordered/unordered lists)
- [x] Document list sidebar (create, rename, delete)
- [x] Auto-save to SQLite (1.5s debounce)
- [x] LLM checkpoint every 4s idle → inline suggestion decorations
- [x] Suggestion hover card (replacement + explanation + accept/dismiss)
- [x] nspell browser-side spell check (English dictionary)
- [x] Word count + save status + checkpoint indicator in StatusBar
- [x] Authentik OIDC auth
- [x] Plagiarism check (two-tier: local voice consistency + opt-in Copyleaks academic check)
- [x] Ask Petal conversational follow-up chat on any suggestion
- [x] Distraction-free mode
- [x] Soft/bubbly pastel design system
- [x] Single Docker binary deployment to write.parodia.dev
## Out of Scope for v1
- Export to DOCX / PDF (v1.1)
- Mobile layout (desktop first)
- Real-time collaboration
- Version history / change tracking
- Multiple language support (English first)
- Offline mode
---
## Notes for Claude Code
1. **Model names are always config values**`LLM_MODEL` drives checkpoint, `LLM_CHAT_MODEL` drives Ask Petal. Both read from env vars. Never hardcode either. If `LLM_CHAT_MODEL` is unset, fall back to `LLM_MODEL` — handle this in the factory, not in individual handlers.
2. **modernc sqlite** (`modernc.org/sqlite`) — pure Go, no cgo, no build friction.
3. **Tiptap JSON format** is stored verbatim in `documents.content`. `content_text` is the plain text extracted for the LLM — keep these in sync on every save.
4. **nspell dictionaries** — load `en-US` dictionary files from CDN or vendor them into `web/public/dictionaries/`. Don't shell out to hunspell binary.
5. **Go embeds frontend** — use `//go:embed web/dist` in `cmd/server/main.go`. Single binary deployment.
6. **Suggestion anchoring — resolve by string, not by stored position.** The `original` text string is the source of truth for where a suggestion belongs, **not** a stored numeric position. Two reasons:
- **Coordinate mismatch:** `strings.Index(contentText, original)` returns a *plain-text* character offset. Tiptap/ProseMirror positions are **not** plain-text offsets — every node boundary (paragraph, heading, list item) consumes position units, so a plaintext offset of N does not equal ProseMirror position N. Handing a plaintext offset to a Tiptap decoration places it in the wrong spot, and the error grows with each block in the document.
- **Staleness:** the user keeps typing (auto-save every 1.5s) after a checkpoint fires. Any position captured at checkpoint time is stale by the time the suggestion is stored and rendered.
**Therefore:** the frontend resolves each suggestion at *render time* by searching the live ProseMirror document for the `original` string (in PM coordinates) and applying the decoration there. The Go handler still runs `strings.Index(contentText, original)` and stores `from_pos`/`to_pos`, but these are **plain-text offsets for server-side use only** (e.g. extracting the surrounding paragraph for Ask Petal context — see Note #10), never shipped to the client as ProseMirror positions. If `original` appears multiple times, take the first occurrence; if not found at all, discard the suggestion rather than erroring. On the client, if `original` is no longer present in the live document (the user already edited that text), silently drop the suggestion — it's obsolete.
7. **Session management** — use `gorilla/sessions` with a cookie store. Session key is `petal_session`. Store `user_id` in session after OIDC callback.
8. **Authentik app** — needs to be created in Authentik with redirect URI `https://write.parodia.dev/api/auth/callback` and the OIDC client credentials added to `.env`.
9. **Ask Petal SSE streaming** — use `text/event-stream` response in Go, flush after each token chunk. Frontend uses `fetch` with `ReadableStream` (not `EventSource` — needs POST) to accumulate tokens into the assistant bubble in real time. Don't buffer the full response.
10. **Ask Petal context injection** — the Go handler for `/api/suggestions/:id/chat` fetches the suggestion + parent document in one query, extracts the paragraph containing `from_pos` from `content_text`, and injects all of it into the system prompt before the user messages. Never trust the client to send this context — always load it server-side.
11. **LLM backend abstraction**`NewLLMClient` is the only place that references `LLM_BACKEND`. Every other package receives an `LLMClient` interface value. `checkpoint.go` and the Ask Petal handler must import only the interface, not either concrete type. This keeps adding a third backend (e.g. LM Studio) to a single new file in the future.
12. **Ollama streaming format differs from vLLM** — Ollama streams newline-delimited JSON objects (`{"message":{"content":"..."},"done":false}`), not SSE `data:` lines. The `OllamaClient.Stream()` method must handle this format; do not reuse the vLLM SSE parser for Ollama.
13. **Copyleaks async flow** — submission returns immediately with a scan ID. Results arrive via webhook, not the submission response. Store the scan ID in `plagiarism_reports` immediately on submission, update the row when the webhook fires. Frontend polls `/api/docs/:id/plagiarism/latest` every 5s while `status=pending` — don't try to make this synchronous.
14. **Copyleaks webhook HMAC** — validate the `X-Copyleaks-Signature` header on every webhook call. Reject without processing if invalid. Never skip this in production.
15. **`replacement: null` in voice suggestions** — the SuggestionCard must check for null replacement and conditionally render. Don't assume replacement is always a string.
17. **CJK font fallback in Ask Petal chat** — Nunito has no CJK coverage. The chat bubble `font-family` must include system CJK fallbacks: `'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif`. Apply this specifically to the AskPetal message bubbles, not the editor body.
18. **Mandarin detection is the model's job** — do not attempt language detection in Go or the frontend. The prompt instructs the model to detect and match. This works fine with Gemma4 which has solid Mandarin capability. If a future model swap degrades Mandarin quality, that's a prompt/model problem, not an architecture problem.

View File

@@ -5,7 +5,6 @@ import { useCheckpoint } from './hooks/useCheckpoint'
import { useSpellChecker } from './hooks/useSpellChecker' import { useSpellChecker } from './hooks/useSpellChecker'
import { DocList } from './components/DocList/DocList' import { DocList } from './components/DocList/DocList'
import { EditorCore, type EditorChange } from './components/Editor/EditorCore' import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
import { ToneSelect } from './components/Editor/ToneSelect'
import { StatusBar } from './components/StatusBar/StatusBar' import { StatusBar } from './components/StatusBar/StatusBar'
import { PetalCompanion } from './components/Companion/PetalCompanion' import { PetalCompanion } from './components/Companion/PetalCompanion'
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner' import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
@@ -17,10 +16,6 @@ export default function App() {
const [currentDoc, setCurrentDoc] = useState<Document | null>(null) const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [wordCount, setWordCount] = useState(0) 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) const [ready, setReady] = useState(false)
// Distraction-free mode: entered on editor focus, collapses the doc-list // Distraction-free mode: entered on editor focus, collapses the doc-list
// sidebar. Escape or a click outside the editor canvas restores it. // sidebar. Escape or a click outside the editor canvas restores it.
@@ -54,8 +49,6 @@ export default function App() {
setCurrentDoc(doc) setCurrentDoc(doc)
setTitle(doc.title) setTitle(doc.title)
setWordCount(doc.word_count) setWordCount(doc.word_count)
setTone(doc.tone || 'general')
setDocText(doc.content_text)
}, },
[saveNow], [saveNow],
) )
@@ -75,8 +68,6 @@ export default function App() {
setCurrentDoc(fresh) setCurrentDoc(fresh)
setTitle(fresh.title) setTitle(fresh.title)
setWordCount(0) setWordCount(0)
setTone(fresh.tone || 'general')
setDocText('')
} else { } else {
setDocs(list) setDocs(list)
await openDoc(list[0].id) await openDoc(list[0].id)
@@ -99,8 +90,6 @@ export default function App() {
setCurrentDoc(fresh) setCurrentDoc(fresh)
setTitle(fresh.title) setTitle(fresh.title)
setWordCount(0) setWordCount(0)
setTone(fresh.tone || 'general')
setDocText('')
}, [saveNow]) }, [saveNow])
const handleDelete = useCallback( const handleDelete = useCallback(
@@ -115,8 +104,6 @@ export default function App() {
setCurrentDoc(null) setCurrentDoc(null)
setTitle('') setTitle('')
setWordCount(0) setWordCount(0)
setTone('general')
setDocText('')
} }
} }
return remaining return remaining
@@ -139,7 +126,6 @@ export default function App() {
const handleEditorChange = useCallback( const handleEditorChange = useCallback(
(change: EditorChange) => { (change: EditorChange) => {
setWordCount(change.word_count) setWordCount(change.word_count)
setDocText(change.content_text)
setEditTick((n) => n + 1) setEditTick((n) => n + 1)
if (currentDoc) { if (currentDoc) {
patchSummary(currentDoc.id, { word_count: change.word_count }) patchSummary(currentDoc.id, { word_count: change.word_count })
@@ -150,20 +136,6 @@ export default function App() {
[currentDoc, patchSummary, schedule, scheduleCheckpoint], [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 // Accept applies the replacement in the editor (handled in EditorCore) and
// marks the suggestion accepted; dismiss just rejects it. Both drop it locally. // marks the suggestion accepted; dismiss just rejects it. Both drop it locally.
const handleAccept = useCallback( const handleAccept = useCallback(
@@ -237,17 +209,14 @@ export default function App() {
className="flex flex-1 flex-col overflow-y-auto px-6 py-8" 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"> <div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
<div className="mb-5 flex items-center gap-3">
<input <input
value={title} value={title}
onChange={(e) => handleTitleChange(e.target.value)} onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Untitled" placeholder="Untitled"
aria-label="Document title" aria-label="Document title"
className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none" className="mb-5 w-full bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
style={{ fontFamily: 'var(--font-ui)' }} style={{ fontFamily: 'var(--font-ui)' }}
/> />
<ToneSelect value={tone} onChange={handleToneChange} />
</div>
<EditorCore <EditorCore
key={currentDoc.id} key={currentDoc.id}
docId={currentDoc.id} docId={currentDoc.id}
@@ -267,7 +236,6 @@ export default function App() {
<div onMouseDown={handleChromeDown}> <div onMouseDown={handleChromeDown}>
<StatusBar <StatusBar
wordCount={wordCount} wordCount={wordCount}
text={docText}
saveStatus={status} saveStatus={status}
checking={checking} checking={checking}
voicing={voicing} voicing={voicing}

View File

@@ -14,7 +14,6 @@ export interface Document {
title: string title: string
content: string // Tiptap JSON (stringified) content: string // Tiptap JSON (stringified)
content_text: string // flattened plain text content_text: string // flattened plain text
tone: string // target writing tone; steers LLM advice
word_count: number word_count: number
created_at: string created_at: string
updated_at: string updated_at: string
@@ -26,25 +25,9 @@ export interface DocUpdate {
title?: string title?: string
content?: string content?: string
content_text?: string content_text?: string
tone?: string
word_count?: number 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' export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
// A single LLM-proposed edit. `original` is the source of truth for placement — // A single LLM-proposed edit. `original` is the source of truth for placement —
@@ -100,9 +83,6 @@ export const api = {
dismissSuggestion: (id: string) => dismissSuggestion: (id: string) =>
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }), 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 // 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. // app polls this to offer a refresh. Bypasses any cache so the answer is live.
version: () => req<{ version: string }>('/version', { cache: 'no-store' }), version: () => req<{ version: string }>('/version', { cache: 'no-store' }),

View File

@@ -10,8 +10,7 @@ import { SuggestionCard } from './SuggestionCard'
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight' import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck' import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
import { MisspellCard } from './MisspellCard' import { MisspellCard } from './MisspellCard'
import { WordCard } from './WordCard' import type { Suggestion } from '../../api/client'
import { api, type Suggestion, type WordInfo } from '../../api/client'
import type { SpellChecker } from '../../hooks/useSpellChecker' import type { SpellChecker } from '../../hooks/useSpellChecker'
export interface EditorChange { export interface EditorChange {
@@ -51,19 +50,6 @@ interface MisspellState {
left: number 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 // 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. // spray up-and-out from a point; each reads its direction from --dx/--dy.
const CONFETTI_DOTS = [ const CONFETTI_DOTS = [
@@ -126,10 +112,6 @@ export function EditorCore({
const [hover, setHover] = useState<HoverState | null>(null) const [hover, setHover] = useState<HoverState | null>(null)
// The open spelling popover (click a red-underlined word), or null. // The open spelling popover (click a red-underlined word), or null.
const [misspell, setMisspell] = useState<MisspellState | null>(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. // Transient confetti burst played at the last accept location.
const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null) const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined) const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
@@ -155,9 +137,8 @@ export function EditorCore({
}, },
onFocus: () => onFocusMode?.(), onFocus: () => onFocusMode?.(),
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
// Any edit shifts positions, stranding the popover anchors. // Any edit shifts positions, stranding the spelling popover's anchor.
setMisspell(null) setMisspell(null)
setWordInfo(null)
onChange({ onChange({
content: JSON.stringify(editor.getJSON()), content: JSON.stringify(editor.getJSON()),
content_text: editor.getText(), content_text: editor.getText(),
@@ -173,7 +154,6 @@ export function EditorCore({
editor.commands.setContent(parseDoc(initialContent) ?? '', false) editor.commands.setContent(parseDoc(initialContent) ?? '', false)
setHover(null) setHover(null)
setMisspell(null) setMisspell(null)
setWordInfo(null)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [docId, editor]) }, [docId, editor])
@@ -206,8 +186,6 @@ export function EditorCore({
Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth), Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth),
) )
const top = elRect.bottom - wrapRect.top + 6 const top = elRect.bottom - wrapRect.top + 6
// A suggestion card and the word popover shouldn't stack.
setWordInfo(null)
setHover((prev) => { setHover((prev) => {
// Moving to a different highlight resets any Ask Petal pin. // Moving to a different highlight resets any Ask Petal pin.
if (prev && prev.suggestion.id !== suggestion.id) setPinned(false) if (prev && prev.suggestion.id !== suggestion.id) setPinned(false)
@@ -306,7 +284,6 @@ export function EditorCore({
const top = elRect.bottom - wrapRect.top + 6 const top = elRect.bottom - wrapRect.top + 6
// Opening a spelling popover supersedes any AI-suggestion hover card. // Opening a spelling popover supersedes any AI-suggestion hover card.
closeCard() closeCard()
setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left }) setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
}, },
[editor, spellChecker, closeCard], [editor, spellChecker, closeCard],
@@ -327,72 +304,6 @@ export function EditorCore({
setMisspell(null) setMisspell(null)
}, [misspell, onAddWord]) }, [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 // A pointer-down outside the popover (and not on another misspelling, which
// would reopen it) closes the spelling card. // would reopen it) closes the spelling card.
useEffect(() => { useEffect(() => {
@@ -431,19 +342,9 @@ export function EditorCore({
onMouseOver={handleMouseOver} onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut} onMouseOut={handleMouseOut}
onClick={handleSpellClick} onClick={handleSpellClick}
onContextMenu={handleContextMenu}
> >
<EditorContent editor={editor} className="h-full" /> <EditorContent editor={editor} className="h-full" />
{confetti && <Confetti top={confetti.top} left={confetti.left} />} {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 && ( {misspell && (
<MisspellCard <MisspellCard
word={misspell.word} word={misspell.word}

View File

@@ -1,119 +0,0 @@
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>
)
}

View File

@@ -1,116 +0,0 @@
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>
)
}

View File

@@ -1,81 +0,0 @@
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`
}

View File

@@ -1,11 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave' import type { SaveStatus } from '../../hooks/useAutoSave'
import { StatsPanel } from './StatsPanel'
interface Props { interface Props {
wordCount: number wordCount: number
// Live plain text of the document, for the expanded stats panel.
text: string
saveStatus: SaveStatus saveStatus: SaveStatus
// True while a grammar checkpoint is in flight — shows the breathing rose dot. // True while a grammar checkpoint is in flight — shows the breathing rose dot.
checking: boolean checking: boolean
@@ -24,44 +20,16 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
// StatusBar is the slim footer: word count on the left, save state and the // StatusBar is the slim footer: word count on the left, save state and the
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose // grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
// circle that breathes while a check is in flight (spec → Signature animations). // circle that breathes while a check is in flight (spec → Signature animations).
export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Props) { export function StatusBar({ wordCount, saveStatus, checking, voicing }: Props) {
const label = SAVE_LABEL[saveStatus] 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 ( return (
<footer <footer
className="flex h-9 shrink-0 items-center gap-3 px-6 text-xs" 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)' }} style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
> >
<div className="relative" ref={statsRef}> <span>
<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'} {wordCount} {wordCount === 1 ? 'word' : 'words'}
</button> </span>
{statsOpen && <StatsPanel text={text} wordCount={wordCount} />}
</div>
{checking && ( {checking && (
<> <>
<span aria-hidden>·</span> <span aria-hidden>·</span>

View File

@@ -1,92 +0,0 @@
// 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, 01 (unique ÷ total English words)
readingTimeMin: number
gradeLevel: number // FleschKincaid 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)
// FleschKincaid 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 FleschKincaid 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' }
}

View File

@@ -156,13 +156,6 @@ button, a, input {
animation: petal-suggestion-in 200ms ease both; 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 -------------------------------------------------------- /* --- Accept confetti --------------------------------------------------------
A tiny CSS-only burst played where a suggestion is accepted: four colored 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 dots spray up-and-out, then fade. No JS animation — each dot reads its