Compare commits
12 Commits
e72d48d64e
...
a634994d25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a634994d25 | ||
|
|
660f00d452 | ||
|
|
183219b5de | ||
|
|
97f99b27e4 | ||
|
|
8a32dde587 | ||
|
|
534f7262ab | ||
|
|
0fa70979a0 | ||
|
|
3c5f3ecb96 | ||
|
|
3f7e705028 | ||
|
|
a4069d5755 | ||
|
|
5e00cdce88 | ||
|
|
9c98e97030 |
@@ -7,6 +7,7 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
|||||||
- **Copyleaks deferred** — needs a public webhook; skip Tier-2 plagiarism until there's a public endpoint. Tier-1 voice-consistency (local) is in scope.
|
- **Copyleaks deferred** — needs a public webhook; skip Tier-2 plagiarism until there's a public endpoint. Tier-1 voice-consistency (local) is in scope.
|
||||||
- **Traefik/deploy deferred** — local dev first.
|
- **Traefik/deploy deferred** — local dev first.
|
||||||
- **LLM**: Qwen 3.5 (256K context) on 64GB dual-GPU. Grammar checkpoint cap ~10K tokens (latency guard); voice pass sends whole document.
|
- **LLM**: Qwen 3.5 (256K context) on 64GB dual-GPU. Grammar checkpoint cap ~10K tokens (latency guard); voice pass sends whole document.
|
||||||
|
- **Reasoning models**: the Ollama client sends `"think": false` on every request. Qwen 3.5 is a reasoning model — left on, it streams chain-of-thought into a separate `thinking` field and exhausts `num_predict` before emitting any answer in `content` (empty response). Non-thinking models ignore the flag. (Validated on deployment hardware 2026-06-25.)
|
||||||
- **Suggestion anchoring**: resolve by `original` string in ProseMirror coords at render time; stored `from_pos`/`to_pos` are plaintext offsets for server-side use only. (Spec Note #6.)
|
- **Suggestion anchoring**: resolve by `original` string in ProseMirror coords at render time; stored `from_pos`/`to_pos` are plaintext offsets for server-side use only. (Spec Note #6.)
|
||||||
- **Aesthetic is an acceptance criterion**: pretty, warm, Chinese-woman-friendly; CJK fonts first-class.
|
- **Aesthetic is an acceptance criterion**: pretty, warm, Chinese-woman-friendly; CJK fonts first-class.
|
||||||
|
|
||||||
@@ -21,46 +22,57 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
|||||||
- [x] Dev workflow documented in README; `.env.example` added
|
- [x] Dev workflow documented in README; `.env.example` added
|
||||||
- [x] Verified end-to-end: binary serves `/api/health` + embedded SPA + SPA fallback
|
- [x] Verified end-to-end: binary serves `/api/health` + embedded SPA + SPA fallback
|
||||||
|
|
||||||
### Phase 1 — Data layer
|
### Phase 1 — Data layer ✅
|
||||||
- [ ] SQLite (modernc) init + migrations (`internal/db/db.go`)
|
- [x] SQLite (modernc) init + migrations (`internal/db/db.go`) — versioned `schema_migrations` runner, WAL + foreign keys, single writer conn
|
||||||
- [ ] Models: User, Document, Suggestion (`internal/db/models.go`)
|
- [x] Models: User, Document, Suggestion (`internal/db/models.go`) — + type/status constants
|
||||||
- [ ] Seed hardcoded `local` user
|
- [x] Seed hardcoded `local` user (idempotent on startup)
|
||||||
- [ ] Schema includes `voice` in suggestions type CHECK
|
- [x] Schema includes `voice` in suggestions type CHECK (full spec schema incl. plagiarism_reports, to avoid a later migration)
|
||||||
|
- [x] `db.Open` wired into `cmd/server/main.go`; `db_test.go` covers migrate/seed idempotency, CHECK constraint, FK cascade
|
||||||
|
|
||||||
### Phase 2 — Document CRUD + auto-save ← first "it works" milestone
|
### Phase 2 — Document CRUD + auto-save ← first "it works" milestone ✅
|
||||||
- [ ] Doc handlers: list/create/get/update/delete (`internal/docs/handlers.go`)
|
- [x] Doc handlers: list/create/get/update/delete (`internal/docs/handlers.go`) — chi sub-router mounted at `/api/docs`, all scoped to `local` user, partial-update via COALESCE so rename and full save share one PUT; `handlers_test.go` covers the lifecycle
|
||||||
- [ ] Frontend DocList sidebar (create/rename/delete)
|
- [x] Frontend DocList sidebar (create/rename/delete) — `DocList`/`DocListItem`, optimistic title/word-count patching
|
||||||
- [ ] Tiptap EditorCore (StarterKit, Underline, TextAlign, Placeholder, CharacterCount)
|
- [x] Tiptap EditorCore (StarterKit, Underline, TextAlign, Placeholder, CharacterCount) + inline `Toolbar` (B/I/U, H1/H2, bullets, align)
|
||||||
- [ ] `useAutoSave` (1.5s debounce) → PUT /api/docs/:id
|
- [x] `useAutoSave` (1.5s debounce) → PUT /api/docs/:id, with `saveNow()` flush before doc switch/create
|
||||||
- [ ] StatusBar: word count + save status
|
- [x] StatusBar: word count + save status (Editing→Saving→Saved, fades after 3s)
|
||||||
- [ ] Keep `content` (Tiptap JSON) and `content_text` (plain) in sync on save
|
- [x] Keep `content` (Tiptap JSON) and `content_text` (plain) in sync on save — editor emits both + word_count together
|
||||||
|
|
||||||
### Phase 3 — LLM grammar checkpoint
|
### Phase 3 — LLM grammar checkpoint ✅
|
||||||
- [ ] `LLMClient` interface + factory (`internal/llm/client.go`)
|
- [x] `LLMClient` interface + factory (`internal/llm/client.go`) — chat-model fallback in factory; doc/history truncation helpers
|
||||||
- [ ] `vllm.go` (OpenAI-compat), `ollama.go` (native) — both behind interface
|
- [x] `vllm.go` (OpenAI-compat), `ollama.go` (native) — both behind interface; Complete + Stream; no client-level timeout (ctx deadline for Complete, open stream for SSE)
|
||||||
- [ ] `checkpoint.go` (30s/doc rate limit), `prompts.go`
|
- [x] `checkpoint.go` (30s/doc `RateLimiter`), `prompts.go` — brace-matched JSON salvage from model output, empty-original drop, type normalization
|
||||||
- [ ] `POST /api/docs/:id/check`
|
- [x] `POST /api/docs/:id/check` (+ `GET /api/docs/:id/suggestions`, `POST /api/suggestions/:id/{accept,dismiss}`) in `internal/suggestions`; replaces pending set per check, leaves accepted/rejected as history; throttled checks return current set
|
||||||
- [ ] `useCheckpoint` (4s debounce) + checkpoint indicator
|
- [x] `useCheckpoint` (4s debounce) + breathing rose checkpoint dot in StatusBar
|
||||||
- [ ] SuggestionMark + SuggestionCard (accept/dismiss); **string-anchoring**, not stored pos
|
- [x] `SuggestionHighlight` (ProseMirror **decorations**, re-anchored by `original` string on every doc change — not stored marks) + `SuggestionCard` (accept applies replacement in-editor then PATCHes; dismiss)
|
||||||
- [ ] Suggestion colors: grammar=mint, phrasing=peach, idiom=lavender, clarity=sky
|
- [x] Suggestion colors: grammar=mint, phrasing=peach, idiom=lavender, clarity=sky (honey reserved for voice)
|
||||||
|
|
||||||
### Phase 4 — Ask Petal (conversational follow-up)
|
### Phase 4 — Ask Petal (conversational follow-up) ✅
|
||||||
- [ ] `POST /api/suggestions/:id/chat` SSE streaming; server-side context injection
|
- [x] `POST /api/suggestions/:id/chat` SSE streaming; server-side context injection — `internal/suggestions/chat.go` loads the suggestion + parent doc in one user-scoped query, extracts the `\n\n`-bounded paragraph around `from_pos` (falls back to truncated doc when `from_pos == -1`), injects it via `AskPetalSystemPrompt`, streams `event: token`/`event: done` SSE frames (JSON-encoded data so token newlines don't break framing). LLM-unreachable returns a clean 502 before any SSE headers; unknown suggestion 404s.
|
||||||
- [ ] AskPetal component, token-by-token render, no persistence
|
- [x] AskPetal component, token-by-token render, no persistence — `AskPetal.tsx` holds the whole conversation in component state (cleared on close), pre-seeds Petal's first bubble with the suggestion explanation, streams via `streamSuggestionChat` (fetch + ReadableStream, not EventSource). `SuggestionCard` gains an "Ask Petal ✨" pill; the card pins open (hover-close suppressed, click-away to dismiss) while the panel is expanded.
|
||||||
- [ ] CJK font fallbacks on chat bubbles (spec Note #17)
|
- [x] CJK font fallbacks on chat bubbles (spec Note #17) — bubbles + input use the `'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC'` stack (the user asks in Mandarin); applied to the chat surface only, not the serif editor body.
|
||||||
|
- `internal/llm/chat.go`: `StreamAskPetal` (max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop `\n\n\n`) reusing the existing `AskPetalSystemPrompt` + `TrimHistory`. Backend stays interface-only; the SSE handler never touches a concrete client.
|
||||||
|
|
||||||
### Phase 5 — Voice consistency pass (Tier 1)
|
### Phase 5 — Voice consistency pass (Tier 1) ✅
|
||||||
- [ ] `POST /api/docs/:id/voice`, whole-document, slow cadence / explicit action
|
- [x] `POST /api/docs/:id/voice`, whole-document (no `TruncateDoc`), explicit "Check my voice 🍯" toolbar action — `internal/llm/voice.go` (`RunVoice`, `VoiceInterval` 20s floor, MaxTokens 2048), `voiceSystemPrompt`/`VoiceMessages` in `prompts.go` (standalone — not bundled with the grammar checkpoint per spec)
|
||||||
- [ ] `voice` suggestion type, honey decoration (`--color-honey`)
|
- [x] `voice` suggestion type, honey decoration — type already in schema/CSS; voice flags carry `replacement: null` → stored `""`, `SuggestionCard` hides the diff row + Accept (Dismiss only)
|
||||||
|
- [x] **Family-scoped pending sets**: grammar and voice are independent passes sharing the suggestions table. `replacePending` now scopes its DELETE by family (`pendingScope`: grammar = `type != 'voice'`, voice = `type = 'voice'`), so neither pass wipes the other's pending flags. Both `/check` and `/voice` return the **unified** pending set (grammar + voice) so the client never drops one family's highlights when the other refreshes (also fixes a latent throttle-vs-success inconsistency).
|
||||||
|
- [x] Frontend: `api.voiceDoc`, `useCheckpoint` gains `voicing`/`runVoice` (shares the run-token guard), `Toolbar` honey "Check my voice 🍯" pill (loading→"Reading…"), `StatusBar` breathing honey dot "Reading your voice…". `check`/`voice` collapsed into a shared `runPass` server-side.
|
||||||
|
- Tests: `TestVoicePassCoexists` (grammar+voice coexist, unified response, null→"" replacement, voice re-run scoped). go build/vet/test clean, tsc clean, vite build OK; live smoke vs a fake vLLM (voice anchored at 62, grammar preserved, unified list `[grammar, voice]`).
|
||||||
|
- **Known limitation (carried from Phase 3)**: `findRange` anchors within a single textblock, so a voice passage spanning a paragraph break (`\n\n`) won't decorate. Model passages usually sit within one paragraph; multi-block anchoring is deferred.
|
||||||
|
|
||||||
### Phase 6 — Design system & polish
|
### Phase 6 — Design system & polish ✅
|
||||||
- [ ] Full pastel tokens, Nunito + Lora + JetBrains Mono
|
- [x] Full pastel tokens, Nunito + Lora + JetBrains Mono — `@theme` tokens + Google Fonts (landed in Phase 0, in use throughout)
|
||||||
- [ ] Shape language, shadows, transitions
|
- [x] Shape language, shadows, transitions — `--radius-*`, `--shadow-soft`, global `200ms ease` on interactive elements
|
||||||
- [ ] Signature animations (suggestion fade-float, accept confetti, breathing checkpoint dot)
|
- [x] Signature animations (suggestion fade-float, accept confetti, breathing checkpoint dot) — fade-float + breathing dot already live; **accept confetti** added this phase: CSS-only 4-dot burst (`petal-confetti`/`@keyframes petal-confetti`, direction via `--dx`/`--dy` inline), spawned in `EditorCore.handleAccept` at the card position, auto-cleared after 720ms
|
||||||
- [ ] Distraction-free mode
|
- [x] Distraction-free mode — entered on editor focus (`EditorCore` `onFocus` → `App.setFocusMode`), the doc-list sidebar slides left + collapses to 0 width (`.petal-sidebar`/`.petal-sidebar-hidden`, 280ms), editor canvas re-centers full-width. Restored by Escape or a pointer-down outside the centered canvas (gutters, header, status bar via `handleChromeDown` + `canvasRef` containment check)
|
||||||
|
- [x] **Companion mascot** (`web/src/components/Companion/`) — cozy corner mascot that reacts to the writing session. `useCompanion` is the library-agnostic behavior engine (cheers on accept/milestones, Mandarin-first writing tips, screen-break reminders after a long stretch, idle naps + welcome-back); `PetalCompanion` renders it + a CJK-first speech bubble (zh prominent, en subtitle — Note #17). Animation via `lottie-web/build/player/lottie_light` (offline, no eval/CDN) behind a `LottiePlayer` wrapper that **auto-crops the asset to its content bbox** (unions getBBox across 6 frames → square viewBox) so stock files with empty artboard padding fill the badge. **Selectable companions** (`companions.ts` roster): clicking the mascot opens a bilingual picker ("选个小伙伴 · Choose a companion") to switch between **瞌睡猫 Sleepy Cat** (`sleeping-cat.json`, `alwaysAsleep` → every mood maps to the sleeping loop, so she snoozes yet still mumbles tips/cheers — a deliberate gag) and **开心狗 Happy Dog** (`happy-dog.json`, awake/bouncy; naps via 😴 emoji). Choice persists in `localStorage` (`petal.companion`). Add a companion = drop a pure-vector Lottie JSON in `animations/` + append a `COMPANIONS` entry. `napping = companion.alwaysAsleep || mood === 'sleeping'` drives the sway/zzz. Each asset is auto-cropped to its content bbox by `LottiePlayer`. App feeds it `editTick`/`acceptTick` + `wordCount`/`saveStatus`. All copy bilingual in `tips.ts`. (`resolveJsonModule` enabled in tsconfig for the JSON import.)
|
||||||
|
|
||||||
### Phase 7 — Spell check
|
### Phase 7 — Spell check ✅
|
||||||
- [ ] nspell browser-side (en-US), vendor dictionaries
|
- [x] nspell browser-side (en-US), vendor dictionaries — Hunspell `en.aff`/`en.dic` (from `dictionary-en`, now a devDep) vendored into `web/public/dictionaries/en/` (+ upstream `LICENSE`); Vite copies them to `dist/`, the Go binary embeds them. ~550KB `.dic` stays out of the JS bundle, fetched as a static asset.
|
||||||
|
- [x] `useSpellChecker` hook (App-level, loads **once per session** not per doc) — `fetch`es aff+dic, builds an `nspell` instance, replays a personal word list from `localStorage` (`petal.spell.personal`); `addWord` persists + bumps a `version` so the checker's identity changes and consumers re-decorate. Exposes a minimal `SpellChecker` ({ `correct`, `suggest` }). Ambient types in `src/types/nspell.d.ts` (package ships none).
|
||||||
|
- [x] `SpellCheck` Tiptap extension — ProseMirror **decorations** (no stored marks, same as the AI-suggestion layer), recomputed on doc edit / caret move / checker swap. English-only tokenizer (`/[A-Za-z][A-Za-z']*/`) so **CJK is never tokenized → never flagged** (north-star: the user writes Mandarin + English); skips <2-char tokens and all-caps acronyms, trims edge apostrophes. Exempts the word under the caret (no jitter mid-typing). Reuses `mapOffset` (now exported from `SuggestionHighlight`) for atom-aware offset→PM-pos mapping. `wordAt(doc, pos)` resolves the exact span under a click (robust to duplicate misspellings).
|
||||||
|
- [x] `MisspellCard` popover + EditorCore wiring — soft **rose wavy underline** (`.petal-misspelling`, pastel take on the red squiggle, not classic red). Click a flagged word → `posAtCoords`→`wordAt` opens a bilingual card ("拼写 · Spelling") with up to 5 nspell corrections as pills (click to replace via `insertContentAt`) + "添加到词典 · Add to dictionary". Closes on outside-pointer-down, doc edit, or doc switch.
|
||||||
|
- Verified: tsc clean, vite build OK (dict in `dist/dictionaries/en/`), go build/vet clean; live server serves both dict files (200, 3086B aff / 551762B dic); nspell smoke (`helllo→hello`, `recieve→receive`, `写作` untokenized, `NASA` ok, `add()` persists).
|
||||||
|
|
||||||
### Deferred (post-v1-local)
|
### Deferred (post-v1-local)
|
||||||
- [ ] Authentik OIDC auth + session middleware
|
- [ ] Authentik OIDC auth + session middleware
|
||||||
@@ -70,3 +82,11 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
|||||||
## Session log
|
## Session log
|
||||||
- 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created.
|
- 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created.
|
||||||
- 2026-06-25: **Phase 0 complete.** Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: **Phase 1 (data layer)** — SQLite via modernc, models, seed `local` user.
|
- 2026-06-25: **Phase 0 complete.** Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: **Phase 1 (data layer)** — SQLite via modernc, models, seed `local` user.
|
||||||
|
- 2026-06-25: **Phase 1 complete.** `internal/db` package: modernc.org/sqlite (pulled go toolchain → 1.25), `Open()` does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired into `main.go`; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes `petal.db`. Next: **Phase 2 (document CRUD + auto-save)** — first "it works" milestone.
|
||||||
|
- 2026-06-25: **Phase 2 complete.** Backend `internal/docs`: chi sub-router (list/create/get/update/delete) mounted at `/api/docs`, local-user scoped, RETURNING on create, COALESCE partial-update (one PUT serves rename + full save), 404/400 JSON errors; `handlers_test.go` walks the full lifecycle. Frontend: `api/client.ts`, `useAutoSave` (1.5s debounce + `saveNow` flush), `EditorCore` (Tiptap StarterKit/Underline/TextAlign/Placeholder/CharacterCount) + `Toolbar`, `DocList`/`DocListItem`, `StatusBar`, rewritten `App.tsx` orchestrating load/select/create/delete with optimistic sidebar patching. `.petal-prose` styles (Lora body, Nunito headings). tsc clean, vite build OK, go build OK; smoke-tested full CRUD incl. CJK title round-trip + SPA serve. Next: **Phase 3 (LLM grammar checkpoint).**
|
||||||
|
- 2026-06-25: **Phase 3 complete.** Backend `internal/llm`: `LLMClient` interface + factory (vLLM OpenAI-compat + Ollama native, both Complete/Stream), `prompts.go` (checkpoint + Ask Petal templates), `checkpoint.go` (brace-matched JSON salvage, per-doc 30s `RateLimiter`, doc/history truncation). `internal/suggestions`: `/api/docs/:id/check` + `:id/suggestions` + `/api/suggestions/:id/{accept,dismiss}`; each check replaces the pending set in a tx (accepted/rejected kept as history), throttled checks return the current set, positions located by `strings.Index` (advisory only). Frontend: `useCheckpoint` (4s debounce, loads existing on doc open, run-token guards stale responses), `SuggestionHighlight` Tiptap extension rendering ProseMirror **decorations** re-anchored by `original` string on every doc change (precise textblock offset→PM-pos mapping, handles inline atoms), `SuggestionCard` (type-colored tag, original→replacement diff, accept applies replacement in-editor + PATCHes, hover-bridge with close delay), breathing rose checkpoint dot in StatusBar, suggestion fade-float + breathe CSS. Tests: llm parse/rate-limit/truncate, suggestions full flow + rate-limit over httptest with a stub client. go build/vet/test clean, tsc clean, vite build OK; end-to-end smoke-tested against a fake vLLM endpoint (anchoring verified: `I has`→0:5, `two apple`→6:15) and 502 path when LLM unreachable. Next: **Phase 4 (Ask Petal SSE chat).**
|
||||||
|
- 2026-06-25: **Phase 5 complete.** Tier-1 voice-consistency pass. Backend: `internal/llm/voice.go` (`RunVoice` — whole document, no `TruncateDoc`, MaxTokens 2048, `VoiceInterval` 20s per-doc floor), standalone `voiceSystemPrompt`/`VoiceMessages` (not bundled with the grammar checkpoint). `internal/suggestions`: `POST /api/docs/:id/voice` route; `check`/`voice` collapsed into a shared `runPass(limiter, pass, scope)`; `pendingScope` makes `replacePending` family-aware (grammar deletes `type != 'voice'`, voice deletes `type = 'voice'`), so the two passes never clobber each other's pending flags; both endpoints now return the **unified** pending set (also fixed a latent throttle-returns-full-set vs success-returns-batch inconsistency). Frontend: `api.voiceDoc`, `useCheckpoint` → `voicing`/`runVoice` (shared run-token guard, reset on doc switch), honey "Check my voice 🍯" pill in `Toolbar` (→ "Reading…" while in flight), breathing honey dot + "Reading your voice…" in `StatusBar`. Voice flags' `replacement: null` round-trips to `""`; `SuggestionCard` already hides the diff row + Accept for those. Tests: `TestVoicePassCoexists` (coexistence both directions, unified response, null→"" replacement). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM: grammar check → grammar flag; voice pass → unified `[grammar@0, voice@62 (empty replacement)]`, grammar preserved. Known limitation: `findRange` is single-textblock, so a voice passage crossing a `\n\n` paragraph break won't decorate (deferred). Next: **Phase 6 (design system & polish).**
|
||||||
|
- 2026-06-25: **Companion kitten added** (Phase 6 extra, per user request). A cozy corner mascot that gives feedback and gentle nudges. `web/src/components/Companion/`: `useCompanion` (behavior engine — cheer on accept/word-count milestones, Mandarin-first writing tips on a paced timer, screen-break reminder after ~25min continuous writing, idle nap after ~75s + welcome-back; priority/cooldown so it never nags), `tips.ts` (all copy bilingual, zh-first), `LottiePlayer` (wraps `lottie-web` **light** build — offline, no eval/CDN fetch, so it bundles into the Go binary), `PetalCompanion` (kitten + CJK-first speech bubble). **Ships working today with an emoji-kitten placeholder** (😺/😻/😴 per mood, CSS bob/nap/zzz); dropping a Lottie cat JSON into `animations/index.ts` is the only change to upgrade to real animation. Library decision: Lottie via `lottie-web` (not the React wrapper → no React 19 peer-dep friction; not dotLottie → no runtime CDN/wasm, stays offline-embeddable). App wires `editTick`/`acceptTick`/`wordCount`/`saveStatus`. tsc clean, vite build OK (light build trimmed ~34KB gzip vs full + removed eval warning), go build/vet/test clean. Verified headless: greeting bubble on load (“嗨~我在这儿陪你写作哦” + EN subtitle), heart-eyes celebrate + “我很喜欢这个改法 💕” on accept. **TODO (user):** source a Lottie cat asset to replace the emoji placeholder.
|
||||||
|
- 2026-06-25: **Phase 6 complete.** Design system & polish. Tokens/fonts/shape/transitions were already in place from Phase 0; this phase added the two missing signature pieces. **Accept confetti**: CSS-only burst (`.petal-confetti-dot` + `@keyframes petal-confetti`, each dot's trajectory from inline `--dx`/`--dy`), a `Confetti` component in `EditorCore` spawned at the accepted card's position on `handleAccept` and cleared after 720ms (timer cleaned up on unmount). **Distraction-free mode**: `EditorCore` gains an `onFocus` → `App` `focusMode` state; the doc-list sidebar is wrapped in `.petal-sidebar` and collapses via `.petal-sidebar-hidden` (width→0 + translateX + fade, 280ms) while the centered editor canvas re-centers into the full pane; restored by Escape (window keydown) or a pointer-down outside the canvas (`handleChromeDown` checks `canvasRef` containment; wired on the header, the editor scroll-gutter, and the status bar). tsc clean, vite build OK, go build/vet/test clean; binary boots and serves the rebuilt SPA with the new CSS embedded (confetti + sidebar-collapse classes verified in the served bundle). Next: **Phase 7 (browser-side spell check, nspell en-US).**
|
||||||
|
- 2026-06-25: **Phase 7 complete.** Browser-side spell check (nspell, en-US). Vendored Hunspell `en.aff`/`en.dic` → `web/public/dictionaries/en/` (`dictionary-en` moved to devDep; dict served as a static asset + embedded in the binary, kept out of the JS bundle). `useSpellChecker` (App-level, loads once/session) builds the nspell instance, replays a `localStorage` personal word list, `addWord` persists + bumps a version to re-decorate; `src/types/nspell.d.ts` supplies the missing types. `SpellCheck` extension renders misspellings as ProseMirror decorations (Latin-only tokenizer ⇒ CJK never flagged; skips short tokens/acronyms; exempts the caret word; reuses exported `mapOffset`; `wordAt` for click→span). `MisspellCard`: rose wavy underline, bilingual card with correction pills + add-to-dictionary. tsc/vite/go all clean; live server serves both dict files; nspell behavior smoke-tested. **All v1 phases (0–7) done.** Remaining work is the deferred post-v1 bucket (auth, Copyleaks, deploy). Next: per user — Chinese spell check is out of scope for nspell (en-only); see discussion.
|
||||||
|
- 2026-06-25: **Phase 4 complete.** Backend: `internal/llm/chat.go` (`StreamAskPetal` — conversational sampling params, reuses `AskPetalSystemPrompt`/`TrimHistory`), `internal/suggestions/chat.go` (`POST /api/suggestions/:id/chat` — one user-scoped join loads the suggestion + parent `content_text`, `surroundingParagraph` extracts the `\n\n`-bounded paragraph at `from_pos` with whole-doc fallback, streams `event: token`/`event: done` SSE frames with JSON-encoded data, `X-Accel-Buffering: no`, real `http.Flusher` per chunk; LLM-down → 502 before SSE headers, unknown id → 404). Handler imports the interface only. Frontend: `streamSuggestionChat` (fetch + ReadableStream SSE parser, abortable), `AskPetal.tsx` (in-component history — no persistence, pre-seeded first bubble, rose/lavender bubbles, CJK font stack per Note #17, streaming caret), `SuggestionCard` "Ask Petal ✨" pill that pins the card open (hover-close suppressed, click-away closes) and widens it to 340px. Tests: `chat_test.go` (streamed-text concat + done event, server-side context injection asserted on the system message, sampling params, 404, `surroundingParagraph` unit). go build/vet/test clean, tsc clean, vite build OK. Live SSE smoke test against a fake streaming vLLM (fresh ports 8077/8088 — a pre-existing dev petal on :8099 left untouched): tokens flushed individually through the chi middleware stack, `done` terminator, 502 on LLM-down, 404 on unknown suggestion all verified. Next: **Phase 5 (voice consistency pass, Tier 1).**
|
||||||
|
|||||||
@@ -11,12 +11,23 @@ import (
|
|||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"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/docs"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/suggestions"
|
||||||
"gitea.parodia.dev/drwily/petal/web"
|
"gitea.parodia.dev/drwily/petal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
|
database, err := db.Open(cfg.DatabasePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("database: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
log.Printf("database ready at %s", cfg.DatabasePath)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(middleware.RealIP)
|
r.Use(middleware.RealIP)
|
||||||
@@ -28,6 +39,18 @@ func main() {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
llmClient := llm.NewLLMClient(cfg)
|
||||||
|
sug := suggestions.New(database, llmClient)
|
||||||
|
|
||||||
|
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
|
||||||
|
// both under /api/docs.
|
||||||
|
docsRouter := docs.New(database).Routes()
|
||||||
|
sug.RegisterDocRoutes(docsRouter)
|
||||||
|
api.Mount("/docs", docsRouter)
|
||||||
|
|
||||||
|
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
|
||||||
|
api.Mount("/suggestions", sug.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).
|
||||||
|
|||||||
19
go.mod
19
go.mod
@@ -1,5 +1,20 @@
|
|||||||
module gitea.parodia.dev/drwily/petal
|
module gitea.parodia.dev/drwily/petal
|
||||||
|
|
||||||
go 1.24.4
|
go 1.25.0
|
||||||
|
|
||||||
require github.com/go-chi/chi/v5 v5.3.0
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
|
modernc.org/sqlite v1.53.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
|
modernc.org/libc v1.73.4 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
51
go.sum
51
go.sum
@@ -1,2 +1,53 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
|
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||||
|
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||||
|
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||||
|
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||||
|
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||||
|
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
184
internal/db/db.go
Normal file
184
internal/db/db.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// Package db owns the SQLite connection, schema migrations, and the core data
|
||||||
|
// models. It uses modernc.org/sqlite (pure Go, no cgo) so the app stays a
|
||||||
|
// single static binary.
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LocalUserID is the id of the single hardcoded user the app runs as while auth
|
||||||
|
// is deferred. The seeded row keeps foreign keys valid; real auth replaces it
|
||||||
|
// later without a schema change.
|
||||||
|
const LocalUserID = "local"
|
||||||
|
|
||||||
|
// DB wraps the SQL handle. It's a thin alias today, leaving room for prepared
|
||||||
|
// statements or helpers later without churning call sites.
|
||||||
|
type DB struct {
|
||||||
|
*sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open initialises the database at path: it ensures the parent directory
|
||||||
|
// exists, opens the connection with foreign keys and WAL enabled, runs all
|
||||||
|
// pending migrations, and seeds the local user.
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create data dir: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := sql.Open("sqlite", dsn(path))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
|
}
|
||||||
|
// SQLite is a single writer; one connection avoids "database is locked"
|
||||||
|
// churn while keeping WAL's concurrent readers.
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d := &DB{sqlDB}
|
||||||
|
if err := d.migrate(); err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("migrate: %w", err)
|
||||||
|
}
|
||||||
|
if err := d.seed(); err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("seed: %w", err)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dsn builds the modernc.org/sqlite connection string with the pragmas we want
|
||||||
|
// applied to every connection.
|
||||||
|
func dsn(path string) string {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Add("_pragma", "foreign_keys(1)")
|
||||||
|
q.Add("_pragma", "busy_timeout(5000)")
|
||||||
|
q.Add("_pragma", "journal_mode(WAL)")
|
||||||
|
return "file:" + path + "?" + q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// migration is one ordered, idempotent schema step. Append new migrations to the
|
||||||
|
// slice in migrate(); never edit or reorder an already-shipped one.
|
||||||
|
type migration struct {
|
||||||
|
name string
|
||||||
|
stmt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrate runs every migration not yet recorded in schema_migrations, inside a
|
||||||
|
// transaction each, in order.
|
||||||
|
func (d *DB) migrate() error {
|
||||||
|
if _, err := d.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range migrations() {
|
||||||
|
var exists bool
|
||||||
|
if err := d.QueryRow(
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE name = ?)`, m.name,
|
||||||
|
).Scan(&exists); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := d.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(m.stmt); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return fmt.Errorf("migration %q: %w", m.name, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO schema_migrations (name) VALUES (?)`, m.name); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return fmt.Errorf("record migration %q: %w", m.name, err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrations returns the ordered schema history. The initial migration mirrors
|
||||||
|
// the schema in petal-spec.md.
|
||||||
|
func migrations() []migration {
|
||||||
|
return []migration{
|
||||||
|
{
|
||||||
|
name: "0001_initial_schema",
|
||||||
|
stmt: `
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
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 '{}',
|
||||||
|
content_text TEXT NOT NULL DEFAULT '',
|
||||||
|
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,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK(status IN ('pending','complete','error')),
|
||||||
|
result_json TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
||||||
|
CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seed inserts the hardcoded local user if it doesn't already exist. It's
|
||||||
|
// idempotent, so it runs safely on every startup.
|
||||||
|
func (d *DB) seed() error {
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO NOTHING`,
|
||||||
|
LocalUserID, "local@petal.local", "Writer",
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
77
internal/db/db_test.go
Normal file
77
internal/db/db_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenMigratesAndSeeds(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
|
||||||
|
d, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local user is seeded.
|
||||||
|
var email string
|
||||||
|
if err := d.QueryRow(`SELECT email FROM users WHERE id = ?`, LocalUserID).Scan(&email); err != nil {
|
||||||
|
t.Fatalf("local user not seeded: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All expected tables exist.
|
||||||
|
for _, table := range []string{"users", "documents", "suggestions", "plagiarism_reports", "schema_migrations"} {
|
||||||
|
var name string
|
||||||
|
err := d.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&name)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("table %q missing: %v", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The voice suggestion type is permitted by the CHECK constraint.
|
||||||
|
if _, err := d.Exec(`INSERT INTO documents (id, user_id) VALUES ('d1', ?)`, LocalUserID); err != nil {
|
||||||
|
t.Fatalf("insert document: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := d.Exec(
|
||||||
|
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||||
|
VALUES ('d1', 0, 4, 'teh', '', 'voice flag', 'voice')`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert voice suggestion: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An invalid type is rejected.
|
||||||
|
if _, err := d.Exec(
|
||||||
|
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||||
|
VALUES ('d1', 0, 4, 'teh', 'the', 'x', 'nonsense')`,
|
||||||
|
); err == nil {
|
||||||
|
t.Error("expected CHECK constraint to reject invalid suggestion type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade delete removes child suggestions (foreign keys enabled).
|
||||||
|
if _, err := d.Exec(`DELETE FROM documents WHERE id = 'd1'`); err != nil {
|
||||||
|
t.Fatalf("delete document: %v", err)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := d.QueryRow(`SELECT COUNT(*) FROM suggestions WHERE doc_id = 'd1'`).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("expected cascade delete, got %d orphan suggestions", n)
|
||||||
|
}
|
||||||
|
d.Close()
|
||||||
|
|
||||||
|
// Re-opening is idempotent: migrations and seed don't double-apply or error.
|
||||||
|
d2, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second open: %v", err)
|
||||||
|
}
|
||||||
|
defer d2.Close()
|
||||||
|
|
||||||
|
var users int
|
||||||
|
if err := d2.QueryRow(`SELECT COUNT(*) FROM users WHERE id = ?`, LocalUserID).Scan(&users); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if users != 1 {
|
||||||
|
t.Errorf("expected exactly 1 local user after reopen, got %d", users)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
internal/db/models.go
Normal file
59
internal/db/models.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// User is an account. With auth deferred, the app runs as a single hardcoded
|
||||||
|
// `local` user (see LocalUserID); the user_id columns and this type exist so
|
||||||
|
// real auth can drop in later without a schema migration.
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document is a single piece of writing. `Content` is the Tiptap JSON document
|
||||||
|
// (source of truth for the editor); `ContentText` is the flattened plain text
|
||||||
|
// kept in sync on every save and fed to the LLM.
|
||||||
|
type Document struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"` // Tiptap JSON
|
||||||
|
ContentText string `json:"content_text"` // plain text for the LLM
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggestion is a single LLM-proposed edit anchored to a span of the document.
|
||||||
|
//
|
||||||
|
// FromPos/ToPos are plaintext offsets into ContentText for server-side use only;
|
||||||
|
// the frontend re-anchors by matching the `Original` string in ProseMirror
|
||||||
|
// coordinates at render time (spec Note #6). `Replacement` is empty for `voice`
|
||||||
|
// flags — those are awareness-only, with no correction to apply.
|
||||||
|
type Suggestion struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DocID string `json:"doc_id"`
|
||||||
|
FromPos int `json:"from_pos"`
|
||||||
|
ToPos int `json:"to_pos"`
|
||||||
|
Original string `json:"original"`
|
||||||
|
Replacement string `json:"replacement"`
|
||||||
|
Explanation string `json:"explanation"`
|
||||||
|
Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice
|
||||||
|
Status string `json:"status"` // pending | accepted | rejected
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggestion type and status values, mirrored from the schema CHECK constraints.
|
||||||
|
const (
|
||||||
|
SuggestionTypeGrammar = "grammar"
|
||||||
|
SuggestionTypePhrasing = "phrasing"
|
||||||
|
SuggestionTypeIdiom = "idiom"
|
||||||
|
SuggestionTypeClarity = "clarity"
|
||||||
|
SuggestionTypeVoice = "voice"
|
||||||
|
|
||||||
|
SuggestionStatusPending = "pending"
|
||||||
|
SuggestionStatusAccepted = "accepted"
|
||||||
|
SuggestionStatusRejected = "rejected"
|
||||||
|
)
|
||||||
208
internal/docs/handlers.go
Normal file
208
internal/docs/handlers.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
// Package docs implements the document CRUD HTTP handlers — the create / list /
|
||||||
|
// read / update / delete surface that backs the editor and its 1.5s auto-save.
|
||||||
|
// All access is scoped to the single hardcoded local user while auth is deferred.
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler holds the dependencies shared by every document route.
|
||||||
|
type Handler struct {
|
||||||
|
DB *db.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Handler.
|
||||||
|
func New(database *db.DB) *Handler {
|
||||||
|
return &Handler{DB: database}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes returns a router mounting the document CRUD endpoints. Mount it under
|
||||||
|
// "/docs" so the full paths are /api/docs, /api/docs/{id}, etc.
|
||||||
|
func (h *Handler) Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/", h.list)
|
||||||
|
r.Post("/", h.create)
|
||||||
|
r.Get("/{id}", h.get)
|
||||||
|
r.Put("/{id}", h.update)
|
||||||
|
r.Delete("/{id}", h.delete)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// docSummary is the lightweight shape returned by the list endpoint — enough to
|
||||||
|
// render the DocList sidebar without shipping every document's full body.
|
||||||
|
type docSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// list returns the local user's documents, most-recently-updated first.
|
||||||
|
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(
|
||||||
|
`SELECT id, title, word_count, updated_at
|
||||||
|
FROM documents
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY updated_at DESC`,
|
||||||
|
db.LocalUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []docSummary{} // non-nil so an empty list serializes as [] not null
|
||||||
|
for rows.Next() {
|
||||||
|
var d docSummary
|
||||||
|
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// create inserts a fresh blank document and returns it in full.
|
||||||
|
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`,
|
||||||
|
db.LocalUserID,
|
||||||
|
).Scan(
|
||||||
|
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||||
|
&doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns a single full document by id.
|
||||||
|
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
doc, err := h.fetch(chi.URLParam(r, "id"))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateRequest is the auto-save payload. Every field is optional (a pointer) so
|
||||||
|
// the same endpoint serves both the editor's full save ({content, content_text,
|
||||||
|
// word_count, title}) and a DocList rename ({title} alone).
|
||||||
|
type updateRequest struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Content *string `json:"content"`
|
||||||
|
ContentText *string `json:"content_text"`
|
||||||
|
WordCount *int `json:"word_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// update applies the provided fields to a document and returns the saved row.
|
||||||
|
// content and content_text are kept in sync by the client and written together.
|
||||||
|
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req updateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
badRequest(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.DB.Exec(
|
||||||
|
`UPDATE documents
|
||||||
|
SET title = COALESCE(?, title),
|
||||||
|
content = COALESCE(?, content),
|
||||||
|
content_text = COALESCE(?, content_text),
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := h.fetch(id)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete removes a document (suggestions cascade via the FK).
|
||||||
|
func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
res, err := h.DB.Exec(
|
||||||
|
`DELETE FROM documents WHERE id = ? AND user_id = ?`,
|
||||||
|
chi.URLParam(r, "id"), db.LocalUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch loads one full document scoped to the local user.
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
return doc, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- small response helpers -------------------------------------------------
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorJSON(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverError(w http.ResponseWriter, err error) {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func badRequest(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusBadRequest, msg) }
|
||||||
|
func notFound(w http.ResponseWriter) { errorJSON(w, http.StatusNotFound, "document not found") }
|
||||||
103
internal/docs/handlers_test.go
Normal file
103
internal/docs/handlers_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestServer spins up an isolated on-disk database and the docs router.
|
||||||
|
func newTestServer(t *testing.T) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
return New(database).Routes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var r *http.Request
|
||||||
|
if body != "" {
|
||||||
|
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||||
|
} else {
|
||||||
|
r = httptest.NewRequest(method, path, nil)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, r)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentCRUD(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
|
||||||
|
// Empty list serializes as [].
|
||||||
|
rec := do(t, srv, http.MethodGet, "/", "")
|
||||||
|
if rec.Code != http.StatusOK || bytes.TrimSpace(rec.Body.Bytes())[0] != '[' {
|
||||||
|
t.Fatalf("list empty: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create.
|
||||||
|
rec = do(t, srv, http.MethodPost, "/", "")
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var created db.Document
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
|
||||||
|
t.Fatalf("decode create: %v", err)
|
||||||
|
}
|
||||||
|
if created.ID == "" || created.Title != "Untitled" {
|
||||||
|
t.Fatalf("unexpected created doc: %+v", created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update (auto-save shape) keeps content + content_text + word count together.
|
||||||
|
body := `{"title":"My Essay","content":"{\"x\":1}","content_text":"hello world","word_count":2}`
|
||||||
|
rec = do(t, srv, http.MethodPut, "/"+created.ID, body)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var updated db.Document
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &updated)
|
||||||
|
if updated.Title != "My Essay" || updated.ContentText != "hello world" || updated.WordCount != 2 {
|
||||||
|
t.Fatalf("update not applied: %+v", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial update (rename only) leaves other fields intact.
|
||||||
|
rec = do(t, srv, http.MethodPut, "/"+created.ID, `{"title":"Renamed"}`)
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &updated)
|
||||||
|
if updated.Title != "Renamed" || updated.WordCount != 2 {
|
||||||
|
t.Fatalf("partial update clobbered fields: %+v", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get.
|
||||||
|
rec = do(t, srv, http.MethodGet, "/"+created.ID, "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get: code=%d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List now has one entry.
|
||||||
|
rec = do(t, srv, http.MethodGet, "/", "")
|
||||||
|
var summaries []docSummary
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &summaries)
|
||||||
|
if len(summaries) != 1 || summaries[0].Title != "Renamed" {
|
||||||
|
t.Fatalf("list after create: %+v", summaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete, then 404 on subsequent get/delete.
|
||||||
|
if rec = do(t, srv, http.MethodDelete, "/"+created.ID, ""); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: code=%d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec = do(t, srv, http.MethodGet, "/"+created.ID, ""); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("get after delete: code=%d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec = do(t, srv, http.MethodPut, "/missing", `{"title":"x"}`); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("update missing: code=%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/llm/chat.go
Normal file
29
internal/llm/chat.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// AskPetalMessages assembles the message array for one Ask Petal turn: the
|
||||||
|
// suggestion-context system prompt followed by the (length-capped) client-side
|
||||||
|
// conversation history. The backend is stateless, so the full history rides on
|
||||||
|
// every request (spec: no server-side sessions).
|
||||||
|
func AskPetalMessages(systemPrompt string, history []Message) []Message {
|
||||||
|
msgs := make([]Message, 0, len(history)+1)
|
||||||
|
msgs = append(msgs, Message{Role: "system", Content: systemPrompt})
|
||||||
|
msgs = append(msgs, TrimHistory(history)...)
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamAskPetal opens an SSE-friendly token stream for an Ask Petal reply using
|
||||||
|
// the conversational sampling parameters from the spec. The caller forwards the
|
||||||
|
// returned chunks to the browser; the channel closes when generation ends.
|
||||||
|
func StreamAskPetal(ctx context.Context, client LLMClient, systemPrompt string, history []Message) (<-chan string, error) {
|
||||||
|
return client.Stream(ctx, CompletionRequest{
|
||||||
|
Messages: AskPetalMessages(systemPrompt, history),
|
||||||
|
MaxTokens: 512,
|
||||||
|
Temperature: 0.7,
|
||||||
|
RepetitionPenalty: 1.15,
|
||||||
|
TopP: 0.92,
|
||||||
|
Stop: []string{"\n\n\n"},
|
||||||
|
Stream: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
143
internal/llm/checkpoint.go
Normal file
143
internal/llm/checkpoint.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckpointInterval is the minimum time between grammar checkpoints for a
|
||||||
|
// single document. The frontend debounces at 4s; this is the server-side floor
|
||||||
|
// that protects the inference endpoint from rapid repeat checks.
|
||||||
|
const CheckpointInterval = 30 * time.Second
|
||||||
|
|
||||||
|
// RawSuggestion is one item as the model emits it. Positions are resolved
|
||||||
|
// server-side from Original; the frontend re-anchors by string at render time.
|
||||||
|
type RawSuggestion struct {
|
||||||
|
Original string `json:"original"`
|
||||||
|
Replacement string `json:"replacement"`
|
||||||
|
Explanation string `json:"explanation"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkpointResponse is the top-level JSON shape the checkpoint prompt requests.
|
||||||
|
type checkpointResponse struct {
|
||||||
|
Suggestions []RawSuggestion `json:"suggestions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
raw, err := client.Complete(ctx, CompletionRequest{
|
||||||
|
Messages: CheckpointMessages(TruncateDoc(contentText)),
|
||||||
|
MaxTokens: 1024,
|
||||||
|
Temperature: 0.3,
|
||||||
|
RepetitionPenalty: 1.15,
|
||||||
|
TopP: 0.9,
|
||||||
|
Stop: []string{"```", "\n\n\n\n"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ParseCheckpoint(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCheckpoint extracts the suggestions array from a model response. Smaller
|
||||||
|
// models sometimes wrap JSON in prose or markdown fences despite instructions,
|
||||||
|
// so we salvage the outermost {...} object before decoding.
|
||||||
|
func ParseCheckpoint(raw string) ([]RawSuggestion, error) {
|
||||||
|
jsonText := extractJSONObject(raw)
|
||||||
|
if jsonText == "" {
|
||||||
|
return nil, fmt.Errorf("checkpoint: no JSON object in model output: %q", truncateForError(raw))
|
||||||
|
}
|
||||||
|
var parsed checkpointResponse
|
||||||
|
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("checkpoint: parse JSON: %w (got %q)", err, truncateForError(jsonText))
|
||||||
|
}
|
||||||
|
// Drop items the model returned with an empty original — they can't be
|
||||||
|
// anchored — and normalize whitespace the model may have echoed.
|
||||||
|
out := parsed.Suggestions[:0]
|
||||||
|
for _, s := range parsed.Suggestions {
|
||||||
|
s.Original = strings.TrimSpace(s.Original)
|
||||||
|
s.Replacement = strings.TrimSpace(s.Replacement)
|
||||||
|
if s.Original == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractJSONObject returns the substring from the first '{' to its matching
|
||||||
|
// closing '}', or "" if none. Tolerates fences/preamble around the object.
|
||||||
|
func extractJSONObject(s string) string {
|
||||||
|
start := strings.IndexByte(s, '{')
|
||||||
|
if start < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
depth := 0
|
||||||
|
inString := false
|
||||||
|
escaped := false
|
||||||
|
for i := start; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
switch {
|
||||||
|
case escaped:
|
||||||
|
escaped = false
|
||||||
|
case c == '\\' && inString:
|
||||||
|
escaped = true
|
||||||
|
case c == '"':
|
||||||
|
inString = !inString
|
||||||
|
case inString:
|
||||||
|
// ignore braces inside strings
|
||||||
|
case c == '{':
|
||||||
|
depth++
|
||||||
|
case c == '}':
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return s[start : i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateForError(s string) string {
|
||||||
|
const max = 200
|
||||||
|
if len(s) > max {
|
||||||
|
return s[:max] + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimiter enforces a minimum interval between checkpoints per document.
|
||||||
|
// Concurrency-safe; one instance is shared across requests.
|
||||||
|
type RateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
interval time.Duration
|
||||||
|
last map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimiter constructs a limiter with the given per-document minimum gap.
|
||||||
|
func NewRateLimiter(interval time.Duration) *RateLimiter {
|
||||||
|
return &RateLimiter{interval: interval, last: make(map[string]time.Time)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow reports whether a checkpoint may run for docID now. When allowed it
|
||||||
|
// records the time and returns (true, 0); when throttled it returns
|
||||||
|
// (false, retryAfter) where retryAfter is the wait until the next allowed run.
|
||||||
|
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration) {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
if last, ok := rl.last[docID]; ok {
|
||||||
|
if elapsed := now.Sub(last); elapsed < rl.interval {
|
||||||
|
return false, rl.interval - elapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl.last[docID] = now
|
||||||
|
return true, 0
|
||||||
|
}
|
||||||
93
internal/llm/checkpoint_test.go
Normal file
93
internal/llm/checkpoint_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCheckpoint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "clean json",
|
||||||
|
raw: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"}]}`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrapped in markdown fence",
|
||||||
|
raw: "Here you go:\n```json\n{\"suggestions\":[{\"original\":\"a apple\",\"replacement\":\"an apple\",\"explanation\":\"use an before a vowel\",\"type\":\"grammar\"}]}\n```",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty suggestions",
|
||||||
|
raw: `{"suggestions":[]}`,
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "drops items with empty original",
|
||||||
|
raw: `{"suggestions":[{"original":"","replacement":"x","explanation":"e","type":"grammar"},{"original":"teh","replacement":"the","explanation":"typo","type":"grammar"}]}`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no json at all",
|
||||||
|
raw: "I could not find any issues!",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "braces inside string values",
|
||||||
|
raw: `{"suggestions":[{"original":"use {x}","replacement":"use x","explanation":"drop the braces","type":"clarity"}]}`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseCheckpoint(tt.raw)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got none")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != tt.want {
|
||||||
|
t.Fatalf("got %d suggestions, want %d", len(got), tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiter(t *testing.T) {
|
||||||
|
rl := NewRateLimiter(30 * time.Second)
|
||||||
|
|
||||||
|
if ok, _ := rl.Allow("doc1"); !ok {
|
||||||
|
t.Fatal("first call should be allowed")
|
||||||
|
}
|
||||||
|
if ok, retry := rl.Allow("doc1"); ok || retry <= 0 {
|
||||||
|
t.Fatalf("immediate second call should be throttled, got ok=%v retry=%v", ok, retry)
|
||||||
|
}
|
||||||
|
// A different document is independent.
|
||||||
|
if ok, _ := rl.Allow("doc2"); !ok {
|
||||||
|
t.Fatal("different doc should be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateDoc(t *testing.T) {
|
||||||
|
short := "hello"
|
||||||
|
if got := TruncateDoc(short); got != short {
|
||||||
|
t.Fatalf("short doc should be unchanged")
|
||||||
|
}
|
||||||
|
long := make([]byte, maxDocChars+500)
|
||||||
|
for i := range long {
|
||||||
|
long[i] = 'a'
|
||||||
|
}
|
||||||
|
got := TruncateDoc(string(long))
|
||||||
|
if len(got) != maxDocChars {
|
||||||
|
t.Fatalf("truncated length = %d, want %d", len(got), maxDocChars)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
internal/llm/client.go
Normal file
110
internal/llm/client.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// Package llm wraps the local inference endpoint behind a backend-agnostic
|
||||||
|
// interface. Two concrete backends — vLLM (OpenAI-compatible) and Ollama
|
||||||
|
// (native) — implement LLMClient; the rest of the app (the grammar checkpoint,
|
||||||
|
// Ask Petal) calls the interface only and never knows which one is active.
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is one chat turn sent to the model.
|
||||||
|
type Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompletionRequest is the backend-neutral request shape. Each backend maps
|
||||||
|
// these fields onto its own wire format (see the parameter cheatsheet in the
|
||||||
|
// spec). Zero-valued sampling fields fall back to backend defaults.
|
||||||
|
type CompletionRequest struct {
|
||||||
|
Messages []Message
|
||||||
|
MaxTokens int
|
||||||
|
Temperature float64
|
||||||
|
RepetitionPenalty float64
|
||||||
|
TopP float64
|
||||||
|
Stop []string
|
||||||
|
Stream bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMClient is the single surface handler code depends on.
|
||||||
|
type LLMClient interface {
|
||||||
|
// Complete returns the full response in one shot (used for the checkpoint
|
||||||
|
// JSON, which we want whole before parsing).
|
||||||
|
Complete(ctx context.Context, req CompletionRequest) (string, error)
|
||||||
|
// Stream returns a channel of text chunks (used for Ask Petal SSE). The
|
||||||
|
// channel closes when generation ends; on a mid-stream error it closes
|
||||||
|
// early and the error is reported via the returned error of a future call.
|
||||||
|
Stream(ctx context.Context, req CompletionRequest) (<-chan string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncation caps. These guard checkpoint latency (prefill time scales with
|
||||||
|
// input length), not the model window — Qwen 3.5 ships 256K, far larger.
|
||||||
|
const (
|
||||||
|
// maxDocChars ~ 10,000 tokens at ~4 chars/token. Grammar checkpoint only;
|
||||||
|
// the voice pass sends the whole document uncut.
|
||||||
|
maxDocChars = 40000
|
||||||
|
// maxHistoryMsgs caps the rolling Ask Petal history (5 turns); oldest pairs
|
||||||
|
// drop first.
|
||||||
|
maxHistoryMsgs = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// TruncateDoc keeps the recent end of a document — the user is actively writing
|
||||||
|
// there — when it exceeds the grammar-checkpoint latency cap. Most documents
|
||||||
|
// fit uncut. The voice pass deliberately does NOT call this.
|
||||||
|
func TruncateDoc(contentText string) string {
|
||||||
|
if len(contentText) > maxDocChars {
|
||||||
|
return contentText[len(contentText)-maxDocChars:]
|
||||||
|
}
|
||||||
|
return contentText
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrimHistory keeps the most recent maxHistoryMsgs messages, dropping the
|
||||||
|
// oldest first so a long Ask Petal chat stays within budget.
|
||||||
|
func TrimHistory(msgs []Message) []Message {
|
||||||
|
if len(msgs) > maxHistoryMsgs {
|
||||||
|
return msgs[len(msgs)-maxHistoryMsgs:]
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLLMClient selects a backend from config. LLM_CHAT_MODEL falls back to
|
||||||
|
// LLM_MODEL here, in the factory, so handlers never deal with the fallback.
|
||||||
|
func NewLLMClient(cfg *config.Config) LLMClient {
|
||||||
|
chatModel := cfg.LLMChatModel
|
||||||
|
if chatModel == "" {
|
||||||
|
chatModel = cfg.LLMModel
|
||||||
|
}
|
||||||
|
base := backend{
|
||||||
|
endpoint: cfg.LLMEndpoint,
|
||||||
|
checkpointModel: cfg.LLMModel,
|
||||||
|
chatModel: chatModel,
|
||||||
|
timeout: cfg.LLMTimeout,
|
||||||
|
}
|
||||||
|
switch cfg.LLMBackend {
|
||||||
|
case "ollama":
|
||||||
|
return &OllamaClient{base}
|
||||||
|
default: // "vllm"
|
||||||
|
return &VLLMClient{base}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backend holds the fields shared by both concrete clients.
|
||||||
|
type backend struct {
|
||||||
|
endpoint string
|
||||||
|
checkpointModel string // LLM_MODEL — small/fast checkpoint model
|
||||||
|
chatModel string // LLM_CHAT_MODEL (or LLM_MODEL) — Ask Petal model
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// model picks the right model id for a request. Streaming requests are Ask
|
||||||
|
// Petal (chat); one-shot Complete calls are the grammar checkpoint.
|
||||||
|
func (b backend) model(req CompletionRequest) string {
|
||||||
|
if req.Stream {
|
||||||
|
return b.chatModel
|
||||||
|
}
|
||||||
|
return b.checkpointModel
|
||||||
|
}
|
||||||
131
internal/llm/ollama.go
Normal file
131
internal/llm/ollama.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OllamaClient talks to Ollama's native /api/chat endpoint. Selected when
|
||||||
|
// LLM_BACKEND=ollama.
|
||||||
|
type OllamaClient struct {
|
||||||
|
backend
|
||||||
|
}
|
||||||
|
|
||||||
|
// ollamaRequest mirrors Ollama's /api/chat body. Sampling parameters live under
|
||||||
|
// "options" (see the cheatsheet in the spec).
|
||||||
|
//
|
||||||
|
// Think is sent false unconditionally: reasoning models (e.g. qwen3.5) otherwise
|
||||||
|
// stream their chain-of-thought into a separate "thinking" field and can exhaust
|
||||||
|
// num_predict before emitting any answer in "content" — leaving us an empty
|
||||||
|
// response. We want direct output (structured JSON for the checkpoint, concise
|
||||||
|
// prose for chat), not reasoning. Non-thinking models simply ignore the flag.
|
||||||
|
type ollamaRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []Message `json:"messages"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
Think bool `json:"think"`
|
||||||
|
Options ollamaOptions `json:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ollamaOptions struct {
|
||||||
|
NumPredict int `json:"num_predict"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
RepeatPenalty float64 `json:"repeat_penalty"`
|
||||||
|
TopP float64 `json:"top_p"`
|
||||||
|
Stop []string `json:"stop,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OllamaClient) body(req CompletionRequest) ollamaRequest {
|
||||||
|
return ollamaRequest{
|
||||||
|
Model: c.model(req),
|
||||||
|
Messages: req.Messages,
|
||||||
|
Stream: req.Stream,
|
||||||
|
Options: ollamaOptions{
|
||||||
|
NumPredict: req.MaxTokens,
|
||||||
|
Temperature: req.Temperature,
|
||||||
|
RepeatPenalty: req.RepetitionPenalty,
|
||||||
|
TopP: req.TopP,
|
||||||
|
Stop: req.Stop,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete sends a non-streaming request and returns the full message content.
|
||||||
|
func (c *OllamaClient) Complete(ctx context.Context, req CompletionRequest) (string, error) {
|
||||||
|
req.Stream = false
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := c.post(ctx, c.body(req))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", httpError("ollama", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return "", fmt.Errorf("ollama: decode response: %w", err)
|
||||||
|
}
|
||||||
|
return out.Message.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream sends a streaming request. Ollama emits newline-delimited JSON objects;
|
||||||
|
// we forward each message.content and stop when done:true.
|
||||||
|
func (c *OllamaClient) Stream(ctx context.Context, req CompletionRequest) (<-chan string, error) {
|
||||||
|
req.Stream = true
|
||||||
|
resp, err := c.post(ctx, c.body(req))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return nil, httpError("ollama", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan string)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
sc := bufio.NewScanner(resp.Body)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var chunk struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(line, &chunk); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if chunk.Message.Content != "" {
|
||||||
|
select {
|
||||||
|
case ch <- chunk.Message.Content:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chunk.Done {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OllamaClient) post(ctx context.Context, body any) (*http.Response, error) {
|
||||||
|
return postJSON(ctx, c.endpoint+"/api/chat", body)
|
||||||
|
}
|
||||||
101
internal/llm/prompts.go
Normal file
101
internal/llm/prompts.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// checkpointSystemPrompt is the grammar-checkpoint instruction. It asks for
|
||||||
|
// strict JSON (no fences, no preamble) so Complete's output parses directly.
|
||||||
|
const checkpointSystemPrompt = `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": []}`
|
||||||
|
|
||||||
|
// CheckpointMessages builds the message array for a grammar checkpoint over the
|
||||||
|
// given (already-truncated) document text.
|
||||||
|
func CheckpointMessages(contentText string) []Message {
|
||||||
|
return []Message{
|
||||||
|
{Role: "system", Content: checkpointSystemPrompt},
|
||||||
|
{Role: "user", Content: contentText},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// voiceSystemPrompt drives the Tier-1 voice-consistency pass. It is a distinct
|
||||||
|
// pass from the grammar checkpoint (spec: "do not bundle them") — the model
|
||||||
|
// reads the whole document to learn the writer's natural voice, then flags
|
||||||
|
// passages that read as tonally out of place. `replacement` is null: these are
|
||||||
|
// awareness-only, with no correction to apply.
|
||||||
|
const voiceSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` +
|
||||||
|
`You are reviewing a COMPLETE document for VOICE CONSISTENCY only — not grammar.
|
||||||
|
|
||||||
|
Read the whole document to learn the writer's natural voice, then identify any passages (2 or more 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. These often signal text ` +
|
||||||
|
`that was paraphrased too closely from another source. Do not flag the first paragraph (there is no baseline yet). ` +
|
||||||
|
`Do not flag grammar or spelling mistakes — only voice.
|
||||||
|
|
||||||
|
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||||
|
{
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"original": "exact passage from the document that feels inconsistent",
|
||||||
|
"replacement": null,
|
||||||
|
"explanation": "friendly one-sentence note, e.g. 'This passage sounds more formal than the rest of your writing — worth reviewing.'",
|
||||||
|
"type": "voice"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
If the voice is consistent throughout, return: {"suggestions": []}`
|
||||||
|
|
||||||
|
// VoiceMessages builds the message array for a voice-consistency pass. Unlike
|
||||||
|
// the checkpoint, the caller passes the WHOLE document (no truncation) — voice
|
||||||
|
// consistency is judged against the established voice everywhere else.
|
||||||
|
func VoiceMessages(contentText string) []Message {
|
||||||
|
return []Message{
|
||||||
|
{Role: "system", Content: voiceSystemPrompt},
|
||||||
|
{Role: "user", Content: contentText},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context
|
||||||
|
// is interpolated in; the user's own messages are appended after this system
|
||||||
|
// turn by the caller.
|
||||||
|
const askPetalSystemTemplate = `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: "%s"
|
||||||
|
- Suggested replacement: "%s"
|
||||||
|
- Issue type: %s
|
||||||
|
- Initial explanation: "%s"
|
||||||
|
- Surrounding paragraph: "%s"
|
||||||
|
|
||||||
|
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.`
|
||||||
|
|
||||||
|
// AskPetalSystemPrompt fills the tutor prompt with one suggestion's context.
|
||||||
|
func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string) string {
|
||||||
|
return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph)
|
||||||
|
}
|
||||||
156
internal/llm/vllm.go
Normal file
156
internal/llm/vllm.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VLLMClient talks to an OpenAI-compatible /v1/chat/completions endpoint
|
||||||
|
// (vLLM's API server). It is the default backend.
|
||||||
|
type VLLMClient struct {
|
||||||
|
backend
|
||||||
|
}
|
||||||
|
|
||||||
|
// vllmRequest is the OpenAI-compatible request body. repetition_penalty is a
|
||||||
|
// vLLM extension to the OpenAI schema, which vLLM accepts.
|
||||||
|
type vllmRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []Message `json:"messages"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
RepetitionPenalty float64 `json:"repetition_penalty"`
|
||||||
|
TopP float64 `json:"top_p"`
|
||||||
|
Stop []string `json:"stop,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *VLLMClient) body(req CompletionRequest) vllmRequest {
|
||||||
|
return vllmRequest{
|
||||||
|
Model: c.model(req),
|
||||||
|
Messages: req.Messages,
|
||||||
|
MaxTokens: req.MaxTokens,
|
||||||
|
Temperature: req.Temperature,
|
||||||
|
RepetitionPenalty: req.RepetitionPenalty,
|
||||||
|
TopP: req.TopP,
|
||||||
|
Stop: req.Stop,
|
||||||
|
Stream: req.Stream,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete sends a non-streaming request and returns the full message content.
|
||||||
|
func (c *VLLMClient) Complete(ctx context.Context, req CompletionRequest) (string, error) {
|
||||||
|
req.Stream = false
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := c.post(ctx, c.body(req))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", httpError("vllm", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return "", fmt.Errorf("vllm: decode response: %w", err)
|
||||||
|
}
|
||||||
|
if len(out.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("vllm: empty choices in response")
|
||||||
|
}
|
||||||
|
return out.Choices[0].Message.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream sends a streaming request and emits delta content chunks on the
|
||||||
|
// returned channel, which closes when the stream ends ([DONE]) or ctx is done.
|
||||||
|
func (c *VLLMClient) Stream(ctx context.Context, req CompletionRequest) (<-chan string, error) {
|
||||||
|
req.Stream = true
|
||||||
|
resp, err := c.post(ctx, c.body(req))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return nil, httpError("vllm", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan string)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
sc := bufio.NewScanner(resp.Body)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if !strings.HasPrefix(line, "data:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||||
|
if data == "[DONE]" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var chunk struct {
|
||||||
|
Choices []struct {
|
||||||
|
Delta struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"delta"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||||
|
continue // skip keep-alives / malformed frames
|
||||||
|
}
|
||||||
|
if len(chunk.Choices) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if text := chunk.Choices[0].Delta.Content; text != "" {
|
||||||
|
select {
|
||||||
|
case ch <- text:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *VLLMClient) post(ctx context.Context, body any) (*http.Response, error) {
|
||||||
|
return postJSON(ctx, c.endpoint+"/v1/chat/completions", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shared HTTP helpers (used by both backends) ---------------------------
|
||||||
|
|
||||||
|
// llmHTTP has no client-level timeout: Complete bounds itself with a context
|
||||||
|
// deadline (LLM_TIMEOUT), and streaming requests must stay open as long as the
|
||||||
|
// model is generating. Cancellation flows through the request context.
|
||||||
|
var llmHTTP = &http.Client{}
|
||||||
|
|
||||||
|
func postJSON(ctx context.Context, url string, body any) (*http.Response, error) {
|
||||||
|
buf, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
return llmHTTP.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpError(backend string, resp *http.Response) error {
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return fmt.Errorf("%s: %s: %s", backend, resp.Status, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
32
internal/llm/voice.go
Normal file
32
internal/llm/voice.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VoiceInterval is the minimum gap between voice-consistency passes for one
|
||||||
|
// document. The pass is whole-document and slow, and it runs on an explicit
|
||||||
|
// user action rather than a typing cadence, so this floor only guards the
|
||||||
|
// inference endpoint against the button being mashed.
|
||||||
|
const VoiceInterval = 20 * time.Second
|
||||||
|
|
||||||
|
// RunVoice sends the WHOLE document — deliberately NOT TruncateDoc'd, since
|
||||||
|
// voice consistency is judged against the established voice everywhere else —
|
||||||
|
// 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) {
|
||||||
|
raw, err := client.Complete(ctx, CompletionRequest{
|
||||||
|
Messages: VoiceMessages(contentText),
|
||||||
|
MaxTokens: 2048,
|
||||||
|
Temperature: 0.3,
|
||||||
|
RepetitionPenalty: 1.15,
|
||||||
|
TopP: 0.9,
|
||||||
|
Stop: []string{"```", "\n\n\n\n"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ParseCheckpoint(raw)
|
||||||
|
}
|
||||||
126
internal/suggestions/chat.go
Normal file
126
internal/suggestions/chat.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package suggestions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// chatRequest is the body the AskPetal panel posts: the full conversation so
|
||||||
|
// far. The suggestion context is loaded server-side and never trusted from the
|
||||||
|
// client (spec Note #10).
|
||||||
|
type chatRequest struct {
|
||||||
|
Messages []llm.Message `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// chat streams an Ask Petal conversational reply over SSE. It loads the
|
||||||
|
// suggestion and its parent document's surrounding paragraph, injects them as
|
||||||
|
// the tutor system prompt, then relays the model's tokens to the browser as
|
||||||
|
// `data:` events. History persistence lives entirely in the client.
|
||||||
|
func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sugID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var body chatRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
errorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// One query for the suggestion fields and the parent document's plain text,
|
||||||
|
// scoped to the local user so a stray id can't read another user's doc.
|
||||||
|
var (
|
||||||
|
original, replacement, explanation, typ string
|
||||||
|
fromPos int
|
||||||
|
contentText string
|
||||||
|
)
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`SELECT s.original, s.replacement, s.explanation, s.type, s.from_pos, d.content_text
|
||||||
|
FROM suggestions s
|
||||||
|
JOIN documents d ON d.id = s.doc_id
|
||||||
|
WHERE s.id = ? AND d.user_id = ?`,
|
||||||
|
sugID, db.LocalUserID,
|
||||||
|
).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
errorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraph := surroundingParagraph(contentText, fromPos)
|
||||||
|
systemPrompt := llm.AskPetalSystemPrompt(original, replacement, typ, explanation, paragraph)
|
||||||
|
|
||||||
|
// SSE requires an unbuffered, flushable writer. chi's middleware writers pass
|
||||||
|
// Flush through; bail with a plain error if somehow they don't.
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
serverError(w, errors.New("streaming unsupported"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch, err := llm.StreamAskPetal(r.Context(), h.Client, systemPrompt, body.Messages)
|
||||||
|
if err != nil {
|
||||||
|
// The stream never opened (e.g. LLM unreachable) — a normal JSON error is
|
||||||
|
// still appropriate since we haven't written SSE headers yet.
|
||||||
|
errorJSON(w, http.StatusBadGateway, "chat failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering (e.g. nginx)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
for chunk := range ch {
|
||||||
|
writeSSE(w, "token", map[string]string{"text": chunk})
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
// Signal a clean end so the client can stop reading without waiting on EOF.
|
||||||
|
writeSSE(w, "done", map[string]bool{"done": true})
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSSE emits one named SSE event with a JSON data payload. JSON-encoding the
|
||||||
|
// data keeps token text (which may contain newlines) from breaking SSE framing.
|
||||||
|
func writeSSE(w http.ResponseWriter, event string, data any) {
|
||||||
|
payload, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("event: " + event + "\ndata: "))
|
||||||
|
_, _ = w.Write(payload)
|
||||||
|
_, _ = w.Write([]byte("\n\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// surroundingParagraph returns the paragraph of contentText containing the
|
||||||
|
// plain-text offset from. Tiptap flattens blocks with blank-line separators, so
|
||||||
|
// paragraphs are bounded by "\n\n". When the offset is unknown (original wasn't
|
||||||
|
// located, from == -1) it falls back to the latency-capped document so the tutor
|
||||||
|
// still has context to work with.
|
||||||
|
func surroundingParagraph(contentText string, from int) string {
|
||||||
|
if from < 0 || from > len(contentText) {
|
||||||
|
return strings.TrimSpace(llm.TruncateDoc(contentText))
|
||||||
|
}
|
||||||
|
start := strings.LastIndex(contentText[:from], "\n\n")
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
} else {
|
||||||
|
start += 2
|
||||||
|
}
|
||||||
|
end := len(contentText)
|
||||||
|
if rel := strings.Index(contentText[from:], "\n\n"); rel >= 0 {
|
||||||
|
end = from + rel
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(contentText[start:end])
|
||||||
|
}
|
||||||
148
internal/suggestions/chat_test.go
Normal file
148
internal/suggestions/chat_test.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package suggestions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// streamClient serves a canned checkpoint (so a suggestion can be seeded through
|
||||||
|
// the public /check route) and a fixed token stream for chat. It records the
|
||||||
|
// last streamed request so a test can assert on the injected system context.
|
||||||
|
type streamClient struct {
|
||||||
|
checkpoint string
|
||||||
|
tokens []string
|
||||||
|
lastStream llm.CompletionRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *streamClient) Complete(_ context.Context, _ llm.CompletionRequest) (string, error) {
|
||||||
|
return c.checkpoint, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *streamClient) Stream(_ context.Context, req llm.CompletionRequest) (<-chan string, error) {
|
||||||
|
c.lastStream = req
|
||||||
|
ch := make(chan string)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
for _, t := range c.tokens {
|
||||||
|
ch <- t
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAskPetalChat(t *testing.T) {
|
||||||
|
client := &streamClient{
|
||||||
|
checkpoint: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"}]}`,
|
||||||
|
tokens: []string{"Because ", "\"I\" ", "takes ", "\"have\"."},
|
||||||
|
}
|
||||||
|
srv, docID, _ := newTestServer(t, client)
|
||||||
|
|
||||||
|
// Seed one suggestion via the checkpoint route, then grab its id.
|
||||||
|
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
listed := do(t, srv, http.MethodGet, "/docs/"+docID+"/suggestions", "")
|
||||||
|
var sugs []db.Suggestion
|
||||||
|
if err := json.Unmarshal(listed.Body.Bytes(), &sugs); err != nil {
|
||||||
|
t.Fatalf("decode suggestions: %v", err)
|
||||||
|
}
|
||||||
|
if len(sugs) != 1 {
|
||||||
|
t.Fatalf("seed: want 1 suggestion, got %d", len(sugs))
|
||||||
|
}
|
||||||
|
sugID := sugs[0].ID
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodPost, "/suggestions/"+sugID+"/chat",
|
||||||
|
`{"messages":[{"role":"user","content":"why is this wrong?"}]}`)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("chat: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "text/event-stream" {
|
||||||
|
t.Fatalf("chat: content-type=%q, want text/event-stream", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The streamed tokens should arrive concatenated across data: events, and the
|
||||||
|
// stream should terminate with a done event.
|
||||||
|
body := rec.Body.String()
|
||||||
|
got := collectSSEText(body)
|
||||||
|
if want := "Because \"I\" takes \"have\"."; got != want {
|
||||||
|
t.Fatalf("streamed text = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "event: done") {
|
||||||
|
t.Fatalf("chat stream missing done event:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context injection: the system prompt (first message) must carry the
|
||||||
|
// suggestion's original text and the surrounding paragraph — loaded
|
||||||
|
// server-side, never from the client.
|
||||||
|
msgs := client.lastStream.Messages
|
||||||
|
if len(msgs) < 2 || msgs[0].Role != "system" {
|
||||||
|
t.Fatalf("expected a leading system message, got %+v", msgs)
|
||||||
|
}
|
||||||
|
sys := msgs[0].Content
|
||||||
|
if !strings.Contains(sys, "I has") {
|
||||||
|
t.Fatalf("system prompt missing original text:\n%s", sys)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sys, "I has two apple.") {
|
||||||
|
t.Fatalf("system prompt missing surrounding paragraph:\n%s", sys)
|
||||||
|
}
|
||||||
|
// The client's user message rides after the system turn.
|
||||||
|
if last := msgs[len(msgs)-1]; last.Role != "user" || last.Content != "why is this wrong?" {
|
||||||
|
t.Fatalf("user message not forwarded: %+v", last)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat sampling parameters from the spec.
|
||||||
|
if client.lastStream.MaxTokens != 512 || client.lastStream.Temperature != 0.7 {
|
||||||
|
t.Fatalf("unexpected chat params: %+v", client.lastStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAskPetalChatNotFound(t *testing.T) {
|
||||||
|
client := &streamClient{checkpoint: `{"suggestions":[]}`}
|
||||||
|
srv, _, _ := newTestServer(t, client)
|
||||||
|
rec := do(t, srv, http.MethodPost, "/suggestions/does-not-exist/chat",
|
||||||
|
`{"messages":[{"role":"user","content":"hi"}]}`)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("want 404 for unknown suggestion, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSurroundingParagraph(t *testing.T) {
|
||||||
|
text := "First paragraph here.\n\nThe target sentence lives here.\n\nThird paragraph."
|
||||||
|
from := strings.Index(text, "target")
|
||||||
|
got := surroundingParagraph(text, from)
|
||||||
|
if got != "The target sentence lives here." {
|
||||||
|
t.Fatalf("paragraph = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown offset falls back to the (trimmed) document.
|
||||||
|
if got := surroundingParagraph(text, -1); !strings.Contains(got, "First paragraph") {
|
||||||
|
t.Fatalf("fallback paragraph = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectSSEText concatenates the JSON text fields from every `event: token`
|
||||||
|
// frame in an SSE body.
|
||||||
|
func collectSSEText(body string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, frame := range strings.Split(body, "\n\n") {
|
||||||
|
if !strings.Contains(frame, "event: token") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(frame, "\n") {
|
||||||
|
data, ok := strings.CutPrefix(line, "data: ")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(data), &payload)
|
||||||
|
b.WriteString(payload.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
287
internal/suggestions/handlers.go
Normal file
287
internal/suggestions/handlers.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
// Package suggestions implements the grammar-checkpoint endpoint and the
|
||||||
|
// accept/dismiss surface for LLM-proposed edits. Checkpoints run a single LLM
|
||||||
|
// pass over a document and persist the resulting pending suggestions; the
|
||||||
|
// frontend re-anchors each one by its `original` string at render time, so the
|
||||||
|
// stored positions are advisory only (spec Note #6).
|
||||||
|
package suggestions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler holds the dependencies for the checkpoint + suggestion routes. The
|
||||||
|
// grammar checkpoint and the voice pass each get their own per-document rate
|
||||||
|
// limiter — they are independent passes with different cadences.
|
||||||
|
type Handler struct {
|
||||||
|
DB *db.DB
|
||||||
|
Client llm.LLMClient
|
||||||
|
Limit *llm.RateLimiter // grammar checkpoint floor
|
||||||
|
VoiceLimit *llm.RateLimiter // voice-consistency floor
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Handler with per-document checkpoint and voice rate limiters.
|
||||||
|
func New(database *db.DB, client llm.LLMClient) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
DB: database,
|
||||||
|
Client: client,
|
||||||
|
Limit: llm.NewRateLimiter(llm.CheckpointInterval),
|
||||||
|
VoiceLimit: llm.NewRateLimiter(llm.VoiceInterval),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterDocRoutes adds the document-scoped routes (check + voice + list) onto
|
||||||
|
// the existing /api/docs router so they share its base path.
|
||||||
|
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
||||||
|
r.Post("/{id}/check", h.check)
|
||||||
|
r.Post("/{id}/voice", h.voice)
|
||||||
|
r.Get("/{id}/suggestions", h.listForDoc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes returns the router mounted at /api/suggestions for per-suggestion
|
||||||
|
// actions.
|
||||||
|
func (h *Handler) Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Post("/{id}/accept", h.accept)
|
||||||
|
r.Post("/{id}/dismiss", h.dismiss)
|
||||||
|
r.Post("/{id}/chat", h.chat)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// check runs a grammar checkpoint over the document. Fast, typing-cadence pass.
|
||||||
|
func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.runPass(w, r, h.Limit, llm.RunCheckpoint, grammarScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// voice runs a Tier-1 voice-consistency pass over the whole document. Slow,
|
||||||
|
// explicit-action pass; replaces only the pending voice flags.
|
||||||
|
func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.runPass(w, r, h.VoiceLimit, llm.RunVoice, voiceScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// fresh batch scoped to this family, and returns the document's FULL pending set
|
||||||
|
// (both families) so the client always renders a unified picture.
|
||||||
|
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
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`SELECT content_text FROM documents WHERE id = ? AND user_id = ?`,
|
||||||
|
docID, db.LocalUserID,
|
||||||
|
).Scan(&contentText)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
errorJSON(w, http.StatusNotFound, "document not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to analyze on an empty document — skip the LLM round-trip.
|
||||||
|
if strings.TrimSpace(contentText) == "" {
|
||||||
|
writeJSON(w, http.StatusOK, []db.Suggestion{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok, _ := limiter.Allow(docID); !ok {
|
||||||
|
// Throttled: return the existing pending set unchanged rather than an
|
||||||
|
// error, so the frontend keeps showing current suggestions.
|
||||||
|
existing, err := h.fetchPending(docID)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, existing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := run(r.Context(), h.Client, contentText)
|
||||||
|
if err != nil {
|
||||||
|
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.replacePending(docID, contentText, raw, scope); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the unified pending set (grammar + voice), not just this batch, so
|
||||||
|
// a grammar check never drops the voice highlights from the client and the
|
||||||
|
// throttle path above stays consistent with the success path.
|
||||||
|
out, err := h.fetchPending(docID)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingScope describes how one LLM pass touches the shared suggestions table:
|
||||||
|
// which family of pending rows it replaces, and the type to stamp on the rows it
|
||||||
|
// inserts. The grammar checkpoint and voice pass each own a disjoint family, so
|
||||||
|
// running one never disturbs the other's pending flags.
|
||||||
|
type pendingScope struct {
|
||||||
|
deleteWhere string // extra WHERE clause scoping the DELETE to this family
|
||||||
|
forceType string // if set, every inserted row gets this type; else normalizeType
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// grammarScope owns the grammar/phrasing/idiom/clarity flags (everything but voice).
|
||||||
|
grammarScope = pendingScope{deleteWhere: "type != 'voice'", forceType: ""}
|
||||||
|
// voiceScope owns the voice flags only.
|
||||||
|
voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice}
|
||||||
|
)
|
||||||
|
|
||||||
|
// replacePending swaps a document's pending suggestions within one family for a
|
||||||
|
// fresh batch in a single transaction. Accepted/rejected suggestions and the
|
||||||
|
// other family's pending rows are left untouched.
|
||||||
|
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error {
|
||||||
|
tx, err := h.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND `+scope.deleteWhere,
|
||||||
|
docID, db.SuggestionStatusPending,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range raw {
|
||||||
|
typ := scope.forceType
|
||||||
|
if typ == "" {
|
||||||
|
typ = normalizeType(s.Type)
|
||||||
|
}
|
||||||
|
from, to := locate(contentText, s.Original)
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
docID, from, to, s.Original, s.Replacement, s.Explanation, typ,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// listForDoc returns the document's current pending suggestions (used when the
|
||||||
|
// editor loads a document, before any new checkpoint fires).
|
||||||
|
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
|
||||||
|
out, err := h.fetchPending(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
|
||||||
|
rows, err := h.DB.Query(
|
||||||
|
`SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at
|
||||||
|
FROM suggestions
|
||||||
|
WHERE doc_id = ? AND status = ?
|
||||||
|
ORDER BY from_pos ASC, created_at ASC`,
|
||||||
|
docID, db.SuggestionStatusPending,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []db.Suggestion{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s db.Suggestion
|
||||||
|
if err := rows.Scan(
|
||||||
|
&s.ID, &s.DocID, &s.FromPos, &s.ToPos, &s.Original, &s.Replacement,
|
||||||
|
&s.Explanation, &s.Type, &s.Status, &s.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// accept marks a suggestion accepted (the client applies the replacement text).
|
||||||
|
func (h *Handler) accept(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.setStatus(w, r, db.SuggestionStatusAccepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dismiss marks a suggestion rejected.
|
||||||
|
func (h *Handler) dismiss(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.setStatus(w, r, db.SuggestionStatusRejected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) {
|
||||||
|
res, err := h.DB.Exec(
|
||||||
|
`UPDATE suggestions SET status = ? WHERE id = ? AND status = ?`,
|
||||||
|
status, chi.URLParam(r, "id"), db.SuggestionStatusPending,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
errorJSON(w, http.StatusNotFound, "pending suggestion not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// locate finds the plaintext offsets of original within contentText. Returns
|
||||||
|
// (-1, -1) when not found; the frontend anchors by string regardless, so a miss
|
||||||
|
// here is non-fatal.
|
||||||
|
func locate(contentText, original string) (int, int) {
|
||||||
|
idx := strings.Index(contentText, original)
|
||||||
|
if idx < 0 {
|
||||||
|
return -1, -1
|
||||||
|
}
|
||||||
|
return idx, idx + len(original)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeType maps the model's type string onto a valid suggestion type,
|
||||||
|
// defaulting unknown values to grammar so a stray label never trips the CHECK.
|
||||||
|
func normalizeType(t string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||||
|
case db.SuggestionTypeGrammar, db.SuggestionTypePhrasing, db.SuggestionTypeIdiom, db.SuggestionTypeClarity:
|
||||||
|
return strings.ToLower(strings.TrimSpace(t))
|
||||||
|
default:
|
||||||
|
return db.SuggestionTypeGrammar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- response helpers -------------------------------------------------------
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorJSON(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverError(w http.ResponseWriter, err error) {
|
||||||
|
errorJSON(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
221
internal/suggestions/handlers_test.go
Normal file
221
internal/suggestions/handlers_test.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package suggestions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubClient returns a canned checkpoint response; it never touches the network.
|
||||||
|
type stubClient struct {
|
||||||
|
response string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubClient) Complete(_ context.Context, _ llm.CompletionRequest) (string, error) {
|
||||||
|
s.calls++
|
||||||
|
return s.response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubClient) Stream(_ context.Context, _ llm.CompletionRequest) (<-chan string, error) {
|
||||||
|
ch := make(chan string)
|
||||||
|
close(ch)
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestServer wires a real DB, a seeded document, and the suggestion routes
|
||||||
|
// (both the doc-scoped and the per-suggestion mounts) onto one router.
|
||||||
|
func newTestServer(t *testing.T, client llm.LLMClient) (http.Handler, string, *Handler) {
|
||||||
|
t.Helper()
|
||||||
|
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
|
||||||
|
// Seed a document with some content to check.
|
||||||
|
var docID string
|
||||||
|
err = database.QueryRow(
|
||||||
|
`INSERT INTO documents (user_id, content_text) VALUES (?, ?) RETURNING id`,
|
||||||
|
db.LocalUserID, "I has two apple.",
|
||||||
|
).Scan(&docID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed doc: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := New(database, client)
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/docs", func(dr chi.Router) { h.RegisterDocRoutes(dr) })
|
||||||
|
r.Mount("/suggestions", h.Routes())
|
||||||
|
return r, docID, h
|
||||||
|
}
|
||||||
|
|
||||||
|
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var r *http.Request
|
||||||
|
if body != "" {
|
||||||
|
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||||
|
} else {
|
||||||
|
r = httptest.NewRequest(method, path, nil)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, r)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckpointFlow(t *testing.T) {
|
||||||
|
client := &stubClient{response: `{"suggestions":[
|
||||||
|
{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"},
|
||||||
|
{"original":"two apple","replacement":"two apples","explanation":"plural noun","type":"grammar"}
|
||||||
|
]}`}
|
||||||
|
srv, docID, _ := newTestServer(t, client)
|
||||||
|
|
||||||
|
// Run a checkpoint → two pending suggestions, with positions located.
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var got []db.Suggestion
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("want 2 suggestions, got %d: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].Original != "I has" || got[0].FromPos != 0 {
|
||||||
|
t.Fatalf("first suggestion anchoring wrong: %+v", got[0])
|
||||||
|
}
|
||||||
|
if got[0].Status != db.SuggestionStatusPending {
|
||||||
|
t.Fatalf("new suggestion should be pending: %+v", got[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listing returns the same pending set.
|
||||||
|
rec = do(t, srv, http.MethodGet, "/docs/"+docID+"/suggestions", "")
|
||||||
|
var listed []db.Suggestion
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &listed)
|
||||||
|
if len(listed) != 2 {
|
||||||
|
t.Fatalf("list pending: want 2, got %d", len(listed))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept the first, dismiss the second.
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", ""); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("accept: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/suggestions/"+got[1].ID+"/dismiss", ""); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("dismiss: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending list is now empty (accepted/dismissed are excluded).
|
||||||
|
rec = do(t, srv, http.MethodGet, "/docs/"+docID+"/suggestions", "")
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &listed)
|
||||||
|
if len(listed) != 0 {
|
||||||
|
t.Fatalf("pending after resolve: want 0, got %d", len(listed))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-accepting an already-resolved suggestion 404s.
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", ""); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("re-accept: want 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVoicePassCoexists proves the voice pass and the grammar checkpoint own
|
||||||
|
// disjoint pending families: running one never wipes the other's flags, and
|
||||||
|
// each endpoint returns the unified pending set.
|
||||||
|
func TestVoicePassCoexists(t *testing.T) {
|
||||||
|
// One client serves both passes; the prompt distinguishes them but the stub
|
||||||
|
// just echoes whatever we set before each call.
|
||||||
|
client := &switchClient{}
|
||||||
|
srv, docID, h := newTestServer(t, client)
|
||||||
|
// Zero the voice floor so the test can re-run the pass without waiting out
|
||||||
|
// the real 20s interval; the family-scoping logic is what's under test here.
|
||||||
|
h.VoiceLimit = llm.NewRateLimiter(0)
|
||||||
|
|
||||||
|
// Grammar checkpoint first → one grammar flag.
|
||||||
|
client.response = `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"agreement","type":"grammar"}]}`
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voice pass next → one voice flag (null replacement). It must NOT remove the
|
||||||
|
// grammar flag; the response is the unified set of both.
|
||||||
|
client.response = `{"suggestions":[{"original":"two apple","replacement":null,"explanation":"sounds more formal","type":"voice"}]}`
|
||||||
|
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/voice", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("voice: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var got []db.Suggestion
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("voice response should carry both families, got %d: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
var grammar, voice *db.Suggestion
|
||||||
|
for i := range got {
|
||||||
|
switch got[i].Type {
|
||||||
|
case db.SuggestionTypeGrammar:
|
||||||
|
grammar = &got[i]
|
||||||
|
case db.SuggestionTypeVoice:
|
||||||
|
voice = &got[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if grammar == nil || voice == nil {
|
||||||
|
t.Fatalf("want one grammar + one voice flag, got %+v", got)
|
||||||
|
}
|
||||||
|
if voice.Replacement != "" {
|
||||||
|
t.Fatalf("voice flag should have empty replacement, got %q", voice.Replacement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh voice pass that finds nothing replaces only the voice flag; the
|
||||||
|
// grammar one survives.
|
||||||
|
client.response = `{"suggestions":[]}`
|
||||||
|
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/voice", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second voice: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
if len(got) != 1 || got[0].Type != db.SuggestionTypeGrammar {
|
||||||
|
t.Fatalf("after clearing voice, only the grammar flag should remain, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// switchClient returns a response that can be swapped between calls.
|
||||||
|
type switchClient struct {
|
||||||
|
response string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *switchClient) Complete(_ context.Context, _ llm.CompletionRequest) (string, error) {
|
||||||
|
s.calls++
|
||||||
|
return s.response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *switchClient) Stream(_ context.Context, _ llm.CompletionRequest) (<-chan string, error) {
|
||||||
|
ch := make(chan string)
|
||||||
|
close(ch)
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckpointRateLimit(t *testing.T) {
|
||||||
|
client := &stubClient{response: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"x","type":"grammar"}]}`}
|
||||||
|
srv, docID, _ := newTestServer(t, client)
|
||||||
|
|
||||||
|
// First check runs the model.
|
||||||
|
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
// Immediate second check is throttled — returns the existing set without
|
||||||
|
// hitting the model again.
|
||||||
|
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
if client.calls != 1 {
|
||||||
|
t.Fatalf("rate limit should suppress the second model call, got %d calls", client.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
web/package-lock.json
generated
52
web/package-lock.json
generated
@@ -15,6 +15,8 @@
|
|||||||
"@tiptap/pm": "^2.11.5",
|
"@tiptap/pm": "^2.11.5",
|
||||||
"@tiptap/react": "^2.11.5",
|
"@tiptap/react": "^2.11.5",
|
||||||
"@tiptap/starter-kit": "^2.11.5",
|
"@tiptap/starter-kit": "^2.11.5",
|
||||||
|
"lottie-web": "^5.13.0",
|
||||||
|
"nspell": "^2.1.5",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"dictionary-en": "^4.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.1.0"
|
||||||
@@ -2129,6 +2132,17 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dictionary-en": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dictionary-en/-/dictionary-en-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3NHnE1uq33ZE/CIwaZ6gqxa4BnglHnxeAcTM0GJ7cmtRGcvX9InMK/IqLtYcUMFCUpUNgybH+DzkqdgAo3F1zg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "(MIT AND BSD)",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.379",
|
"version": "1.5.379",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz",
|
||||||
@@ -2282,6 +2296,29 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/is-buffer": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -2605,6 +2642,12 @@
|
|||||||
"uc.micro": "^2.0.0"
|
"uc.micro": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lottie-web": {
|
||||||
|
"version": "5.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
|
||||||
|
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -2694,6 +2737,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nspell": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/nspell/-/nspell-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-PSStyugKMiD9mHmqI/CR5xXrSIGejUXPlo88FBRq5Og1kO5QwQ5Ilu8D8O5I/SHpoS+mibpw6uKA8rd3vXd2Sg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-buffer": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/orderedmap": {
|
"node_modules/orderedmap": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
|||||||
@@ -9,21 +9,24 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.1.0",
|
"@tiptap/extension-character-count": "^2.11.5",
|
||||||
"react-dom": "^19.1.0",
|
|
||||||
"@tiptap/react": "^2.11.5",
|
|
||||||
"@tiptap/pm": "^2.11.5",
|
|
||||||
"@tiptap/starter-kit": "^2.11.5",
|
|
||||||
"@tiptap/extension-underline": "^2.11.5",
|
|
||||||
"@tiptap/extension-text-align": "^2.11.5",
|
|
||||||
"@tiptap/extension-placeholder": "^2.11.5",
|
"@tiptap/extension-placeholder": "^2.11.5",
|
||||||
"@tiptap/extension-character-count": "^2.11.5"
|
"@tiptap/extension-text-align": "^2.11.5",
|
||||||
|
"@tiptap/extension-underline": "^2.11.5",
|
||||||
|
"@tiptap/pm": "^2.11.5",
|
||||||
|
"@tiptap/react": "^2.11.5",
|
||||||
|
"@tiptap/starter-kit": "^2.11.5",
|
||||||
|
"lottie-web": "^5.13.0",
|
||||||
|
"nspell": "^2.1.5",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"dictionary-en": "^4.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.1.0"
|
||||||
|
|||||||
347
web/public/dictionaries/en/LICENSE
Normal file
347
web/public/dictionaries/en/LICENSE
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
en_US Hunspell Dictionary
|
||||||
|
Version 2020.12.07
|
||||||
|
Mon Dec 7 20:14:35 2020 -0500 [5ef55f9]
|
||||||
|
http://wordlist.sourceforge.net
|
||||||
|
|
||||||
|
README file for English Hunspell dictionaries derived from SCOWL.
|
||||||
|
|
||||||
|
These dictionaries are created using the speller/make-hunspell-dict
|
||||||
|
script in SCOWL.
|
||||||
|
|
||||||
|
The following dictionaries are available:
|
||||||
|
|
||||||
|
en_US (American)
|
||||||
|
en_CA (Canadian)
|
||||||
|
en_GB-ise (British with "ise" spelling)
|
||||||
|
en_GB-ize (British with "ize" spelling)
|
||||||
|
en_AU (Australian)
|
||||||
|
|
||||||
|
en_US-large
|
||||||
|
en_CA-large
|
||||||
|
en_GB-large (with both "ise" and "ize" spelling)
|
||||||
|
en_AU-large
|
||||||
|
|
||||||
|
The normal (non-large) dictionaries correspond to SCOWL size 60 and,
|
||||||
|
to encourage consistent spelling, generally only include one spelling
|
||||||
|
variant for a word. The large dictionaries correspond to SCOWL size
|
||||||
|
70 and may include multiple spelling for a word when both variants are
|
||||||
|
considered almost equal. The larger dictionaries however (1) have not
|
||||||
|
been as carefully checked for errors as the normal dictionaries and
|
||||||
|
thus may contain misspelled or invalid words; and (2) contain
|
||||||
|
uncommon, yet valid, words that might cause problems as they are
|
||||||
|
likely to be misspellings of more common words (for example, "ort" and
|
||||||
|
"calender").
|
||||||
|
|
||||||
|
To get an idea of the difference in size, here are 25 random words
|
||||||
|
only found in the large dictionary for American English:
|
||||||
|
|
||||||
|
Bermejo Freyr's Guenevere Hatshepsut Nottinghamshire arrestment
|
||||||
|
crassitudes crural dogwatches errorless fetial flaxseeds godroon
|
||||||
|
incretion jalapeño's kelpie kishkes neuroglias pietisms pullulation
|
||||||
|
stemwinder stenoses syce thalassic zees
|
||||||
|
|
||||||
|
The en_US, en_CA and en_AU are the official dictionaries for Hunspell.
|
||||||
|
The en_GB and large dictionaries are made available on an experimental
|
||||||
|
basis. If you find them useful please send me a quick email at
|
||||||
|
kevina@gnu.org.
|
||||||
|
|
||||||
|
If none of these dictionaries suite you (for example, maybe you want
|
||||||
|
the normal dictionary that also includes common variants) additional
|
||||||
|
dictionaries can be generated at http://app.aspell.net/create or by
|
||||||
|
modifying speller/make-hunspell-dict in SCOWL. Please do let me know
|
||||||
|
if you end up publishing a customized dictionary.
|
||||||
|
|
||||||
|
If a word is not found in the dictionary or a word is there you think
|
||||||
|
shouldn't be, you can lookup the word up at http://app.aspell.net/lookup
|
||||||
|
to help determine why that is.
|
||||||
|
|
||||||
|
General comments on these list can be sent directly to me at
|
||||||
|
kevina@gnu.org or to the wordlist-devel mailing lists
|
||||||
|
(https://lists.sourceforge.net/lists/listinfo/wordlist-devel). If you
|
||||||
|
have specific issues with any of these dictionaries please file a bug
|
||||||
|
report at https://github.com/kevina/wordlist/issues.
|
||||||
|
|
||||||
|
IMPORTANT CHANGES INTRODUCED In 2016.11.20:
|
||||||
|
|
||||||
|
New Australian dictionaries thanks to the work of Benjamin Titze
|
||||||
|
(btitze@protonmail.ch).
|
||||||
|
|
||||||
|
IMPORTANT CHANGES INTRODUCED IN 2016.04.24:
|
||||||
|
|
||||||
|
The dictionaries are now in UTF-8 format instead of ISO-8859-1. This
|
||||||
|
was required to handle smart quotes correctly.
|
||||||
|
|
||||||
|
IMPORTANT CHANGES INTRODUCED IN 2016.01.19:
|
||||||
|
|
||||||
|
"SET UTF8" was changes to "SET UTF-8" in the affix file as some
|
||||||
|
versions of Hunspell do not recognize "UTF8".
|
||||||
|
|
||||||
|
ADDITIONAL NOTES:
|
||||||
|
|
||||||
|
The NOSUGGEST flag was added to certain taboo words. While I made an
|
||||||
|
honest attempt to flag the strongest taboo words with the NOSUGGEST
|
||||||
|
flag, I MAKE NO GUARANTEE THAT I FLAGGED EVERY POSSIBLE TABOO WORD.
|
||||||
|
The list was originally derived from Németh László, however I removed
|
||||||
|
some words which, while being considered taboo by some dictionaries,
|
||||||
|
are not really considered swear words in today's society.
|
||||||
|
|
||||||
|
COPYRIGHT, SOURCES, and CREDITS:
|
||||||
|
|
||||||
|
The English dictionaries come directly from SCOWL
|
||||||
|
and is thus under the same copyright of SCOWL. The affix file is
|
||||||
|
a heavily modified version of the original english.aff file which was
|
||||||
|
released as part of Geoff Kuenning's Ispell and as such is covered by
|
||||||
|
his BSD license. Part of SCOWL is also based on Ispell thus the
|
||||||
|
Ispell copyright is included with the SCOWL copyright.
|
||||||
|
|
||||||
|
The collective work is Copyright 2000-2018 by Kevin Atkinson as well
|
||||||
|
as any of the copyrights mentioned below:
|
||||||
|
|
||||||
|
Copyright 2000-2018 by Kevin Atkinson
|
||||||
|
|
||||||
|
Permission to use, copy, modify, distribute and sell these word
|
||||||
|
lists, the associated scripts, the output created from the scripts,
|
||||||
|
and its documentation for any purpose is hereby granted without fee,
|
||||||
|
provided that the above copyright notice appears in all copies and
|
||||||
|
that both that copyright notice and this permission notice appear in
|
||||||
|
supporting documentation. Kevin Atkinson makes no representations
|
||||||
|
about the suitability of this array for any purpose. It is provided
|
||||||
|
"as is" without express or implied warranty.
|
||||||
|
|
||||||
|
Alan Beale <biljir@pobox.com> also deserves special credit as he has,
|
||||||
|
in addition to providing the 12Dicts package and being a major
|
||||||
|
contributor to the ENABLE word list, given me an incredible amount of
|
||||||
|
feedback and created a number of special lists (those found in the
|
||||||
|
Supplement) in order to help improve the overall quality of SCOWL.
|
||||||
|
|
||||||
|
The 10 level includes the 1000 most common English words (according to
|
||||||
|
the Moby (TM) Words II [MWords] package), a subset of the 1000 most
|
||||||
|
common words on the Internet (again, according to Moby Words II), and
|
||||||
|
frequently class 16 from Brian Kelk's "UK English Wordlist
|
||||||
|
with Frequency Classification".
|
||||||
|
|
||||||
|
The MWords package was explicitly placed in the public domain:
|
||||||
|
|
||||||
|
The Moby lexicon project is complete and has
|
||||||
|
been place into the public domain. Use, sell,
|
||||||
|
rework, excerpt and use in any way on any platform.
|
||||||
|
|
||||||
|
Placing this material on internal or public servers is
|
||||||
|
also encouraged. The compiler is not aware of any
|
||||||
|
export restrictions so freely distribute world-wide.
|
||||||
|
|
||||||
|
You can verify the public domain status by contacting
|
||||||
|
|
||||||
|
Grady Ward
|
||||||
|
3449 Martha Ct.
|
||||||
|
Arcata, CA 95521-4884
|
||||||
|
|
||||||
|
grady@netcom.com
|
||||||
|
grady@northcoast.com
|
||||||
|
|
||||||
|
The "UK English Wordlist With Frequency Classification" is also in the
|
||||||
|
Public Domain:
|
||||||
|
|
||||||
|
Date: Sat, 08 Jul 2000 20:27:21 +0100
|
||||||
|
From: Brian Kelk <Brian.Kelk@cl.cam.ac.uk>
|
||||||
|
|
||||||
|
> I was wondering what the copyright status of your "UK English
|
||||||
|
> Wordlist With Frequency Classification" word list as it seems to
|
||||||
|
> be lacking any copyright notice.
|
||||||
|
|
||||||
|
There were many many sources in total, but any text marked
|
||||||
|
"copyright" was avoided. Locally-written documentation was one
|
||||||
|
source. An earlier version of the list resided in a filespace called
|
||||||
|
PUBLIC on the University mainframe, because it was considered public
|
||||||
|
domain.
|
||||||
|
|
||||||
|
Date: Tue, 11 Jul 2000 19:31:34 +0100
|
||||||
|
|
||||||
|
> So are you saying your word list is also in the public domain?
|
||||||
|
|
||||||
|
That is the intention.
|
||||||
|
|
||||||
|
The 20 level includes frequency classes 7-15 from Brian's word list.
|
||||||
|
|
||||||
|
The 35 level includes frequency classes 2-6 and words appearing in at
|
||||||
|
least 11 of 12 dictionaries as indicated in the 12Dicts package. All
|
||||||
|
words from the 12Dicts package have had likely inflections added via
|
||||||
|
my inflection database.
|
||||||
|
|
||||||
|
The 12Dicts package and Supplement is in the Public Domain.
|
||||||
|
|
||||||
|
The WordNet database, which was used in the creation of the
|
||||||
|
Inflections database, is under the following copyright:
|
||||||
|
|
||||||
|
This software and database is being provided to you, the LICENSEE,
|
||||||
|
by Princeton University under the following license. By obtaining,
|
||||||
|
using and/or copying this software and database, you agree that you
|
||||||
|
have read, understood, and will comply with these terms and
|
||||||
|
conditions.:
|
||||||
|
|
||||||
|
Permission to use, copy, modify and distribute this software and
|
||||||
|
database and its documentation for any purpose and without fee or
|
||||||
|
royalty is hereby granted, provided that you agree to comply with
|
||||||
|
the following copyright notice and statements, including the
|
||||||
|
disclaimer, and that the same appear on ALL copies of the software,
|
||||||
|
database and documentation, including modifications that you make
|
||||||
|
for internal use or for distribution.
|
||||||
|
|
||||||
|
WordNet 1.6 Copyright 1997 by Princeton University. All rights
|
||||||
|
reserved.
|
||||||
|
|
||||||
|
THIS SOFTWARE AND DATABASE IS PROVIDED "AS IS" AND PRINCETON
|
||||||
|
UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||||
|
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PRINCETON
|
||||||
|
UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES OF MERCHANT-
|
||||||
|
ABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE
|
||||||
|
LICENSED SOFTWARE, DATABASE OR DOCUMENTATION WILL NOT INFRINGE ANY
|
||||||
|
THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
|
||||||
|
|
||||||
|
The name of Princeton University or Princeton may not be used in
|
||||||
|
advertising or publicity pertaining to distribution of the software
|
||||||
|
and/or database. Title to copyright in this software, database and
|
||||||
|
any associated documentation shall at all times remain with
|
||||||
|
Princeton University and LICENSEE agrees to preserve same.
|
||||||
|
|
||||||
|
The 40 level includes words from Alan's 3esl list found in version 4.0
|
||||||
|
of his 12dicts package. Like his other stuff the 3esl list is also in the
|
||||||
|
public domain.
|
||||||
|
|
||||||
|
The 50 level includes Brian's frequency class 1, words appearing
|
||||||
|
in at least 5 of 12 of the dictionaries as indicated in the 12Dicts
|
||||||
|
package, and uppercase words in at least 4 of the previous 12
|
||||||
|
dictionaries. A decent number of proper names is also included: The
|
||||||
|
top 1000 male, female, and Last names from the 1990 Census report; a
|
||||||
|
list of names sent to me by Alan Beale; and a few names that I added
|
||||||
|
myself. Finally a small list of abbreviations not commonly found in
|
||||||
|
other word lists is included.
|
||||||
|
|
||||||
|
The name files form the Census report is a government document which I
|
||||||
|
don't think can be copyrighted.
|
||||||
|
|
||||||
|
The file special-jargon.50 uses common.lst and word.lst from the
|
||||||
|
"Unofficial Jargon File Word Lists" which is derived from "The Jargon
|
||||||
|
File". All of which is in the Public Domain. This file also contain
|
||||||
|
a few extra UNIX terms which are found in the file "unix-terms" in the
|
||||||
|
special/ directory.
|
||||||
|
|
||||||
|
The 55 level includes words from Alan's 2of4brif list found in version
|
||||||
|
4.0 of his 12dicts package. Like his other stuff the 2of4brif is also
|
||||||
|
in the public domain.
|
||||||
|
|
||||||
|
The 60 level includes all words appearing in at least 2 of the 12
|
||||||
|
dictionaries as indicated by the 12Dicts package.
|
||||||
|
|
||||||
|
The 70 level includes Brian's frequency class 0 and the 74,550 common
|
||||||
|
dictionary words from the MWords package. The common dictionary words,
|
||||||
|
like those from the 12Dicts package, have had all likely inflections
|
||||||
|
added. The 70 level also included the 5desk list from version 4.0 of
|
||||||
|
the 12Dics package which is in the public domain.
|
||||||
|
|
||||||
|
The 80 level includes the ENABLE word list, all the lists in the
|
||||||
|
ENABLE supplement package (except for ABLE), the "UK Advanced Cryptics
|
||||||
|
Dictionary" (UKACD), the list of signature words from the YAWL package,
|
||||||
|
and the 10,196 places list from the MWords package.
|
||||||
|
|
||||||
|
The ENABLE package, mainted by M\Cooper <thegrendel@theriver.com>,
|
||||||
|
is in the Public Domain:
|
||||||
|
|
||||||
|
The ENABLE master word list, WORD.LST, is herewith formally released
|
||||||
|
into the Public Domain. Anyone is free to use it or distribute it in
|
||||||
|
any manner they see fit. No fee or registration is required for its
|
||||||
|
use nor are "contributions" solicited (if you feel you absolutely
|
||||||
|
must contribute something for your own peace of mind, the authors of
|
||||||
|
the ENABLE list ask that you make a donation on their behalf to your
|
||||||
|
favorite charity). This word list is our gift to the Scrabble
|
||||||
|
community, as an alternate to "official" word lists. Game designers
|
||||||
|
may feel free to incorporate the WORD.LST into their games. Please
|
||||||
|
mention the source and credit us as originators of the list. Note
|
||||||
|
that if you, as a game designer, use the WORD.LST in your product,
|
||||||
|
you may still copyright and protect your product, but you may *not*
|
||||||
|
legally copyright or in any way restrict redistribution of the
|
||||||
|
WORD.LST portion of your product. This *may* under law restrict your
|
||||||
|
rights to restrict your users' rights, but that is only fair.
|
||||||
|
|
||||||
|
UKACD, by J Ross Beresford <ross@bryson.demon.co.uk>, is under the
|
||||||
|
following copyright:
|
||||||
|
|
||||||
|
Copyright (c) J Ross Beresford 1993-1999. All Rights Reserved.
|
||||||
|
|
||||||
|
The following restriction is placed on the use of this publication:
|
||||||
|
if The UK Advanced Cryptics Dictionary is used in a software package
|
||||||
|
or redistributed in any form, the copyright notice must be
|
||||||
|
prominently displayed and the text of this document must be included
|
||||||
|
verbatim.
|
||||||
|
|
||||||
|
There are no other restrictions: I would like to see the list
|
||||||
|
distributed as widely as possible.
|
||||||
|
|
||||||
|
The 95 level includes the 354,984 single words, 256,772 compound
|
||||||
|
words, 4,946 female names and the 3,897 male names, and 21,986 names
|
||||||
|
from the MWords package, ABLE.LST from the ENABLE Supplement, and some
|
||||||
|
additional words found in my part-of-speech database that were not
|
||||||
|
found anywhere else.
|
||||||
|
|
||||||
|
Accent information was taken from UKACD.
|
||||||
|
|
||||||
|
The VarCon package was used to create the American, British, Canadian,
|
||||||
|
and Australian word list. It is under the following copyright:
|
||||||
|
|
||||||
|
Copyright 2000-2016 by Kevin Atkinson
|
||||||
|
|
||||||
|
Permission to use, copy, modify, distribute and sell this array, the
|
||||||
|
associated software, and its documentation for any purpose is hereby
|
||||||
|
granted without fee, provided that the above copyright notice appears
|
||||||
|
in all copies and that both that copyright notice and this permission
|
||||||
|
notice appear in supporting documentation. Kevin Atkinson makes no
|
||||||
|
representations about the suitability of this array for any
|
||||||
|
purpose. It is provided "as is" without express or implied warranty.
|
||||||
|
|
||||||
|
Copyright 2016 by Benjamin Titze
|
||||||
|
|
||||||
|
Permission to use, copy, modify, distribute and sell this array, the
|
||||||
|
associated software, and its documentation for any purpose is hereby
|
||||||
|
granted without fee, provided that the above copyright notice appears
|
||||||
|
in all copies and that both that copyright notice and this permission
|
||||||
|
notice appear in supporting documentation. Benjamin Titze makes no
|
||||||
|
representations about the suitability of this array for any
|
||||||
|
purpose. It is provided "as is" without express or implied warranty.
|
||||||
|
|
||||||
|
Since the original words lists come from the Ispell distribution:
|
||||||
|
|
||||||
|
Copyright 1993, Geoff Kuenning, Granada Hills, CA
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
3. All modifications to the source code must be clearly marked as
|
||||||
|
such. Binary redistributions based on modified source code
|
||||||
|
must be clearly marked as modified versions in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
(clause 4 removed with permission from Geoff Kuenning)
|
||||||
|
5. The name of Geoff Kuenning may not be used to endorse or promote
|
||||||
|
products derived from this software without specific prior
|
||||||
|
written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY GEOFF KUENNING AND CONTRIBUTORS ``AS IS'' AND
|
||||||
|
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
ARE DISCLAIMED. IN NO EVENT SHALL GEOFF KUENNING OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
|
||||||
|
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||||
|
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||||
|
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||||
|
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGE.
|
||||||
|
|
||||||
|
Build Date: Mon Dec 7 20:19:27 EST 2020
|
||||||
|
Wordlist Command: mk-list --accents=strip en_US 60
|
||||||
205
web/public/dictionaries/en/en.aff
Normal file
205
web/public/dictionaries/en/en.aff
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
SET UTF-8
|
||||||
|
TRY esianrtolcdugmphbyfvkwzESIANRTOLCDUGMPHBYFVKWZ'
|
||||||
|
ICONV 1
|
||||||
|
ICONV ’ '
|
||||||
|
NOSUGGEST !
|
||||||
|
|
||||||
|
# ordinal numbers
|
||||||
|
COMPOUNDMIN 1
|
||||||
|
# only in compounds: 1th, 2th, 3th
|
||||||
|
ONLYINCOMPOUND c
|
||||||
|
# compound rules:
|
||||||
|
# 1. [0-9]*1[0-9]th (10th, 11th, 12th, 56714th, etc.)
|
||||||
|
# 2. [0-9]*[02-9](1st|2nd|3rd|[4-9]th) (21st, 22nd, 123rd, 1234th, etc.)
|
||||||
|
COMPOUNDRULE 2
|
||||||
|
COMPOUNDRULE n*1t
|
||||||
|
COMPOUNDRULE n*mp
|
||||||
|
WORDCHARS 0123456789
|
||||||
|
|
||||||
|
PFX A Y 1
|
||||||
|
PFX A 0 re .
|
||||||
|
|
||||||
|
PFX I Y 1
|
||||||
|
PFX I 0 in .
|
||||||
|
|
||||||
|
PFX U Y 1
|
||||||
|
PFX U 0 un .
|
||||||
|
|
||||||
|
PFX C Y 1
|
||||||
|
PFX C 0 de .
|
||||||
|
|
||||||
|
PFX E Y 1
|
||||||
|
PFX E 0 dis .
|
||||||
|
|
||||||
|
PFX F Y 1
|
||||||
|
PFX F 0 con .
|
||||||
|
|
||||||
|
PFX K Y 1
|
||||||
|
PFX K 0 pro .
|
||||||
|
|
||||||
|
SFX V N 2
|
||||||
|
SFX V e ive e
|
||||||
|
SFX V 0 ive [^e]
|
||||||
|
|
||||||
|
SFX N Y 3
|
||||||
|
SFX N e ion e
|
||||||
|
SFX N y ication y
|
||||||
|
SFX N 0 en [^ey]
|
||||||
|
|
||||||
|
SFX X Y 3
|
||||||
|
SFX X e ions e
|
||||||
|
SFX X y ications y
|
||||||
|
SFX X 0 ens [^ey]
|
||||||
|
|
||||||
|
SFX H N 2
|
||||||
|
SFX H y ieth y
|
||||||
|
SFX H 0 th [^y]
|
||||||
|
|
||||||
|
SFX Y Y 1
|
||||||
|
SFX Y 0 ly .
|
||||||
|
|
||||||
|
SFX G Y 2
|
||||||
|
SFX G e ing e
|
||||||
|
SFX G 0 ing [^e]
|
||||||
|
|
||||||
|
SFX J Y 2
|
||||||
|
SFX J e ings e
|
||||||
|
SFX J 0 ings [^e]
|
||||||
|
|
||||||
|
SFX D Y 4
|
||||||
|
SFX D 0 d e
|
||||||
|
SFX D y ied [^aeiou]y
|
||||||
|
SFX D 0 ed [^ey]
|
||||||
|
SFX D 0 ed [aeiou]y
|
||||||
|
|
||||||
|
SFX T N 4
|
||||||
|
SFX T 0 st e
|
||||||
|
SFX T y iest [^aeiou]y
|
||||||
|
SFX T 0 est [aeiou]y
|
||||||
|
SFX T 0 est [^ey]
|
||||||
|
|
||||||
|
SFX R Y 4
|
||||||
|
SFX R 0 r e
|
||||||
|
SFX R y ier [^aeiou]y
|
||||||
|
SFX R 0 er [aeiou]y
|
||||||
|
SFX R 0 er [^ey]
|
||||||
|
|
||||||
|
SFX Z Y 4
|
||||||
|
SFX Z 0 rs e
|
||||||
|
SFX Z y iers [^aeiou]y
|
||||||
|
SFX Z 0 ers [aeiou]y
|
||||||
|
SFX Z 0 ers [^ey]
|
||||||
|
|
||||||
|
SFX S Y 4
|
||||||
|
SFX S y ies [^aeiou]y
|
||||||
|
SFX S 0 s [aeiou]y
|
||||||
|
SFX S 0 es [sxzh]
|
||||||
|
SFX S 0 s [^sxzhy]
|
||||||
|
|
||||||
|
SFX P Y 3
|
||||||
|
SFX P y iness [^aeiou]y
|
||||||
|
SFX P 0 ness [aeiou]y
|
||||||
|
SFX P 0 ness [^y]
|
||||||
|
|
||||||
|
SFX M Y 1
|
||||||
|
SFX M 0 's .
|
||||||
|
|
||||||
|
SFX B Y 3
|
||||||
|
SFX B 0 able [^aeiou]
|
||||||
|
SFX B 0 able ee
|
||||||
|
SFX B e able [^aeiou]e
|
||||||
|
|
||||||
|
SFX L Y 1
|
||||||
|
SFX L 0 ment .
|
||||||
|
|
||||||
|
REP 90
|
||||||
|
REP a ei
|
||||||
|
REP ei a
|
||||||
|
REP a ey
|
||||||
|
REP ey a
|
||||||
|
REP ai ie
|
||||||
|
REP ie ai
|
||||||
|
REP alot a_lot
|
||||||
|
REP are air
|
||||||
|
REP are ear
|
||||||
|
REP are eir
|
||||||
|
REP air are
|
||||||
|
REP air ere
|
||||||
|
REP ere air
|
||||||
|
REP ere ear
|
||||||
|
REP ere eir
|
||||||
|
REP ear are
|
||||||
|
REP ear air
|
||||||
|
REP ear ere
|
||||||
|
REP eir are
|
||||||
|
REP eir ere
|
||||||
|
REP ch te
|
||||||
|
REP te ch
|
||||||
|
REP ch ti
|
||||||
|
REP ti ch
|
||||||
|
REP ch tu
|
||||||
|
REP tu ch
|
||||||
|
REP ch s
|
||||||
|
REP s ch
|
||||||
|
REP ch k
|
||||||
|
REP k ch
|
||||||
|
REP f ph
|
||||||
|
REP ph f
|
||||||
|
REP gh f
|
||||||
|
REP f gh
|
||||||
|
REP i igh
|
||||||
|
REP igh i
|
||||||
|
REP i uy
|
||||||
|
REP uy i
|
||||||
|
REP i ee
|
||||||
|
REP ee i
|
||||||
|
REP j di
|
||||||
|
REP di j
|
||||||
|
REP j gg
|
||||||
|
REP gg j
|
||||||
|
REP j ge
|
||||||
|
REP ge j
|
||||||
|
REP s ti
|
||||||
|
REP ti s
|
||||||
|
REP s ci
|
||||||
|
REP ci s
|
||||||
|
REP k cc
|
||||||
|
REP cc k
|
||||||
|
REP k qu
|
||||||
|
REP qu k
|
||||||
|
REP kw qu
|
||||||
|
REP o eau
|
||||||
|
REP eau o
|
||||||
|
REP o ew
|
||||||
|
REP ew o
|
||||||
|
REP oo ew
|
||||||
|
REP ew oo
|
||||||
|
REP ew ui
|
||||||
|
REP ui ew
|
||||||
|
REP oo ui
|
||||||
|
REP ui oo
|
||||||
|
REP ew u
|
||||||
|
REP u ew
|
||||||
|
REP oo u
|
||||||
|
REP u oo
|
||||||
|
REP u oe
|
||||||
|
REP oe u
|
||||||
|
REP u ieu
|
||||||
|
REP ieu u
|
||||||
|
REP ue ew
|
||||||
|
REP ew ue
|
||||||
|
REP uff ough
|
||||||
|
REP oo ieu
|
||||||
|
REP ieu oo
|
||||||
|
REP ier ear
|
||||||
|
REP ear ier
|
||||||
|
REP ear air
|
||||||
|
REP air ear
|
||||||
|
REP w qu
|
||||||
|
REP qu w
|
||||||
|
REP z ss
|
||||||
|
REP ss z
|
||||||
|
REP shun tion
|
||||||
|
REP shun sion
|
||||||
|
REP shun cion
|
||||||
|
REP size cise
|
||||||
49569
web/public/dictionaries/en/en.dic
Normal file
49569
web/public/dictionaries/en/en.dic
Normal file
File diff suppressed because it is too large
Load Diff
283
web/src/App.tsx
283
web/src/App.tsx
@@ -1,46 +1,261 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, type DocSummary, type Document, type Suggestion } from './api/client'
|
||||||
|
import { useAutoSave } from './hooks/useAutoSave'
|
||||||
|
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 { StatusBar } from './components/StatusBar/StatusBar'
|
||||||
|
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||||
|
|
||||||
// Phase 0 placeholder: proves the stack end-to-end — React + Tailwind v4 tokens
|
|
||||||
// rendering, and the Go backend reachable via the /api proxy. Real UI lands in
|
|
||||||
// later phases (DocList, Editor, StatusBar).
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [api, setApi] = useState<'checking' | 'ok' | 'down'>('checking')
|
const [docs, setDocs] = useState<DocSummary[]>([])
|
||||||
|
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [wordCount, setWordCount] = useState(0)
|
||||||
|
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.
|
||||||
|
const [focusMode, setFocusMode] = useState(false)
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
|
// Monotonic counters the companion watches to react to writing + accepts.
|
||||||
|
const [editTick, setEditTick] = useState(0)
|
||||||
|
const [acceptTick, setAcceptTick] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
|
||||||
fetch('/api/health')
|
const {
|
||||||
.then((r) => setApi(r.ok ? 'ok' : 'down'))
|
suggestions,
|
||||||
.catch(() => setApi('down'))
|
checking,
|
||||||
|
voicing,
|
||||||
|
schedule: scheduleCheckpoint,
|
||||||
|
runVoice,
|
||||||
|
removeSuggestion,
|
||||||
|
} = useCheckpoint(currentDoc?.id ?? null)
|
||||||
|
// Browser-side spell checker — loads the en-US dictionary once per session.
|
||||||
|
const { checker: spellChecker, addWord } = useSpellChecker()
|
||||||
|
|
||||||
|
// Patch a summary in the sidebar list (optimistic title / word-count updates).
|
||||||
|
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
|
||||||
|
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const openDoc = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
await saveNow() // flush any pending edits to the doc we're leaving
|
||||||
|
const doc = await api.getDoc(id)
|
||||||
|
setCurrentDoc(doc)
|
||||||
|
setTitle(doc.title)
|
||||||
|
setWordCount(doc.word_count)
|
||||||
|
},
|
||||||
|
[saveNow],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initial load: fetch the list, opening the first doc (or creating one).
|
||||||
|
const bootedRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (bootedRef.current) return
|
||||||
|
bootedRef.current = true
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
let list = await api.listDocs()
|
||||||
|
if (list.length === 0) {
|
||||||
|
const fresh = await api.createDoc()
|
||||||
|
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at }]
|
||||||
|
setDocs(list)
|
||||||
|
setCurrentDoc(fresh)
|
||||||
|
setTitle(fresh.title)
|
||||||
|
setWordCount(0)
|
||||||
|
} else {
|
||||||
|
setDocs(list)
|
||||||
|
await openDoc(list[0].id)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to load documents', err)
|
||||||
|
} finally {
|
||||||
|
setReady(true)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [openDoc])
|
||||||
|
|
||||||
|
const handleCreate = useCallback(async () => {
|
||||||
|
await saveNow()
|
||||||
|
const fresh = await api.createDoc()
|
||||||
|
setDocs((prev) => [
|
||||||
|
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at },
|
||||||
|
...prev,
|
||||||
|
])
|
||||||
|
setCurrentDoc(fresh)
|
||||||
|
setTitle(fresh.title)
|
||||||
|
setWordCount(0)
|
||||||
|
}, [saveNow])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
await api.deleteDoc(id)
|
||||||
|
setDocs((prev) => {
|
||||||
|
const remaining = prev.filter((d) => d.id !== id)
|
||||||
|
if (currentDoc?.id === id) {
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
void openDoc(remaining[0].id)
|
||||||
|
} else {
|
||||||
|
setCurrentDoc(null)
|
||||||
|
setTitle('')
|
||||||
|
setWordCount(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remaining
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[currentDoc, openDoc],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleTitleChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setTitle(value)
|
||||||
|
if (currentDoc) {
|
||||||
|
patchSummary(currentDoc.id, { title: value })
|
||||||
|
schedule({ title: value })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentDoc, patchSummary, schedule],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleEditorChange = useCallback(
|
||||||
|
(change: EditorChange) => {
|
||||||
|
setWordCount(change.word_count)
|
||||||
|
setEditTick((n) => n + 1)
|
||||||
|
if (currentDoc) {
|
||||||
|
patchSummary(currentDoc.id, { word_count: change.word_count })
|
||||||
|
schedule(change)
|
||||||
|
scheduleCheckpoint()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentDoc, patchSummary, 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(
|
||||||
|
async (s: Suggestion) => {
|
||||||
|
removeSuggestion(s.id)
|
||||||
|
setAcceptTick((n) => n + 1)
|
||||||
|
try {
|
||||||
|
await api.acceptSuggestion(s.id)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('accept failed', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeSuggestion],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Escape always restores the sidebar while in distraction-free mode.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusMode) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setFocusMode(false)
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [focusMode])
|
||||||
|
|
||||||
|
// A pointer-down outside the centered editor canvas (the cream gutters, the
|
||||||
|
// header, the status bar) also restores the sidebar.
|
||||||
|
const handleChromeDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (!canvasRef.current?.contains(e.target as Node)) setFocusMode(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(
|
||||||
|
async (s: Suggestion) => {
|
||||||
|
removeSuggestion(s.id)
|
||||||
|
try {
|
||||||
|
await api.dismissSuggestion(s.id)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('dismiss failed', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeSuggestion],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-6 px-6 text-center">
|
<div className="flex h-full flex-col">
|
||||||
<div
|
<header
|
||||||
className="flex flex-col items-center gap-3 bg-surface px-12 py-10"
|
onMouseDown={handleChromeDown}
|
||||||
style={{ borderRadius: 'var(--radius-card)', boxShadow: 'var(--shadow-soft)' }}
|
className="flex h-12 shrink-0 items-center gap-2 px-5"
|
||||||
|
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
<span className="text-5xl">🌸</span>
|
<span className="text-xl">🌸</span>
|
||||||
<h1 className="text-3xl font-extrabold text-plum">Petal</h1>
|
<span className="text-lg font-extrabold text-plum">Petal</span>
|
||||||
<p className="max-w-xs text-muted" style={{ fontFamily: 'var(--font-body)' }}>
|
</header>
|
||||||
A cozy place to write.
|
|
||||||
</p>
|
<div className="flex min-h-0 flex-1">
|
||||||
<span
|
<div className={`petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}`}>
|
||||||
className="mt-2 inline-flex items-center gap-2 px-4 py-1.5 text-sm font-semibold"
|
<DocList
|
||||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)' }}
|
docs={docs}
|
||||||
>
|
selectedId={currentDoc?.id ?? null}
|
||||||
<span
|
onSelect={openDoc}
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
onCreate={handleCreate}
|
||||||
style={{
|
onDelete={handleDelete}
|
||||||
background:
|
|
||||||
api === 'ok'
|
|
||||||
? 'var(--color-success)'
|
|
||||||
: api === 'down'
|
|
||||||
? 'var(--color-accent)'
|
|
||||||
: 'var(--color-muted)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{api === 'checking' ? 'Checking backend…' : api === 'ok' ? 'Backend connected' : 'Backend offline'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
|
{currentDoc ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onMouseDown={handleChromeDown}
|
||||||
|
className="flex flex-1 flex-col overflow-y-auto px-6 py-8"
|
||||||
|
>
|
||||||
|
<div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => handleTitleChange(e.target.value)}
|
||||||
|
placeholder="Untitled"
|
||||||
|
aria-label="Document title"
|
||||||
|
className="mb-5 w-full bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
|
||||||
|
style={{ fontFamily: 'var(--font-ui)' }}
|
||||||
|
/>
|
||||||
|
<EditorCore
|
||||||
|
key={currentDoc.id}
|
||||||
|
docId={currentDoc.id}
|
||||||
|
initialContent={currentDoc.content}
|
||||||
|
onChange={handleEditorChange}
|
||||||
|
suggestions={suggestions}
|
||||||
|
onAccept={handleAccept}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
onVoiceCheck={runVoice}
|
||||||
|
voicing={voicing}
|
||||||
|
onFocusMode={() => setFocusMode(true)}
|
||||||
|
spellChecker={spellChecker}
|
||||||
|
onAddWord={addWord}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div onMouseDown={handleChromeDown}>
|
||||||
|
<StatusBar
|
||||||
|
wordCount={wordCount}
|
||||||
|
saveStatus={status}
|
||||||
|
checking={checking}
|
||||||
|
voicing={voicing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="flex flex-1 items-center justify-center text-sm"
|
||||||
|
style={{ color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
{ready ? 'Create a document to start writing.' : 'Loading…'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PetalCompanion
|
||||||
|
wordCount={wordCount}
|
||||||
|
saveStatus={status}
|
||||||
|
editTick={editTick}
|
||||||
|
acceptTick={acceptTick}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
146
web/src/api/client.ts
Normal file
146
web/src/api/client.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// Thin fetch wrappers for the Petal backend. Everything lives under /api, which
|
||||||
|
// Vite proxies to the Go server in dev and the binary serves directly in prod.
|
||||||
|
|
||||||
|
export interface DocSummary {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
word_count: number
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Document {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
title: string
|
||||||
|
content: string // Tiptap JSON (stringified)
|
||||||
|
content_text: string // flattened plain text
|
||||||
|
word_count: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fields the editor sends on auto-save. All optional so a rename can send title
|
||||||
|
// alone; the editor sends the full set.
|
||||||
|
export interface DocUpdate {
|
||||||
|
title?: string
|
||||||
|
content?: string
|
||||||
|
content_text?: string
|
||||||
|
word_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
|
||||||
|
|
||||||
|
// A single LLM-proposed edit. `original` is the source of truth for placement —
|
||||||
|
// the editor re-anchors by matching this string in the live document (spec
|
||||||
|
// Note #6); from_pos/to_pos are server-side advisory only. `replacement` is
|
||||||
|
// empty for voice flags (awareness-only, no correction to apply).
|
||||||
|
export interface Suggestion {
|
||||||
|
id: string
|
||||||
|
doc_id: string
|
||||||
|
from_pos: number
|
||||||
|
to_pos: number
|
||||||
|
original: string
|
||||||
|
replacement: string
|
||||||
|
explanation: string
|
||||||
|
type: SuggestionType
|
||||||
|
status: 'pending' | 'accepted' | 'rejected'
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`/api${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...init,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(() => '')
|
||||||
|
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
listDocs: () => req<DocSummary[]>('/docs'),
|
||||||
|
createDoc: () => req<Document>('/docs', { method: 'POST' }),
|
||||||
|
getDoc: (id: string) => req<Document>(`/docs/${id}`),
|
||||||
|
updateDoc: (id: string, body: DocUpdate) =>
|
||||||
|
req<Document>(`/docs/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
|
deleteDoc: (id: string) => req<void>(`/docs/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Grammar checkpoint: runs an LLM pass and returns the fresh pending set.
|
||||||
|
// Rate-limited per document server-side (returns the existing set if too soon).
|
||||||
|
// Both passes return the UNIFIED pending set (grammar + voice), so the client
|
||||||
|
// never drops one family's highlights when the other refreshes.
|
||||||
|
checkDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/check`, { method: 'POST' }),
|
||||||
|
// Voice-consistency pass: whole-document, explicit-action, slower. Returns the
|
||||||
|
// unified pending set too. Rate-limited per document server-side.
|
||||||
|
voiceDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/voice`, { method: 'POST' }),
|
||||||
|
// Pending suggestions for a doc, loaded when the editor opens it.
|
||||||
|
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
|
||||||
|
acceptSuggestion: (id: string) =>
|
||||||
|
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
|
||||||
|
dismissSuggestion: (id: string) =>
|
||||||
|
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// One turn in an Ask Petal conversation. History lives only in the component —
|
||||||
|
// the server is stateless and re-injects the suggestion context every request.
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamSuggestionChat POSTs the conversation to the SSE chat endpoint and
|
||||||
|
// invokes onToken for each text chunk as it arrives. It uses fetch + a
|
||||||
|
// ReadableStream reader (not EventSource, which can't POST) and resolves when
|
||||||
|
// the stream ends. Abort via the optional signal to cancel mid-response.
|
||||||
|
export async function streamSuggestionChat(
|
||||||
|
suggestionId: string,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
onToken: (text: string) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(`/api/suggestions/${suggestionId}/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ messages }),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
const detail = await res.text().catch(() => '')
|
||||||
|
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buf = ''
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buf += decoder.decode(value, { stream: true })
|
||||||
|
// SSE events are separated by a blank line. Process every complete one and
|
||||||
|
// keep the trailing partial in the buffer.
|
||||||
|
let sep: number
|
||||||
|
while ((sep = buf.indexOf('\n\n')) >= 0) {
|
||||||
|
const event = parseSSE(buf.slice(0, sep))
|
||||||
|
buf = buf.slice(sep + 2)
|
||||||
|
if (event.name === 'done') return
|
||||||
|
if (event.name === 'token' && event.data) {
|
||||||
|
const text = (JSON.parse(event.data) as { text: string }).text
|
||||||
|
if (text) onToken(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSSE pulls the event name and data payload out of one raw SSE frame.
|
||||||
|
function parseSSE(frame: string): { name: string; data: string } {
|
||||||
|
let name = 'message'
|
||||||
|
const dataLines: string[] = []
|
||||||
|
for (const line of frame.split('\n')) {
|
||||||
|
if (line.startsWith('event:')) name = line.slice(6).trim()
|
||||||
|
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
|
||||||
|
}
|
||||||
|
return { name, data: dataLines.join('\n') }
|
||||||
|
}
|
||||||
78
web/src/components/Companion/LottiePlayer.tsx
Normal file
78
web/src/components/Companion/LottiePlayer.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
// Light build: drops the eval-based expression engine (smaller bundle, no eval
|
||||||
|
// warning). Plenty for a looping mascot; swap to 'lottie-web' only if an asset
|
||||||
|
// genuinely needs After Effects expressions.
|
||||||
|
import lottie, { type AnimationItem } from 'lottie-web/build/player/lottie_light'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Parsed Lottie JSON (imported, so it bundles offline). When undefined the
|
||||||
|
// `fallback` is rendered instead — letting the companion ship before any
|
||||||
|
// animation asset exists.
|
||||||
|
animationData?: object
|
||||||
|
loop?: boolean
|
||||||
|
className?: string
|
||||||
|
fallback: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tighten the SVG viewBox to the animation's actual drawn content so an asset
|
||||||
|
// with a lot of empty artboard padding (common with stock Lottie files) fills
|
||||||
|
// the badge instead of floating tiny in the middle. We union the content bbox
|
||||||
|
// across a few frames (the mascot moves) and make it square so it isn't
|
||||||
|
// distorted by the square container.
|
||||||
|
function fitToContent(anim: AnimationItem, svg: SVGSVGElement) {
|
||||||
|
try {
|
||||||
|
const frames = anim.getDuration(true)
|
||||||
|
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity
|
||||||
|
const SAMPLES = 6
|
||||||
|
for (let i = 0; i < SAMPLES; i++) {
|
||||||
|
anim.goToAndStop(Math.floor(((frames - 1) * i) / (SAMPLES - 1)), true)
|
||||||
|
const b = svg.getBBox()
|
||||||
|
if (b.width === 0 || b.height === 0) continue
|
||||||
|
x1 = Math.min(x1, b.x)
|
||||||
|
y1 = Math.min(y1, b.y)
|
||||||
|
x2 = Math.max(x2, b.x + b.width)
|
||||||
|
y2 = Math.max(y2, b.y + b.height)
|
||||||
|
}
|
||||||
|
if (!isFinite(x1)) return
|
||||||
|
const cx = (x1 + x2) / 2, cy = (y1 + y2) / 2
|
||||||
|
const side = Math.max(x2 - x1, y2 - y1) * 1.14 // ~7% breathing room each side
|
||||||
|
svg.setAttribute('viewBox', `${cx - side / 2} ${cy - side / 2} ${side} ${side}`)
|
||||||
|
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
|
||||||
|
} catch {
|
||||||
|
/* getBBox unsupported / not laid out — leave the original viewBox */
|
||||||
|
} finally {
|
||||||
|
anim.goToAndPlay(0, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully
|
||||||
|
// offline). Reloads the animation whenever the data changes — moods swap by
|
||||||
|
// passing a different `animationData`.
|
||||||
|
export function LottiePlayer({ animationData, loop = true, className, fallback }: Props) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const anim = useRef<AnimationItem | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = ref.current
|
||||||
|
if (!container || !animationData) return
|
||||||
|
const item = lottie.loadAnimation({
|
||||||
|
container,
|
||||||
|
renderer: 'svg',
|
||||||
|
loop,
|
||||||
|
autoplay: true,
|
||||||
|
animationData,
|
||||||
|
})
|
||||||
|
anim.current = item
|
||||||
|
item.addEventListener('DOMLoaded', () => {
|
||||||
|
const svg = container.querySelector('svg')
|
||||||
|
if (svg) fitToContent(item, svg as SVGSVGElement)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
item.destroy()
|
||||||
|
anim.current = null
|
||||||
|
}
|
||||||
|
}, [animationData, loop])
|
||||||
|
|
||||||
|
if (!animationData) return <>{fallback}</>
|
||||||
|
return <div ref={ref} className={className} aria-hidden />
|
||||||
|
}
|
||||||
182
web/src/components/Companion/PetalCompanion.tsx
Normal file
182
web/src/components/Companion/PetalCompanion.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||||
|
import { useCompanion, type Mood } from './useCompanion'
|
||||||
|
import { LottiePlayer } from './LottiePlayer'
|
||||||
|
import { COMPANIONS, DEFAULT_COMPANION } from './companions'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
wordCount: number
|
||||||
|
saveStatus: SaveStatus
|
||||||
|
editTick: number
|
||||||
|
acceptTick: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emoji placeholder per mood, used for any mood a companion has no Lottie for.
|
||||||
|
const MOOD_EMOJI: Record<Mood, string> = {
|
||||||
|
idle: '🐾',
|
||||||
|
happy: '😺',
|
||||||
|
talking: '😸',
|
||||||
|
celebrate: '😻',
|
||||||
|
sleeping: '🐾',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'petal.companion'
|
||||||
|
|
||||||
|
// PetalCompanion is the cozy corner mascot: it watches the writing session via
|
||||||
|
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
|
||||||
|
// break reminders. Clicking the mascot opens a picker to switch companions
|
||||||
|
// (the choice persists in localStorage).
|
||||||
|
export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: Props) {
|
||||||
|
const { mood, bubble, dismiss } = useCompanion({ wordCount, saveStatus, editTick, acceptTick })
|
||||||
|
|
||||||
|
const [companionId, setCompanionId] = useState<string>(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) || DEFAULT_COMPANION
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_COMPANION
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const companion = COMPANIONS.find((c) => c.id === companionId) ?? COMPANIONS[0]
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Awake companions (no sleeping clip) don't visibly nap — when the engine
|
||||||
|
// dozes them, keep their normal idle pose instead of a sleepy face. Only a
|
||||||
|
// mascot with a real sleep animation (the always-asleep cat) shows the zzz.
|
||||||
|
const hasSleepClip = Boolean(companion.animations.sleeping)
|
||||||
|
const renderMood: Mood =
|
||||||
|
mood === 'sleeping' && !companion.alwaysAsleep && !hasSleepClip ? 'idle' : mood
|
||||||
|
const napping = companion.alwaysAsleep || (mood === 'sleeping' && hasSleepClip)
|
||||||
|
|
||||||
|
function choose(id: string) {
|
||||||
|
setCompanionId(id)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, id)
|
||||||
|
} catch {
|
||||||
|
/* private mode / storage disabled — selection just won't persist */
|
||||||
|
}
|
||||||
|
setPickerOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click outside the corner closes the picker.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pickerOpen) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (!rootRef.current?.contains(e.target as Node)) setPickerOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
|
}, [pickerOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2"
|
||||||
|
>
|
||||||
|
{pickerOpen && (
|
||||||
|
<div
|
||||||
|
className="petal-bubble pointer-events-auto flex flex-col gap-0.5 p-1.5"
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
minWidth: 180,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="px-2 pb-1 pt-0.5 text-[0.7rem] font-bold"
|
||||||
|
style={{ color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
选个小伙伴 · Choose a companion
|
||||||
|
</p>
|
||||||
|
{COMPANIONS.map((c) => {
|
||||||
|
const active = c.id === companion.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => choose(c.id)}
|
||||||
|
className="flex items-center gap-2.5 rounded-xl px-2 py-1.5 text-left text-sm transition-colors"
|
||||||
|
style={{
|
||||||
|
background: active ? 'var(--color-surface-alt)' : 'transparent',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!active) e.currentTarget.style.background = 'var(--color-surface-alt)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!active) e.currentTarget.style.background = 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 20, lineHeight: 1 }}>{c.emoji}</span>
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="font-bold">{c.zh}</span>{' '}
|
||||||
|
<span style={{ color: 'var(--color-muted)' }}>{c.name}</span>
|
||||||
|
</span>
|
||||||
|
{active && <span style={{ color: 'var(--color-accent)' }}>✓</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bubble && !pickerOpen && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
onClick={dismiss}
|
||||||
|
className="petal-bubble pointer-events-auto max-w-[260px] cursor-pointer p-3 pr-3.5"
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
// CJK-first font stack (north star Note #17) — she reads Mandarin.
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
}}
|
||||||
|
title="Click to dismiss"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-bold leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{bubble.zh}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{bubble.en}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPickerOpen((o) => !o)}
|
||||||
|
title="Choose a companion"
|
||||||
|
aria-label="Choose a companion"
|
||||||
|
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
|
||||||
|
style={{
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
padding: 0,
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
background: 'var(--color-surface-alt)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LottiePlayer
|
||||||
|
key={companion.id}
|
||||||
|
animationData={companion.animations[renderMood]}
|
||||||
|
className="h-28 w-28"
|
||||||
|
fallback={<span style={{ fontSize: 64, lineHeight: 1 }}>{MOOD_EMOJI[renderMood]}</span>}
|
||||||
|
/>
|
||||||
|
{napping && (
|
||||||
|
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
|
||||||
|
z
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
web/src/components/Companion/animations/happy-dog.json
Normal file
1
web/src/components/Companion/animations/happy-dog.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
49
web/src/components/Companion/companions.ts
Normal file
49
web/src/components/Companion/companions.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Mood } from './useCompanion'
|
||||||
|
import sleepingCat from './animations/sleeping-cat.json'
|
||||||
|
import happyDog from './animations/happy-dog.json'
|
||||||
|
|
||||||
|
export interface Companion {
|
||||||
|
id: string
|
||||||
|
name: string // English label
|
||||||
|
zh: string // Chinese label (she reads Mandarin — north star Note #17)
|
||||||
|
emoji: string // shown in the picker + used as the per-mood fallback base
|
||||||
|
// mood → Lottie asset. Unmapped moods fall back to the per-mood emoji.
|
||||||
|
animations: Partial<Record<Mood, object>>
|
||||||
|
// When true the mascot keeps its calm sway + drifting zzz regardless of mood
|
||||||
|
// (e.g. a cat that's always asleep but still talks in its sleep).
|
||||||
|
alwaysAsleep?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// The roster. Add a companion by dropping a pure-vector Lottie JSON into
|
||||||
|
// ./animations and appending an entry here — that's the whole change.
|
||||||
|
export const COMPANIONS: Companion[] = [
|
||||||
|
{
|
||||||
|
id: 'cat',
|
||||||
|
name: 'Sleepy Cat',
|
||||||
|
zh: '瞌睡猫',
|
||||||
|
emoji: '😴',
|
||||||
|
alwaysAsleep: true,
|
||||||
|
animations: {
|
||||||
|
idle: sleepingCat,
|
||||||
|
happy: sleepingCat,
|
||||||
|
talking: sleepingCat,
|
||||||
|
celebrate: sleepingCat,
|
||||||
|
sleeping: sleepingCat,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dog',
|
||||||
|
name: 'Happy Dog',
|
||||||
|
zh: '开心狗',
|
||||||
|
emoji: '🐶',
|
||||||
|
animations: {
|
||||||
|
idle: happyDog,
|
||||||
|
happy: happyDog,
|
||||||
|
talking: happyDog,
|
||||||
|
celebrate: happyDog,
|
||||||
|
// no sleeping clip → stays in its idle pose instead of visibly napping
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEFAULT_COMPANION = 'cat'
|
||||||
63
web/src/components/Companion/tips.ts
Normal file
63
web/src/components/Companion/tips.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Bilingual companion copy — Mandarin first (she writes/reads in Mandarin), with
|
||||||
|
// a warm English subtitle. Kept gentle and encouraging, never scolding. The
|
||||||
|
// bubble renders zh prominently and en underneath. (Spec north star: CJK is
|
||||||
|
// first-class; Ask Petal & companion answer in Mandarin.)
|
||||||
|
|
||||||
|
export interface Line {
|
||||||
|
zh: string
|
||||||
|
en: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Played when a suggestion is accepted or a milestone hits — pure warmth.
|
||||||
|
export const ENCOURAGEMENTS: Line[] = [
|
||||||
|
{ zh: '好棒!这一句更顺了 🌸', en: 'Lovely — that reads so much smoother now.' },
|
||||||
|
{ zh: '你写得越来越好了 ✨', en: "You're getting better and better." },
|
||||||
|
{ zh: '我很喜欢这个改法 💕', en: 'I really like that change.' },
|
||||||
|
{ zh: '继续保持,加油!', en: 'Keep going — you’ve got this!' },
|
||||||
|
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, that’s much clearer.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Gentle, periodic writing/ESL tips. Surfaced only when nothing else is showing.
|
||||||
|
export const TIPS: Line[] = [
|
||||||
|
{ zh: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' },
|
||||||
|
{ zh: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." },
|
||||||
|
{ zh: '过去的事情用过去式:go → went。', en: 'For the past, use past tense: go → went.' },
|
||||||
|
{ zh: '读出声音,能帮你发现奇怪的地方。', en: 'Reading aloud helps you catch awkward spots.' },
|
||||||
|
{ zh: '一个段落讲一个想法就好。', en: 'One idea per paragraph keeps it tidy.' },
|
||||||
|
{ zh: '不确定的地方,问问我就好啦 ✨', en: 'Not sure about something? Just ask me. ✨' },
|
||||||
|
{ zh: '复数别忘了加 s:two apples 🍎', en: 'Plurals take an “s”: two apples 🍎' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Shown after a long stretch of continuous writing.
|
||||||
|
export const BREAKS: Line[] = [
|
||||||
|
{ zh: '写了好一会儿啦,起来走走,让眼睛休息一下 🍵', en: "You've been writing a while — stretch and rest your eyes. 🍵" },
|
||||||
|
{ zh: '喝口水,休息五分钟好不好?', en: 'Sip some water and take five?' },
|
||||||
|
{ zh: '看看远方,放松一下眼睛 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// First hello when the app opens.
|
||||||
|
export const GREETING: Line = {
|
||||||
|
zh: '嗨~我在这儿陪你写作哦 🐱',
|
||||||
|
en: "Hi! I'm right here keeping you company. 🐱",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Welcome-back nudge after she returns from an idle pause.
|
||||||
|
export const WELCOME_BACK: Line = {
|
||||||
|
zh: '欢迎回来 ✨ 我们继续吧!',
|
||||||
|
en: 'Welcome back ✨ let’s keep going!',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word-count milestones worth a little cheer.
|
||||||
|
export const MILESTONES = [50, 100, 250, 500, 1000, 2000]
|
||||||
|
|
||||||
|
export function milestoneLine(n: number): Line {
|
||||||
|
return {
|
||||||
|
zh: `哇!已经 ${n} 个词了,太厉害了 🎉`,
|
||||||
|
en: `Wow — ${n} words already! Amazing. 🎉`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic-enough random pick (Math.random is fine in the browser runtime).
|
||||||
|
export function pick<T>(arr: T[]): T {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)]
|
||||||
|
}
|
||||||
182
web/src/components/Companion/useCompanion.ts
Normal file
182
web/src/components/Companion/useCompanion.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||||
|
import {
|
||||||
|
BREAKS,
|
||||||
|
ENCOURAGEMENTS,
|
||||||
|
GREETING,
|
||||||
|
MILESTONES,
|
||||||
|
TIPS,
|
||||||
|
WELCOME_BACK,
|
||||||
|
milestoneLine,
|
||||||
|
pick,
|
||||||
|
type Line,
|
||||||
|
} from './tips'
|
||||||
|
|
||||||
|
// The kitten's expression. Maps to a Lottie animation when assets are present,
|
||||||
|
// otherwise to an emoji placeholder (see PetalCompanion).
|
||||||
|
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate'
|
||||||
|
|
||||||
|
export type BubbleTone = 'cheer' | 'tip' | 'break'
|
||||||
|
export interface Bubble extends Line {
|
||||||
|
tone: BubbleTone
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Signals {
|
||||||
|
wordCount: number
|
||||||
|
saveStatus: SaveStatus
|
||||||
|
// Monotonic counters bumped by the app on each editor change / accepted
|
||||||
|
// suggestion — lets the companion react without a full event bus.
|
||||||
|
editTick: number
|
||||||
|
acceptTick: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timing knobs (ms). Tuned to feel present but never naggy.
|
||||||
|
const IDLE_MS = 75_000 // no edits → kitten naps
|
||||||
|
const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break
|
||||||
|
const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window
|
||||||
|
const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
|
||||||
|
const BUBBLE_MS = 6_500 // how long a bubble lingers
|
||||||
|
const CHEER_MS = 4_500 // shorter for quick cheers
|
||||||
|
const now = () => Date.now()
|
||||||
|
|
||||||
|
// useCompanion is the behavior engine: it watches writing signals and decides
|
||||||
|
// when the kitten speaks, what mood it shows, and how to pace itself so the
|
||||||
|
// companion feels alive without interrupting. UI-agnostic — returns state only.
|
||||||
|
export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Signals) {
|
||||||
|
const [mood, setMood] = useState<Mood>('idle')
|
||||||
|
const [bubble, setBubble] = useState<Bubble | null>(null)
|
||||||
|
|
||||||
|
const lastActivity = useRef(now())
|
||||||
|
const sessionStart = useRef(now())
|
||||||
|
const lastProactive = useRef(0)
|
||||||
|
const lastTip = useRef(0)
|
||||||
|
const lastBreak = useRef(0)
|
||||||
|
const nextMilestone = useRef(0) // index into MILESTONES
|
||||||
|
const sleeping = useRef(false)
|
||||||
|
|
||||||
|
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
const moodTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
|
||||||
|
// Show a bubble + talking mood, then settle back. `proactive` messages respect
|
||||||
|
// the spacing floor; user-triggered ones (cheers) always go through.
|
||||||
|
const say = useCallback((b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean }) => {
|
||||||
|
const t = now()
|
||||||
|
if (opts?.proactive) {
|
||||||
|
if (bubble) return
|
||||||
|
if (t - lastProactive.current < PROACTIVE_GAP) return
|
||||||
|
lastProactive.current = t
|
||||||
|
}
|
||||||
|
sleeping.current = false
|
||||||
|
setBubble(b)
|
||||||
|
setMood(opts?.celebrate ? 'celebrate' : 'talking')
|
||||||
|
clearTimeout(bubbleTimer.current)
|
||||||
|
clearTimeout(moodTimer.current)
|
||||||
|
const dur = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS
|
||||||
|
bubbleTimer.current = setTimeout(() => setBubble(null), dur)
|
||||||
|
moodTimer.current = setTimeout(() => setMood('idle'), dur)
|
||||||
|
}, [bubble])
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
clearTimeout(bubbleTimer.current)
|
||||||
|
clearTimeout(moodTimer.current)
|
||||||
|
setBubble(null)
|
||||||
|
setMood('idle')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Opening hello (once), after a short beat so it doesn't race the first paint.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Edits: record activity and wake from a nap with a warm welcome-back.
|
||||||
|
const firstEdit = useRef(true)
|
||||||
|
useEffect(() => {
|
||||||
|
if (firstEdit.current) {
|
||||||
|
firstEdit.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastActivity.current = now()
|
||||||
|
if (sleeping.current) {
|
||||||
|
sleeping.current = false
|
||||||
|
sessionStart.current = now() // a fresh stretch starts on return
|
||||||
|
say({ ...WELCOME_BACK, tone: 'cheer' })
|
||||||
|
} else if (mood === 'sleeping') {
|
||||||
|
setMood('idle')
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [editTick])
|
||||||
|
|
||||||
|
// Accepts: always cheer (it's a direct response to her action).
|
||||||
|
const firstAccept = useRef(true)
|
||||||
|
useEffect(() => {
|
||||||
|
if (firstAccept.current) {
|
||||||
|
firstAccept.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { celebrate: true })
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [acceptTick])
|
||||||
|
|
||||||
|
// Word-count milestones: cheer the first time each threshold is crossed.
|
||||||
|
useEffect(() => {
|
||||||
|
while (
|
||||||
|
nextMilestone.current < MILESTONES.length &&
|
||||||
|
wordCount >= MILESTONES[nextMilestone.current]
|
||||||
|
) {
|
||||||
|
const n = MILESTONES[nextMilestone.current]
|
||||||
|
nextMilestone.current += 1
|
||||||
|
// Skip silently if we're just loading a long doc (no edits yet).
|
||||||
|
if (!firstEdit.current) say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [wordCount])
|
||||||
|
|
||||||
|
// Occasional gentle cheer on a successful save (kept rare so it isn't noise).
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveStatus === 'saved' && Math.random() < 0.18) {
|
||||||
|
say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { proactive: true })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [saveStatus])
|
||||||
|
|
||||||
|
// Heartbeat: nap when idle, suggest breaks, and drop the odd writing tip.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
const t = now()
|
||||||
|
const idleFor = t - lastActivity.current
|
||||||
|
|
||||||
|
if (idleFor > IDLE_MS) {
|
||||||
|
sleeping.current = true
|
||||||
|
if (!bubble) setMood('sleeping')
|
||||||
|
return // resting — no nudges while she's away
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active: time for a break?
|
||||||
|
if (t - sessionStart.current > BREAK_MS && t - lastBreak.current > BREAK_MS) {
|
||||||
|
lastBreak.current = t
|
||||||
|
sessionStart.current = t
|
||||||
|
say({ ...pick(BREAKS), tone: 'break' }, { proactive: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise an occasional tip.
|
||||||
|
if (t - lastTip.current > TIP_MIN_GAP) {
|
||||||
|
lastTip.current = t
|
||||||
|
say({ ...pick(TIPS), tone: 'tip' }, { proactive: true })
|
||||||
|
}
|
||||||
|
}, 10_000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [bubble, say])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
clearTimeout(bubbleTimer.current)
|
||||||
|
clearTimeout(moodTimer.current)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { mood, bubble, dismiss }
|
||||||
|
}
|
||||||
49
web/src/components/DocList/DocList.tsx
Normal file
49
web/src/components/DocList/DocList.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { DocSummary } from '../../api/client'
|
||||||
|
import { DocListItem } from './DocListItem'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
docs: DocSummary[]
|
||||||
|
selectedId: string | null
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
onCreate: () => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocList is the 260px sidebar: the document browser plus a New button.
|
||||||
|
export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Props) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="flex h-full w-[260px] flex-col gap-2 p-3"
|
||||||
|
style={{ borderRight: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCreate}
|
||||||
|
className="flex items-center justify-center gap-2 py-2.5 text-sm font-bold text-white"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">+</span> New Doc
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||||
|
{docs.length === 0 ? (
|
||||||
|
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
No documents yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
docs.map((doc) => (
|
||||||
|
<DocListItem
|
||||||
|
key={doc.id}
|
||||||
|
doc={doc}
|
||||||
|
active={doc.id === selectedId}
|
||||||
|
onSelect={() => onSelect(doc.id)}
|
||||||
|
onDelete={() => onDelete(doc.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
web/src/components/DocList/DocListItem.tsx
Normal file
48
web/src/components/DocList/DocListItem.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { DocSummary } from '../../api/client'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
doc: DocSummary
|
||||||
|
active: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row in the sidebar: title + word count, a delete affordance on hover, and
|
||||||
|
// a rose wash when it's the open document.
|
||||||
|
export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onSelect}
|
||||||
|
className="group flex cursor-pointer items-center gap-2 px-3 py-2.5"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
background: active ? 'var(--color-surface)' : 'transparent',
|
||||||
|
boxShadow: active ? 'var(--shadow-soft)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
className="truncate text-sm font-semibold"
|
||||||
|
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
{doc.title || 'Untitled'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Delete document"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDelete()
|
||||||
|
}}
|
||||||
|
className="flex h-6 w-6 shrink-0 items-center justify-center text-base opacity-0 group-hover:opacity-100"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
159
web/src/components/Editor/AskPetal.tsx
Normal file
159
web/src/components/Editor/AskPetal.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { streamSuggestionChat, type ChatMessage } from '../../api/client'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
suggestionId: string
|
||||||
|
// Pre-populates Petal's first bubble so the conversation opens with context.
|
||||||
|
explanation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CJK fallback stack — Nunito has no Chinese glyphs, and the user asks questions
|
||||||
|
// in Mandarin (spec Note #17). Applied to the bubbles specifically, not the
|
||||||
|
// serif editor body.
|
||||||
|
const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif"
|
||||||
|
|
||||||
|
// AskPetal is the mini chat panel inside an expanded SuggestionCard. The whole
|
||||||
|
// conversation lives in this component's state — nothing is persisted; closing
|
||||||
|
// the card (unmounting) clears it. Each send streams Petal's reply token-by-
|
||||||
|
// token into the latest assistant bubble.
|
||||||
|
export function AskPetal({ suggestionId, explanation }: Props) {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||||
|
{ role: 'assistant', content: explanation },
|
||||||
|
])
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [streaming, setStreaming] = useState(false)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Keep the latest bubble in view as tokens arrive.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
// Focus the input when the panel opens.
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const text = input.trim()
|
||||||
|
if (!text || streaming) return
|
||||||
|
setInput('')
|
||||||
|
|
||||||
|
// Append the user turn plus an empty assistant bubble to stream into.
|
||||||
|
const history: ChatMessage[] = [...messages, { role: 'user', content: text }]
|
||||||
|
setMessages([...history, { role: 'assistant', content: '' }])
|
||||||
|
setStreaming(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await streamSuggestionChat(suggestionId, history, (token) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = prev.slice()
|
||||||
|
const last = next[next.length - 1]
|
||||||
|
next[next.length - 1] = { ...last, content: last.content + token }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = prev.slice()
|
||||||
|
next[next.length - 1] = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Sorry, I had trouble responding just now. Please try again. 🌸',
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
console.error('Ask Petal chat failed:', err)
|
||||||
|
} finally {
|
||||||
|
setStreaming(false)
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mt-3 flex flex-col"
|
||||||
|
style={{
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
paddingTop: '0.625rem',
|
||||||
|
fontFamily: CHAT_FONT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex flex-col gap-2 overflow-y-auto pr-1"
|
||||||
|
style={{ maxHeight: 220 }}
|
||||||
|
>
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<Bubble key={i} role={m.role} content={m.content} streaming={streaming && i === messages.length - 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="mt-2 flex items-center gap-1.5"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void send()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Ask why… / 问为什么…"
|
||||||
|
className="min-w-0 flex-1 rounded-full px-3 py-1.5 text-xs focus:outline-none"
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface-alt)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
fontFamily: CHAT_FONT,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={streaming || input.trim() === ''}
|
||||||
|
className="rounded-full px-3 py-1.5 text-xs font-bold text-white disabled:opacity-50"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bubble renders one chat turn: Petal rose-tinted and left-aligned, the user
|
||||||
|
// lavender and right-aligned. A trailing caret marks the actively streaming
|
||||||
|
// reply until its first token lands.
|
||||||
|
function Bubble({
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
streaming,
|
||||||
|
}: {
|
||||||
|
role: ChatMessage['role']
|
||||||
|
content: string
|
||||||
|
streaming: boolean
|
||||||
|
}) {
|
||||||
|
const isPetal = role === 'assistant'
|
||||||
|
return (
|
||||||
|
<div className={`flex ${isPetal ? 'justify-start' : 'justify-end'}`}>
|
||||||
|
<div
|
||||||
|
className="max-w-[85%] rounded-2xl px-3 py-1.5 text-xs leading-snug"
|
||||||
|
style={{
|
||||||
|
background: isPetal ? 'var(--color-surface-alt)' : 'var(--color-lavender)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
fontFamily: CHAT_FONT,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
{streaming && content === '' && (
|
||||||
|
<span className="petal-chat-caret" aria-hidden>
|
||||||
|
▍
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
371
web/src/components/Editor/EditorCore.tsx
Normal file
371
web/src/components/Editor/EditorCore.tsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import { useEditor, EditorContent } from '@tiptap/react'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import Underline from '@tiptap/extension-underline'
|
||||||
|
import TextAlign from '@tiptap/extension-text-align'
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
|
import CharacterCount from '@tiptap/extension-character-count'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Toolbar } from '../Toolbar/Toolbar'
|
||||||
|
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 type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||||
|
|
||||||
|
export interface EditorChange {
|
||||||
|
content: string // Tiptap JSON, stringified
|
||||||
|
content_text: string // flattened plain text for the LLM
|
||||||
|
word_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Changing docId reloads the editor with that document's content.
|
||||||
|
docId: string
|
||||||
|
initialContent: string
|
||||||
|
onChange: (change: EditorChange) => void
|
||||||
|
// LLM suggestions to highlight; accept/dismiss notify the parent for the API
|
||||||
|
// call + state removal (accept's text replacement happens here in the editor).
|
||||||
|
suggestions: Suggestion[]
|
||||||
|
onAccept: (s: Suggestion) => void
|
||||||
|
onDismiss: (s: Suggestion) => void
|
||||||
|
// Triggers the whole-document voice-consistency pass; `voicing` is true while
|
||||||
|
// it runs (drives the toolbar button's loading state).
|
||||||
|
onVoiceCheck: () => void
|
||||||
|
voicing: boolean
|
||||||
|
// Fired when the editor gains focus, so the app can enter distraction-free mode.
|
||||||
|
onFocusMode?: () => void
|
||||||
|
// Browser-side spell checker (null until the dictionary loads). Adding a word
|
||||||
|
// to the personal dictionary is bubbled up so it persists app-wide.
|
||||||
|
spellChecker: SpellChecker | null
|
||||||
|
onAddWord: (word: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MisspellState {
|
||||||
|
word: string
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
suggestions: string[]
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = [
|
||||||
|
{ dx: -20, dy: -26, color: 'var(--color-accent)' },
|
||||||
|
{ dx: 18, dy: -30, color: 'var(--color-mint)' },
|
||||||
|
{ dx: -6, dy: -34, color: 'var(--color-peach)' },
|
||||||
|
{ dx: 24, dy: -18, color: 'var(--color-lavender)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Confetti({ top, left }: { top: number; left: number }) {
|
||||||
|
return (
|
||||||
|
<div className="petal-confetti pointer-events-none absolute z-30" style={{ top, left }} aria-hidden>
|
||||||
|
{CONFETTI_DOTS.map((d, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="petal-confetti-dot"
|
||||||
|
style={{ '--dx': `${d.dx}px`, '--dy': `${d.dy}px`, background: d.color } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDoc turns the stored content string into a Tiptap doc node, tolerating
|
||||||
|
// the DB's '{}' default and any malformed value by falling back to empty.
|
||||||
|
function parseDoc(raw: string): object | undefined {
|
||||||
|
if (!raw) return undefined
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed && typeof parsed === 'object' && parsed.type === 'doc') return parsed
|
||||||
|
} catch {
|
||||||
|
/* fall through to empty */
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HoverState {
|
||||||
|
suggestion: Suggestion
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
|
||||||
|
// alignment, a placeholder, character counting, and the suggestion-highlight
|
||||||
|
// decoration layer. Hovering a highlight opens its SuggestionCard.
|
||||||
|
export function EditorCore({
|
||||||
|
docId,
|
||||||
|
initialContent,
|
||||||
|
onChange,
|
||||||
|
suggestions,
|
||||||
|
onAccept,
|
||||||
|
onDismiss,
|
||||||
|
onVoiceCheck,
|
||||||
|
voicing,
|
||||||
|
onFocusMode,
|
||||||
|
spellChecker,
|
||||||
|
onAddWord,
|
||||||
|
}: Props) {
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [hover, setHover] = useState<HoverState | null>(null)
|
||||||
|
// The open spelling popover (click a red-underlined word), or null.
|
||||||
|
const [misspell, setMisspell] = useState<MisspellState | null>(null)
|
||||||
|
// Transient confetti burst played at the last accept location.
|
||||||
|
const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
|
||||||
|
const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
// Delays card close so the pointer can travel from highlight to card.
|
||||||
|
const closeTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
// While the Ask Petal panel is expanded the card is pinned: the hover-close
|
||||||
|
// timer is suppressed so chatting doesn't dismiss it. A click outside closes.
|
||||||
|
const [pinned, setPinned] = useState(false)
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Underline,
|
||||||
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||||
|
Placeholder.configure({ placeholder: 'Start writing…' }),
|
||||||
|
CharacterCount,
|
||||||
|
SuggestionHighlight,
|
||||||
|
SpellCheck,
|
||||||
|
],
|
||||||
|
content: parseDoc(initialContent),
|
||||||
|
editorProps: {
|
||||||
|
attributes: { class: 'petal-prose focus:outline-none' },
|
||||||
|
},
|
||||||
|
onFocus: () => onFocusMode?.(),
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
// Any edit shifts positions, stranding the spelling popover's anchor.
|
||||||
|
setMisspell(null)
|
||||||
|
onChange({
|
||||||
|
content: JSON.stringify(editor.getJSON()),
|
||||||
|
content_text: editor.getText(),
|
||||||
|
word_count: editor.storage.characterCount.words(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// When the selected document changes, swap in its content without emitting an
|
||||||
|
// update (false) so loading a doc doesn't trigger a spurious save.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
|
||||||
|
setHover(null)
|
||||||
|
setMisspell(null)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [docId, editor])
|
||||||
|
|
||||||
|
// Push the spell checker into its decoration plugin once the dictionary loads
|
||||||
|
// (and again whenever the personal dictionary changes its identity).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
setSpellChecker(editor.state, editor.view.dispatch, spellChecker)
|
||||||
|
}, [editor, spellChecker])
|
||||||
|
|
||||||
|
// Push the current suggestion list into the decoration plugin.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
setSuggestions(editor.state, editor.view.dispatch, suggestions)
|
||||||
|
// Close the card if its suggestion is gone.
|
||||||
|
setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null))
|
||||||
|
}, [editor, suggestions])
|
||||||
|
|
||||||
|
const openCardFor = useCallback(
|
||||||
|
(id: string, el: HTMLElement) => {
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper) return
|
||||||
|
const suggestion = suggestions.find((s) => s.id === id)
|
||||||
|
if (!suggestion) return
|
||||||
|
const elRect = el.getBoundingClientRect()
|
||||||
|
const wrapRect = wrapper.getBoundingClientRect()
|
||||||
|
const cardWidth = 300
|
||||||
|
const left = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth),
|
||||||
|
)
|
||||||
|
const top = elRect.bottom - wrapRect.top + 6
|
||||||
|
setHover((prev) => {
|
||||||
|
// Moving to a different highlight resets any Ask Petal pin.
|
||||||
|
if (prev && prev.suggestion.id !== suggestion.id) setPinned(false)
|
||||||
|
return { suggestion, top, left }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[suggestions],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleMouseOver = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
const target = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
|
||||||
|
if (!target) return
|
||||||
|
const id = target.getAttribute('data-suggestion-id')
|
||||||
|
if (!id) return
|
||||||
|
clearTimeout(closeTimer.current)
|
||||||
|
openCardFor(id, target)
|
||||||
|
},
|
||||||
|
[openCardFor],
|
||||||
|
)
|
||||||
|
|
||||||
|
const scheduleClose = useCallback(() => {
|
||||||
|
if (pinned) return // Ask Petal open — keep the card until an explicit close.
|
||||||
|
clearTimeout(closeTimer.current)
|
||||||
|
closeTimer.current = setTimeout(() => setHover(null), 160)
|
||||||
|
}, [pinned])
|
||||||
|
|
||||||
|
// Fully close the card and drop any pin (used on accept/dismiss/click-away).
|
||||||
|
const closeCard = useCallback(() => {
|
||||||
|
clearTimeout(closeTimer.current)
|
||||||
|
setPinned(false)
|
||||||
|
setHover(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Leaving a highlight schedules a close; entering the card cancels it, so the
|
||||||
|
// pointer can bridge the small gap from text to card.
|
||||||
|
const handleMouseOut = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if ((e.target as HTMLElement).closest('.petal-suggestion')) scheduleClose()
|
||||||
|
},
|
||||||
|
[scheduleClose],
|
||||||
|
)
|
||||||
|
|
||||||
|
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
|
||||||
|
|
||||||
|
// Accept applies the replacement to the document, plays a little confetti
|
||||||
|
// burst where the card sat, then notifies the parent.
|
||||||
|
const handleAccept = useCallback(
|
||||||
|
(s: Suggestion) => {
|
||||||
|
if (editor && s.replacement.trim() !== '') {
|
||||||
|
const range = findRange(editor.state.doc, s.original)
|
||||||
|
if (range) {
|
||||||
|
editor.chain().focus().insertContentAt(range, s.replacement).run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHover((h) => {
|
||||||
|
if (h) {
|
||||||
|
setConfetti({ top: h.top, left: h.left + 16 })
|
||||||
|
clearTimeout(confettiTimer.current)
|
||||||
|
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
})
|
||||||
|
closeCard()
|
||||||
|
onAccept(s)
|
||||||
|
},
|
||||||
|
[editor, onAccept, closeCard],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(
|
||||||
|
(s: Suggestion) => {
|
||||||
|
closeCard()
|
||||||
|
onDismiss(s)
|
||||||
|
},
|
||||||
|
[onDismiss, closeCard],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Click a red-underlined word to open its spelling popover, anchored under the
|
||||||
|
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same
|
||||||
|
// misspelling appearing elsewhere); nspell supplies the corrections.
|
||||||
|
const handleSpellClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!editor || !spellChecker) return
|
||||||
|
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
|
||||||
|
if (!target) return
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper) 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 elRect = target.getBoundingClientRect()
|
||||||
|
const wrapRect = wrapper.getBoundingClientRect()
|
||||||
|
const cardWidth = 240
|
||||||
|
const left = Math.max(0, Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth))
|
||||||
|
const top = elRect.bottom - wrapRect.top + 6
|
||||||
|
// Opening a spelling popover supersedes any AI-suggestion hover card.
|
||||||
|
closeCard()
|
||||||
|
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||||
|
},
|
||||||
|
[editor, spellChecker, closeCard],
|
||||||
|
)
|
||||||
|
|
||||||
|
const replaceMisspelling = useCallback(
|
||||||
|
(correction: string) => {
|
||||||
|
if (editor && misspell) {
|
||||||
|
editor.chain().focus().insertContentAt({ from: misspell.from, to: misspell.to }, correction).run()
|
||||||
|
}
|
||||||
|
setMisspell(null)
|
||||||
|
},
|
||||||
|
[editor, misspell],
|
||||||
|
)
|
||||||
|
|
||||||
|
const addMisspellingToDict = useCallback(() => {
|
||||||
|
if (misspell) onAddWord(misspell.word)
|
||||||
|
setMisspell(null)
|
||||||
|
}, [misspell, onAddWord])
|
||||||
|
|
||||||
|
// A pointer-down outside the popover (and not on another misspelling, which
|
||||||
|
// would reopen it) closes the spelling card.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!misspell) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (t.closest('.petal-misspell-card') || t.closest('.petal-misspelling')) return
|
||||||
|
setMisspell(null)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
|
}, [misspell])
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
clearTimeout(closeTimer.current)
|
||||||
|
clearTimeout(confettiTimer.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
|
||||||
|
// the only way to dismiss a pinned card without accept/dismiss.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pinned) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (!(e.target as HTMLElement).closest('.petal-suggestion-card')) closeCard()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
|
}, [pinned, closeCard])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} />
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className="relative flex-1"
|
||||||
|
onMouseOver={handleMouseOver}
|
||||||
|
onMouseOut={handleMouseOut}
|
||||||
|
onClick={handleSpellClick}
|
||||||
|
>
|
||||||
|
<EditorContent editor={editor} className="h-full" />
|
||||||
|
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
||||||
|
{misspell && (
|
||||||
|
<MisspellCard
|
||||||
|
word={misspell.word}
|
||||||
|
suggestions={misspell.suggestions}
|
||||||
|
style={{ top: misspell.top, left: misspell.left }}
|
||||||
|
onReplace={replaceMisspelling}
|
||||||
|
onAdd={addMisspellingToDict}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hover && (
|
||||||
|
<SuggestionCard
|
||||||
|
suggestion={hover.suggestion}
|
||||||
|
style={{ top: hover.top, left: hover.left }}
|
||||||
|
onAccept={handleAccept}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
onPointerEnter={keepOpen}
|
||||||
|
onPointerLeave={scheduleClose}
|
||||||
|
onExpandChange={setPinned}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
web/src/components/Editor/MisspellCard.tsx
Normal file
75
web/src/components/Editor/MisspellCard.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// MisspellCard is the little popover for a single misspelled word: the flagged
|
||||||
|
// word, up to a few nspell corrections as tappable pills, and an "add to my
|
||||||
|
// dictionary" action for names/terms Petal shouldn't nag about. Labels are
|
||||||
|
// bilingual (zh-first, en subtitle) to match the rest of Petal's chrome — the
|
||||||
|
// user writes in Mandarin and English (spec Note #17).
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
word: string
|
||||||
|
suggestions: string[]
|
||||||
|
style: React.CSSProperties
|
||||||
|
onReplace: (correction: string) => void
|
||||||
|
onAdd: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Props) {
|
||||||
|
const shown = suggestions.slice(0, 5)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={`Spelling suggestions for ${word}`}
|
||||||
|
className="petal-misspell-card absolute z-20 p-3 text-sm"
|
||||||
|
style={{
|
||||||
|
width: 240,
|
||||||
|
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-accent)', color: 'white' }}
|
||||||
|
>
|
||||||
|
拼写 · Spelling
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{word}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shown.length > 0 ? (
|
||||||
|
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||||
|
{shown.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>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2.5 leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
没有建议 · No suggestions
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAdd}
|
||||||
|
className="mt-3 rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
|
||||||
|
style={{ background: 'transparent', color: 'var(--color-accent-hover)' }}
|
||||||
|
>
|
||||||
|
添加到词典 · Add to dictionary
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
144
web/src/components/Editor/SpellCheck.ts
Normal file
144
web/src/components/Editor/SpellCheck.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
import type { EditorState, Transaction } from '@tiptap/pm/state'
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
|
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||||
|
import { mapOffset } from './SuggestionHighlight'
|
||||||
|
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||||
|
|
||||||
|
// SpellCheck renders browser-side nspell misspellings as ProseMirror
|
||||||
|
// decorations (a wavy red underline), recomputed from the live document on every
|
||||||
|
// change. Like the AI-suggestion layer it stores no marks — the underline is a
|
||||||
|
// pure overlay, so it never travels into saved content. English only: the word
|
||||||
|
// tokenizer matches Latin-letter runs, so CJK text (the user writes in both
|
||||||
|
// Mandarin and English) is simply never tokenized and never flagged.
|
||||||
|
|
||||||
|
export const spellPluginKey = new PluginKey<PluginState>('petalSpellCheck')
|
||||||
|
|
||||||
|
interface PluginState {
|
||||||
|
checker: SpellChecker | null
|
||||||
|
decorations: DecorationSet
|
||||||
|
}
|
||||||
|
|
||||||
|
// A word is a run of Latin letters with optional internal/edge apostrophes
|
||||||
|
// (don't, O'Brien). Anything else — digits, punctuation, CJK — terminates a run.
|
||||||
|
const WORD_RE = /[A-Za-z][A-Za-z']*/g
|
||||||
|
|
||||||
|
// isCheckable filters tokens we shouldn't flag: single letters and all-caps
|
||||||
|
// acronyms (NASA, USA), which dictionaries reliably miss and which read as noise
|
||||||
|
// when underlined.
|
||||||
|
function isCheckable(word: string): boolean {
|
||||||
|
if (word.length < 2) return false
|
||||||
|
if (word === word.toUpperCase()) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// strip leading/trailing apostrophes (e.g. a quoted 'word') so the dictionary
|
||||||
|
// lookup sees the bare token; returns the core plus how many chars were trimmed
|
||||||
|
// off the front (to re-anchor the decoration).
|
||||||
|
function coreOf(word: string): { core: string; lead: number } {
|
||||||
|
const lead = word.match(/^'+/)?.[0].length ?? 0
|
||||||
|
const trail = word.match(/'+$/)?.[0].length ?? 0
|
||||||
|
return { core: word.slice(lead, word.length - trail), lead }
|
||||||
|
}
|
||||||
|
|
||||||
|
function eachMisspelling(
|
||||||
|
doc: PMNode,
|
||||||
|
checker: SpellChecker,
|
||||||
|
visit: (from: number, to: number, word: string) => void,
|
||||||
|
) {
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (!node.isTextblock) return true
|
||||||
|
const text = node.textContent
|
||||||
|
WORD_RE.lastIndex = 0
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = WORD_RE.exec(text)) !== null) {
|
||||||
|
const { core, lead } = coreOf(m[0])
|
||||||
|
if (!isCheckable(core) || checker.correct(core)) continue
|
||||||
|
const from = mapOffset(node, pos, m.index + lead)
|
||||||
|
const to = mapOffset(node, pos, m.index + lead + core.length)
|
||||||
|
visit(from, to, core)
|
||||||
|
}
|
||||||
|
return false // never descend into a textblock's inline children
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDecorations(doc: PMNode, checker: SpellChecker, cursor: number): DecorationSet {
|
||||||
|
const decos: Decoration[] = []
|
||||||
|
eachMisspelling(doc, checker, (from, to, _word) => {
|
||||||
|
// Don't flag the word the caret currently sits in — it's mid-typing, and a
|
||||||
|
// red underline appearing under the cursor on every keystroke is jittery.
|
||||||
|
if (cursor >= from && cursor <= to) return
|
||||||
|
decos.push(Decoration.inline(from, to, { class: 'petal-misspelling', 'data-misspelling': '' }))
|
||||||
|
})
|
||||||
|
return DecorationSet.create(doc, decos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordAt resolves the misspelling token under a ProseMirror position (from a
|
||||||
|
// click), returning its range + text so the card can offer corrections and the
|
||||||
|
// replacement can target the exact span — robust to duplicate words anywhere
|
||||||
|
// else in the document. Returns null if the position isn't inside a Latin word.
|
||||||
|
export function wordAt(doc: PMNode, pos: number): { from: number; to: number; word: string } | null {
|
||||||
|
let found: { from: number; to: number; word: string } | null = null
|
||||||
|
doc.descendants((node, nodePos) => {
|
||||||
|
if (found) return false
|
||||||
|
if (!node.isTextblock) return true
|
||||||
|
if (pos <= nodePos || pos >= nodePos + node.nodeSize) return false
|
||||||
|
const text = node.textContent
|
||||||
|
WORD_RE.lastIndex = 0
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = WORD_RE.exec(text)) !== null) {
|
||||||
|
const { core, lead } = coreOf(m[0])
|
||||||
|
if (!core) continue
|
||||||
|
const from = mapOffset(node, nodePos, m.index + lead)
|
||||||
|
const to = mapOffset(node, nodePos, m.index + lead + core.length)
|
||||||
|
if (pos >= from && pos <= to) {
|
||||||
|
found = { from, to, word: core }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSpellChecker pushes the (possibly null) checker into the plugin, rebuilding
|
||||||
|
// decorations immediately against the current document.
|
||||||
|
export function setSpellChecker(
|
||||||
|
state: EditorState,
|
||||||
|
dispatch: (tr: Transaction) => void,
|
||||||
|
checker: SpellChecker | null,
|
||||||
|
) {
|
||||||
|
dispatch(state.tr.setMeta(spellPluginKey, checker ?? null))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpellCheck = Extension.create({
|
||||||
|
name: 'spellCheck',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin<PluginState>({
|
||||||
|
key: spellPluginKey,
|
||||||
|
state: {
|
||||||
|
init: () => ({ checker: null, decorations: DecorationSet.empty }),
|
||||||
|
apply(tr, value, _oldState, newState) {
|
||||||
|
const meta = tr.getMeta(spellPluginKey) as SpellChecker | null | undefined
|
||||||
|
const checker = meta !== undefined ? meta : value.checker
|
||||||
|
if (!checker) return { checker: null, decorations: DecorationSet.empty }
|
||||||
|
// Rebuild on a checker swap, a doc edit, or a caret move (so the word
|
||||||
|
// you just left gets re-evaluated and the new caret word is exempt).
|
||||||
|
if (meta !== undefined || tr.docChanged || tr.selectionSet) {
|
||||||
|
return { checker, decorations: buildDecorations(newState.doc, checker, newState.selection.head) }
|
||||||
|
}
|
||||||
|
return { checker, decorations: value.decorations }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
return spellPluginKey.getState(state)?.decorations
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
127
web/src/components/Editor/SuggestionCard.tsx
Normal file
127
web/src/components/Editor/SuggestionCard.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { Suggestion, SuggestionType } from '../../api/client'
|
||||||
|
import { AskPetal } from './AskPetal'
|
||||||
|
|
||||||
|
// Per-type accent color + human label, mirroring the design tokens.
|
||||||
|
const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
|
||||||
|
grammar: { color: 'var(--color-mint)', label: 'Grammar' },
|
||||||
|
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
|
||||||
|
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
|
||||||
|
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
|
||||||
|
voice: { color: 'var(--color-honey)', label: 'Voice' },
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
suggestion: Suggestion
|
||||||
|
style: React.CSSProperties
|
||||||
|
onAccept: (s: Suggestion) => void
|
||||||
|
onDismiss: (s: Suggestion) => void
|
||||||
|
onPointerEnter: () => void
|
||||||
|
onPointerLeave: () => void
|
||||||
|
// Pins the card open while the Ask Petal panel is expanded, so the chat isn't
|
||||||
|
// dismissed by the hover-close timer when the pointer drifts away.
|
||||||
|
onExpandChange: (expanded: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestionCard is the hover panel for a single suggestion: a colored type tag,
|
||||||
|
// the original → replacement diff, the friendly explanation, and accept/dismiss
|
||||||
|
// actions. Voice flags carry no replacement, so the diff row is hidden and only
|
||||||
|
// Dismiss is offered (awareness-only).
|
||||||
|
export function SuggestionCard({
|
||||||
|
suggestion,
|
||||||
|
style,
|
||||||
|
onAccept,
|
||||||
|
onDismiss,
|
||||||
|
onPointerEnter,
|
||||||
|
onPointerLeave,
|
||||||
|
onExpandChange,
|
||||||
|
}: Props) {
|
||||||
|
const meta = TYPE_META[suggestion.type]
|
||||||
|
const hasReplacement = suggestion.replacement.trim() !== ''
|
||||||
|
const [asking, setAsking] = useState(false)
|
||||||
|
|
||||||
|
function toggleAsking() {
|
||||||
|
setAsking((prev) => {
|
||||||
|
const next = !prev
|
||||||
|
onExpandChange(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={`${meta.label} suggestion`}
|
||||||
|
onMouseEnter={onPointerEnter}
|
||||||
|
onMouseLeave={onPointerLeave}
|
||||||
|
className="petal-suggestion-card absolute z-20 p-3.5 text-sm"
|
||||||
|
style={{
|
||||||
|
width: asking ? 340 : 300,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||||
|
style={{ background: meta.color, color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{hasReplacement && (
|
||||||
|
<div className="mt-2.5 flex flex-col gap-1" style={{ fontFamily: 'var(--font-body)' }}>
|
||||||
|
<span className="text-[0.95rem] line-through" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{suggestion.original}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.95rem] font-medium" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{suggestion.replacement}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-2.5 leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{suggestion.explanation}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAsking}
|
||||||
|
className="mt-2 rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
|
||||||
|
style={{
|
||||||
|
background: asking ? 'var(--color-surface-alt)' : 'transparent',
|
||||||
|
color: 'var(--color-accent-hover)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{asking ? 'Hide Petal' : 'Ask Petal ✨'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{asking && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
{hasReplacement && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAccept(suggestion)}
|
||||||
|
className="rounded-full px-3.5 py-1.5 text-xs font-bold text-white"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDismiss(suggestion)}
|
||||||
|
className="rounded-full px-3.5 py-1.5 text-xs font-semibold"
|
||||||
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
web/src/components/Editor/SuggestionHighlight.ts
Normal file
121
web/src/components/Editor/SuggestionHighlight.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
import type { EditorState, Transaction } from '@tiptap/pm/state'
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
|
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||||
|
import type { Suggestion } from '../../api/client'
|
||||||
|
|
||||||
|
// SuggestionHighlight renders LLM suggestions as ProseMirror *decorations*, not
|
||||||
|
// stored marks. Decorations are ephemeral overlays recomputed from the live
|
||||||
|
// document on every change, which is exactly the string-anchoring the spec
|
||||||
|
// requires (Note #6): each suggestion is re-located by matching its `original`
|
||||||
|
// text in the current doc, so edits made while a checkpoint is in flight never
|
||||||
|
// leave a highlight stranded on stale coordinates.
|
||||||
|
|
||||||
|
export const suggestionPluginKey = new PluginKey<PluginState>('petalSuggestions')
|
||||||
|
|
||||||
|
interface PluginState {
|
||||||
|
suggestions: Suggestion[]
|
||||||
|
decorations: DecorationSet
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapOffset converts a character offset within a textblock's flattened text into
|
||||||
|
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
|
||||||
|
// breaks) that occupy a position but contribute no text.
|
||||||
|
export function mapOffset(block: PMNode, blockPos: number, targetOffset: number): number {
|
||||||
|
let textOffset = 0
|
||||||
|
let pmPos = blockPos + 1 // inline content starts just inside the block
|
||||||
|
let result = pmPos
|
||||||
|
let done = false
|
||||||
|
block.forEach((child) => {
|
||||||
|
if (done) return
|
||||||
|
const len = child.isText ? (child.text?.length ?? 0) : 0
|
||||||
|
if (textOffset + len >= targetOffset) {
|
||||||
|
result = pmPos + (targetOffset - textOffset)
|
||||||
|
done = true
|
||||||
|
} else {
|
||||||
|
textOffset += len
|
||||||
|
pmPos += child.nodeSize
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!done) result = pmPos
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRange locates the first occurrence of `search` within a single textblock
|
||||||
|
// and returns its ProseMirror range, or null if the string isn't present (the
|
||||||
|
// user may have edited or removed it since the checkpoint ran). Exported so the
|
||||||
|
// accept flow can resolve the same span to replace.
|
||||||
|
export function findRange(doc: PMNode, search: string): { from: number; to: number } | null {
|
||||||
|
if (!search) return null
|
||||||
|
let result: { from: number; to: number } | null = null
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (result) return false
|
||||||
|
if (!node.isTextblock) return true // keep descending to the textblock
|
||||||
|
const idx = node.textContent.indexOf(search)
|
||||||
|
if (idx >= 0) {
|
||||||
|
result = {
|
||||||
|
from: mapOffset(node, pos, idx),
|
||||||
|
to: mapOffset(node, pos, idx + search.length),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false // never descend into a textblock's inline children
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet {
|
||||||
|
const decos: Decoration[] = []
|
||||||
|
for (const s of suggestions) {
|
||||||
|
const range = findRange(doc, s.original)
|
||||||
|
if (!range) continue
|
||||||
|
decos.push(
|
||||||
|
Decoration.inline(range.from, range.to, {
|
||||||
|
class: `petal-suggestion petal-suggestion-${s.type}`,
|
||||||
|
'data-suggestion-id': s.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return DecorationSet.create(doc, decos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSuggestions pushes a new suggestion list into the plugin. Decorations are
|
||||||
|
// rebuilt against the current document immediately.
|
||||||
|
export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) {
|
||||||
|
const tr = state.tr.setMeta(suggestionPluginKey, suggestions)
|
||||||
|
dispatch(tr)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SuggestionHighlight = Extension.create({
|
||||||
|
name: 'suggestionHighlight',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin<PluginState>({
|
||||||
|
key: suggestionPluginKey,
|
||||||
|
state: {
|
||||||
|
init: () => ({ suggestions: [], decorations: DecorationSet.empty }),
|
||||||
|
apply(tr, value, _oldState, newState) {
|
||||||
|
const meta = tr.getMeta(suggestionPluginKey) as Suggestion[] | undefined
|
||||||
|
if (meta) {
|
||||||
|
return { suggestions: meta, decorations: buildDecorations(newState.doc, meta) }
|
||||||
|
}
|
||||||
|
// On any document change, re-anchor by string against the new doc.
|
||||||
|
if (tr.docChanged) {
|
||||||
|
return {
|
||||||
|
suggestions: value.suggestions,
|
||||||
|
decorations: buildDecorations(newState.doc, value.suggestions),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
return suggestionPluginKey.getState(state)?.decorations
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
76
web/src/components/StatusBar/StatusBar.tsx
Normal file
76
web/src/components/StatusBar/StatusBar.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
wordCount: number
|
||||||
|
saveStatus: SaveStatus
|
||||||
|
// True while a grammar checkpoint is in flight — shows the breathing rose dot.
|
||||||
|
checking: boolean
|
||||||
|
// True while a whole-document voice pass runs — shows a breathing honey dot.
|
||||||
|
voicing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||||
|
idle: '',
|
||||||
|
pending: 'Editing…',
|
||||||
|
saving: 'Saving…',
|
||||||
|
saved: 'Saved just now',
|
||||||
|
error: "Couldn't save",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const label = SAVE_LABEL[saveStatus]
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
className="flex h-9 shrink-0 items-center gap-3 px-6 text-xs"
|
||||||
|
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
||||||
|
</span>
|
||||||
|
{checking && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5" title="Petal is reading your writing…">
|
||||||
|
<span
|
||||||
|
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
/>
|
||||||
|
Checking…
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{voicing && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5" title="Petal is reading your voice…">
|
||||||
|
<span
|
||||||
|
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||||
|
style={{ background: 'var(--color-honey)' }}
|
||||||
|
/>
|
||||||
|
Reading your voice…
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{label && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{ color: saveStatus === 'error' ? 'var(--color-accent)' : undefined }}
|
||||||
|
>
|
||||||
|
{saveStatus === 'saved' && (
|
||||||
|
<span
|
||||||
|
className="inline-block h-1.5 w-1.5 rounded-full"
|
||||||
|
style={{ background: 'var(--color-success)' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
164
web/src/components/Toolbar/Toolbar.tsx
Normal file
164
web/src/components/Toolbar/Toolbar.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import type { Editor } from '@tiptap/react'
|
||||||
|
import { useEditorState } from '@tiptap/react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
editor: Editor | null
|
||||||
|
// Runs the whole-document voice-consistency pass; `voicing` shows its progress.
|
||||||
|
onVoiceCheck: () => void
|
||||||
|
voicing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// A formatting button. `active` gets the rose pill treatment so the writer can
|
||||||
|
// see what's applied at the cursor.
|
||||||
|
function TBtn({
|
||||||
|
onClick,
|
||||||
|
active,
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClick: () => void
|
||||||
|
active?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
label: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={active}
|
||||||
|
disabled={disabled}
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep editor selection
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex h-8 min-w-8 items-center justify-center px-2 text-sm font-semibold disabled:opacity-40"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-input)',
|
||||||
|
color: active ? 'var(--color-accent-hover)' : 'var(--color-muted)',
|
||||||
|
background: active ? 'var(--color-surface-alt)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Divider = () => (
|
||||||
|
<span className="mx-1 h-5 w-px" style={{ background: 'var(--color-border)' }} />
|
||||||
|
)
|
||||||
|
|
||||||
|
// Toolbar renders inline formatting controls bound to the live Tiptap editor.
|
||||||
|
// useEditorState subscribes to just the flags it reads, so the buttons reflect
|
||||||
|
// the current selection without re-rendering the whole tree on every keystroke.
|
||||||
|
export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
||||||
|
const state = useEditorState({
|
||||||
|
editor,
|
||||||
|
selector: ({ editor }) =>
|
||||||
|
editor
|
||||||
|
? {
|
||||||
|
bold: editor.isActive('bold'),
|
||||||
|
italic: editor.isActive('italic'),
|
||||||
|
underline: editor.isActive('underline'),
|
||||||
|
h1: editor.isActive('heading', { level: 1 }),
|
||||||
|
h2: editor.isActive('heading', { level: 2 }),
|
||||||
|
bullet: editor.isActive('bulletList'),
|
||||||
|
left: editor.isActive({ textAlign: 'left' }),
|
||||||
|
center: editor.isActive({ textAlign: 'center' }),
|
||||||
|
right: editor.isActive({ textAlign: 'right' }),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!editor || !state) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mb-4 flex items-center gap-0.5 self-start px-2 py-1.5"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TBtn label="Bold" active={state.bold} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||||
|
<span className="font-extrabold">B</span>
|
||||||
|
</TBtn>
|
||||||
|
<TBtn label="Italic" active={state.italic} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||||
|
<span className="italic">I</span>
|
||||||
|
</TBtn>
|
||||||
|
<TBtn
|
||||||
|
label="Underline"
|
||||||
|
active={state.underline}
|
||||||
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||||
|
>
|
||||||
|
<span className="underline">U</span>
|
||||||
|
</TBtn>
|
||||||
|
<Divider />
|
||||||
|
<TBtn
|
||||||
|
label="Heading 1"
|
||||||
|
active={state.h1}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</TBtn>
|
||||||
|
<TBtn
|
||||||
|
label="Heading 2"
|
||||||
|
active={state.h2}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</TBtn>
|
||||||
|
<TBtn
|
||||||
|
label="Bullet list"
|
||||||
|
active={state.bullet}
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</TBtn>
|
||||||
|
<Divider />
|
||||||
|
<TBtn
|
||||||
|
label="Align left"
|
||||||
|
active={state.left}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||||
|
>
|
||||||
|
⇤
|
||||||
|
</TBtn>
|
||||||
|
<TBtn
|
||||||
|
label="Align center"
|
||||||
|
active={state.center}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||||
|
>
|
||||||
|
↔
|
||||||
|
</TBtn>
|
||||||
|
<TBtn
|
||||||
|
label="Align right"
|
||||||
|
active={state.right}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||||
|
>
|
||||||
|
⇥
|
||||||
|
</TBtn>
|
||||||
|
<Divider />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Check my voice"
|
||||||
|
disabled={voicing}
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep editor selection
|
||||||
|
onClick={onVoiceCheck}
|
||||||
|
className="ml-0.5 inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-xs font-bold transition-colors disabled:opacity-70"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
background: 'var(--color-honey)',
|
||||||
|
}}
|
||||||
|
title="Read the whole document for passages that don't sound like you"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={voicing ? 'petal-checkpoint-dot inline-block h-2 w-2 rounded-full' : 'hidden'}
|
||||||
|
style={{ background: 'var(--color-plum)' }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{voicing ? 'Reading…' : 'Check my voice 🍯'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
web/src/hooks/useAutoSave.ts
Normal file
66
web/src/hooks/useAutoSave.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, type DocUpdate } from '../api/client'
|
||||||
|
|
||||||
|
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error'
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 1500
|
||||||
|
const SAVED_FADE_MS = 3000
|
||||||
|
|
||||||
|
// useAutoSave debounces document saves. Call schedule() on every edit; it fires
|
||||||
|
// PUT /api/docs/:id 1.5s after the last change. status drives the StatusBar:
|
||||||
|
// pending → saving → saved (fades to idle after 3s).
|
||||||
|
export function useAutoSave(docId: string | null) {
|
||||||
|
const [status, setStatus] = useState<SaveStatus>('idle')
|
||||||
|
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
const fadeRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
const pendingRef = useRef<DocUpdate | null>(null)
|
||||||
|
// Latest doc id, read inside the timer so a doc switch doesn't save to the old one.
|
||||||
|
const docIdRef = useRef(docId)
|
||||||
|
docIdRef.current = docId
|
||||||
|
|
||||||
|
const flush = useCallback(async () => {
|
||||||
|
const id = docIdRef.current
|
||||||
|
const body = pendingRef.current
|
||||||
|
pendingRef.current = null
|
||||||
|
if (!id || !body) return
|
||||||
|
|
||||||
|
setStatus('saving')
|
||||||
|
try {
|
||||||
|
await api.updateDoc(id, body)
|
||||||
|
setStatus('saved')
|
||||||
|
clearTimeout(fadeRef.current)
|
||||||
|
fadeRef.current = setTimeout(() => setStatus('idle'), SAVED_FADE_MS)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('auto-save failed', err)
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const schedule = useCallback(
|
||||||
|
(update: DocUpdate) => {
|
||||||
|
pendingRef.current = { ...pendingRef.current, ...update }
|
||||||
|
setStatus('pending')
|
||||||
|
clearTimeout(fadeRef.current)
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
debounceRef.current = setTimeout(flush, DEBOUNCE_MS)
|
||||||
|
},
|
||||||
|
[flush],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Save any pending edits immediately (e.g. before switching documents).
|
||||||
|
const saveNow = useCallback(() => {
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
return flush()
|
||||||
|
}, [flush])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
clearTimeout(fadeRef.current)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { status, schedule, saveNow }
|
||||||
|
}
|
||||||
98
web/src/hooks/useCheckpoint.ts
Normal file
98
web/src/hooks/useCheckpoint.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, type Suggestion } from '../api/client'
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 4000
|
||||||
|
|
||||||
|
// useCheckpoint manages the grammar-checkpoint lifecycle for one document:
|
||||||
|
// it loads any existing pending suggestions when the doc opens, then fires a
|
||||||
|
// fresh check 4s after the user stops typing. `checking` drives the breathing
|
||||||
|
// dot in the StatusBar. The server rate-limits per document, so a check that
|
||||||
|
// fires too soon simply returns the current set unchanged.
|
||||||
|
export function useCheckpoint(docId: string | null) {
|
||||||
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
||||||
|
const [checking, setChecking] = useState(false)
|
||||||
|
// True while a whole-document voice pass is in flight (explicit user action).
|
||||||
|
const [voicing, setVoicing] = useState(false)
|
||||||
|
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
const docIdRef = useRef(docId)
|
||||||
|
docIdRef.current = docId
|
||||||
|
|
||||||
|
// Token to discard responses from a doc we've since navigated away from.
|
||||||
|
const runRef = useRef(0)
|
||||||
|
|
||||||
|
const runCheck = useCallback(async () => {
|
||||||
|
const id = docIdRef.current
|
||||||
|
if (!id) return
|
||||||
|
const run = ++runRef.current
|
||||||
|
setChecking(true)
|
||||||
|
try {
|
||||||
|
const fresh = await api.checkDoc(id)
|
||||||
|
if (run === runRef.current && id === docIdRef.current) {
|
||||||
|
setSuggestions(fresh)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('checkpoint failed', err)
|
||||||
|
} finally {
|
||||||
|
if (run === runRef.current) setChecking(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Run the voice-consistency pass now (explicit "Check my voice" action). Like
|
||||||
|
// runCheck it returns the unified pending set, so grammar highlights survive.
|
||||||
|
// Shares the run token so navigating away discards a late voice response.
|
||||||
|
const runVoice = useCallback(async () => {
|
||||||
|
const id = docIdRef.current
|
||||||
|
if (!id) return
|
||||||
|
const run = ++runRef.current
|
||||||
|
setVoicing(true)
|
||||||
|
try {
|
||||||
|
const full = await api.voiceDoc(id)
|
||||||
|
if (run === runRef.current && id === docIdRef.current) {
|
||||||
|
setSuggestions(full)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('voice pass failed', err)
|
||||||
|
} finally {
|
||||||
|
if (run === runRef.current) setVoicing(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Call on every edit; schedules a check 4s after typing settles.
|
||||||
|
const schedule = useCallback(() => {
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
debounceRef.current = setTimeout(runCheck, DEBOUNCE_MS)
|
||||||
|
}, [runCheck])
|
||||||
|
|
||||||
|
// Load existing pending suggestions whenever the document changes, and cancel
|
||||||
|
// any in-flight debounce from the previous doc.
|
||||||
|
useEffect(() => {
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
runRef.current++
|
||||||
|
setSuggestions([])
|
||||||
|
setChecking(false)
|
||||||
|
setVoicing(false)
|
||||||
|
if (!docId) return
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const existing = await api.listSuggestions(docId)
|
||||||
|
if (!cancelled && docIdRef.current === docId) setSuggestions(existing)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to load suggestions', err)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [docId])
|
||||||
|
|
||||||
|
useEffect(() => () => clearTimeout(debounceRef.current), [])
|
||||||
|
|
||||||
|
// Drop one suggestion locally (after accept/dismiss) without a refetch.
|
||||||
|
const removeSuggestion = useCallback((id: string) => {
|
||||||
|
setSuggestions((prev) => prev.filter((s) => s.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { suggestions, checking, voicing, schedule, runVoice, removeSuggestion }
|
||||||
|
}
|
||||||
88
web/src/hooks/useSpellChecker.ts
Normal file
88
web/src/hooks/useSpellChecker.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import nspell, { type NSpell } from 'nspell'
|
||||||
|
|
||||||
|
// useSpellChecker loads the vendored en-US Hunspell dictionary (served from
|
||||||
|
// /dictionaries/en, embedded in the Go binary via web/dist) and builds an
|
||||||
|
// in-browser nspell instance — zero backend round-trips, per spec. The
|
||||||
|
// dictionary is ~550KB, so it's fetched as a static asset (kept out of the JS
|
||||||
|
// bundle) once per app session, not per document. A personal word list lives in
|
||||||
|
// localStorage and is replayed into nspell on load; adding a word bumps a
|
||||||
|
// `version` so consumers re-run their decorations and the word stops flagging.
|
||||||
|
|
||||||
|
// SpellChecker is the minimal surface the editor decoration layer consumes.
|
||||||
|
export interface SpellChecker {
|
||||||
|
correct(word: string): boolean
|
||||||
|
suggest(word: string): string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERSONAL_KEY = 'petal.spell.personal'
|
||||||
|
|
||||||
|
function loadPersonal(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PERSONAL_KEY)
|
||||||
|
const parsed = raw ? JSON.parse(raw) : []
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((w): w is string => typeof w === 'string') : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePersonal(words: string[]) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PERSONAL_KEY, JSON.stringify(words))
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable — personal words just won't persist this session */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpellChecker() {
|
||||||
|
const spellRef = useRef<NSpell | null>(null)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
// Bumped whenever the personal dictionary changes, to force re-decoration.
|
||||||
|
const [version, setVersion] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
// Served from web/dist root (and embedded in the Go binary), same as /api.
|
||||||
|
const [aff, dic] = await Promise.all([
|
||||||
|
fetch('/dictionaries/en/en.aff').then((r) => r.text()),
|
||||||
|
fetch('/dictionaries/en/en.dic').then((r) => r.text()),
|
||||||
|
])
|
||||||
|
if (cancelled) return
|
||||||
|
const sp = nspell(aff, dic)
|
||||||
|
for (const w of loadPersonal()) sp.add(w)
|
||||||
|
spellRef.current = sp
|
||||||
|
setReady(true)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('spell checker failed to load', err)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Recreate the checker's identity on load and on every personal-dict change so
|
||||||
|
// the editor's effect re-pushes it and rebuilds decorations.
|
||||||
|
const checker = useMemo<SpellChecker | null>(() => {
|
||||||
|
if (!ready) return null
|
||||||
|
return {
|
||||||
|
correct: (w) => spellRef.current?.correct(w) ?? true,
|
||||||
|
suggest: (w) => spellRef.current?.suggest(w) ?? [],
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ready, version])
|
||||||
|
|
||||||
|
const addWord = useCallback((word: string) => {
|
||||||
|
const sp = spellRef.current
|
||||||
|
if (!sp) return
|
||||||
|
sp.add(word)
|
||||||
|
const next = Array.from(new Set([...loadPersonal(), word]))
|
||||||
|
savePersonal(next)
|
||||||
|
setVersion((v) => v + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { checker, ready, addWord }
|
||||||
|
}
|
||||||
@@ -53,3 +53,203 @@ body {
|
|||||||
button, a, input {
|
button, a, input {
|
||||||
transition: all 200ms ease;
|
transition: all 200ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Editor body — serif Lora for prose, warm plum ink, roomy line height. The
|
||||||
|
title and headings use the Nunito UI face for contrast. */
|
||||||
|
.petal-prose {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--color-plum);
|
||||||
|
}
|
||||||
|
.petal-prose > * + * {
|
||||||
|
margin-top: 0.9em;
|
||||||
|
}
|
||||||
|
.petal-prose h1,
|
||||||
|
.petal-prose h2,
|
||||||
|
.petal-prose h3 {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.petal-prose h1 { font-size: 1.6em; }
|
||||||
|
.petal-prose h2 { font-size: 1.3em; }
|
||||||
|
.petal-prose h3 { font-size: 1.1em; }
|
||||||
|
.petal-prose ul,
|
||||||
|
.petal-prose ol {
|
||||||
|
padding-left: 1.4em;
|
||||||
|
}
|
||||||
|
.petal-prose ul { list-style: disc; }
|
||||||
|
.petal-prose ol { list-style: decimal; }
|
||||||
|
.petal-prose a {
|
||||||
|
color: var(--color-accent-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.petal-prose blockquote {
|
||||||
|
border-left: 3px solid var(--color-border);
|
||||||
|
padding-left: 1em;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.petal-prose code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
/* Placeholder shown on the empty first paragraph (Tiptap Placeholder ext). */
|
||||||
|
.petal-prose p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--color-muted);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Suggestion decorations -------------------------------------------------
|
||||||
|
Inline highlights anchored by string at render time (not stored marks). Each
|
||||||
|
type gets a soft underline in its palette color; hovering opens its card. */
|
||||||
|
.petal-suggestion {
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 200ms ease;
|
||||||
|
/* gentle fade + slight upward float as decorations appear */
|
||||||
|
animation: petal-suggestion-in 260ms ease both;
|
||||||
|
}
|
||||||
|
.petal-suggestion:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
.petal-suggestion-grammar { border-bottom-color: var(--color-mint); }
|
||||||
|
.petal-suggestion-phrasing { border-bottom-color: var(--color-peach); }
|
||||||
|
.petal-suggestion-idiom { border-bottom-color: var(--color-lavender); }
|
||||||
|
.petal-suggestion-clarity { border-bottom-color: var(--color-sky); }
|
||||||
|
.petal-suggestion-voice { border-bottom-color: var(--color-honey); }
|
||||||
|
|
||||||
|
@keyframes petal-suggestion-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.petal-suggestion-card {
|
||||||
|
animation: petal-suggestion-in 200ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Spell check ------------------------------------------------------------
|
||||||
|
Browser-side nspell flags misspellings with a soft rose wavy underline (a
|
||||||
|
gentler take on the classic red squiggle, to fit the pastel palette). Like the
|
||||||
|
suggestion layer these are ProseMirror decorations, never stored marks.
|
||||||
|
Clicking a flagged word opens its MisspellCard with corrections. */
|
||||||
|
.petal-misspelling {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline wavy var(--color-accent);
|
||||||
|
text-decoration-skip-ink: none;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.petal-misspelling:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.petal-misspell-card {
|
||||||
|
animation: petal-suggestion-in 200ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Accept confetti --------------------------------------------------------
|
||||||
|
A tiny CSS-only burst played where a suggestion is accepted: four colored
|
||||||
|
dots spray up-and-out, then fade. No JS animation — each dot reads its
|
||||||
|
direction from --dx/--dy custom properties set inline. (Spec → Signature.) */
|
||||||
|
.petal-confetti {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.petal-confetti-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
animation: petal-confetti 680ms cubic-bezier(0.2, 0.7, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
@keyframes petal-confetti {
|
||||||
|
0% { transform: translate(0, 0) scale(0.3); opacity: 0; }
|
||||||
|
18% { opacity: 1; }
|
||||||
|
100% { transform: translate(var(--dx), var(--dy)) scale(1); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Distraction-free mode ---------------------------------------------------
|
||||||
|
The doc-list sidebar slides left and collapses to zero width; the editor
|
||||||
|
canvas (centered, max-width) re-centers into the full pane. Width + transform
|
||||||
|
animate together for a smooth slide. (Spec → Distraction-free mode.) */
|
||||||
|
.petal-sidebar {
|
||||||
|
width: 260px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 280ms ease, transform 280ms ease, opacity 200ms ease;
|
||||||
|
}
|
||||||
|
.petal-sidebar-hidden {
|
||||||
|
width: 0;
|
||||||
|
transform: translateX(-24px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Companion kitten -------------------------------------------------------
|
||||||
|
The cozy corner mascot. Gently bobs while awake, settles and sways slowly
|
||||||
|
while napping; its speech bubble pops in; little zzz drift up when asleep. */
|
||||||
|
.petal-companion {
|
||||||
|
animation: petal-bob 3.2s ease-in-out infinite;
|
||||||
|
transition: transform 200ms ease;
|
||||||
|
}
|
||||||
|
.petal-companion:hover {
|
||||||
|
transform: translateY(-2px) scale(1.04);
|
||||||
|
}
|
||||||
|
.petal-companion-sleep {
|
||||||
|
animation: petal-bob-slow 5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes petal-bob {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-4px); }
|
||||||
|
}
|
||||||
|
@keyframes petal-bob-slow {
|
||||||
|
0%, 100% { transform: translateY(0) rotate(-1deg); }
|
||||||
|
50% { transform: translateY(-2px) rotate(1deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.petal-bubble {
|
||||||
|
animation: petal-bubble-in 240ms cubic-bezier(0.2, 0.8, 0.3, 1.2) both;
|
||||||
|
transform-origin: bottom right;
|
||||||
|
}
|
||||||
|
@keyframes petal-bubble-in {
|
||||||
|
from { opacity: 0; transform: translateY(6px) scale(0.92); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.petal-zzz {
|
||||||
|
top: 6px;
|
||||||
|
right: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
animation: petal-zzz 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes petal-zzz {
|
||||||
|
0% { opacity: 0; transform: translateY(0) scale(0.8); }
|
||||||
|
30% { opacity: 0.9; }
|
||||||
|
100% { opacity: 0; transform: translateY(-14px) scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blinking caret in the Ask Petal bubble while awaiting the first token. */
|
||||||
|
.petal-chat-caret {
|
||||||
|
animation: petal-breathe 1s ease-in-out infinite;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breathing rose dot shown in the StatusBar while a checkpoint runs. */
|
||||||
|
.petal-checkpoint-dot {
|
||||||
|
animation: petal-breathe 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes petal-breathe {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|||||||
19
web/src/types/nspell.d.ts
vendored
Normal file
19
web/src/types/nspell.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Minimal ambient types for nspell (the package ships no declarations). We use
|
||||||
|
// only the handful of methods the spell checker needs: correctness lookup,
|
||||||
|
// correction suggestions, and adding words to the in-memory personal dictionary.
|
||||||
|
declare module 'nspell' {
|
||||||
|
export interface NSpell {
|
||||||
|
correct(word: string): boolean
|
||||||
|
suggest(word: string): string[]
|
||||||
|
add(word: string, model?: string): NSpell
|
||||||
|
remove(word: string): NSpell
|
||||||
|
}
|
||||||
|
export interface Dictionary {
|
||||||
|
aff: string | Buffer
|
||||||
|
dic: string | Buffer
|
||||||
|
}
|
||||||
|
export default function nspell(
|
||||||
|
aff: string | Buffer | Dictionary,
|
||||||
|
dic?: string | Buffer,
|
||||||
|
): NSpell
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user