Compare commits
23 Commits
e72d48d64e
...
feat/petal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2487e73551 | ||
|
|
0a1ea225dd | ||
|
|
69ecb79da1 | ||
|
|
f3c4fe2c22 | ||
|
|
9e141e4169 | ||
|
|
60eba25fee | ||
|
|
8e1111d768 | ||
|
|
cf7720ea77 | ||
|
|
4c288834c0 | ||
| 0d59b645bb | |||
|
|
95123e8c49 | ||
|
|
a634994d25 | ||
|
|
660f00d452 | ||
|
|
183219b5de | ||
|
|
97f99b27e4 | ||
|
|
8a32dde587 | ||
|
|
534f7262ab | ||
|
|
0fa70979a0 | ||
|
|
3c5f3ecb96 | ||
|
|
3f7e705028 | ||
|
|
a4069d5755 | ||
|
|
5e00cdce88 | ||
|
|
9c98e97030 |
118
BUILD_PLAN.md
118
BUILD_PLAN.md
@@ -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,52 +22,99 @@ 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).
|
||||||
|
|
||||||
|
### Phase 8 — Trust foundation (version history + export) ✅
|
||||||
|
- [x] **Version history** — `document_versions` table (migration `0003`), full-body snapshots that cascade with the doc. Kinds: `auto` (throttled background, ≥3min apart, max 40/doc, pruned), `manual` (explicit restore point), `pre_restore` (safety copy taken before a restore, so restore is undoable). Snapshot taken post-save in `update` only when a real body came through and content changed (empties + bare renames never snapshot). Endpoints: `GET/POST /api/docs/:id/versions`, `GET /api/docs/:id/versions/:vid`, `POST /api/docs/:id/versions/:vid/restore`. All scoped to the owner via a join on `documents`. `versions_test.go` covers lifecycle/throttle/restore/pre_restore/404.
|
||||||
|
- [x] **Export** — pure-Go Tiptap-JSON → Markdown / HTML / plain-text / **docx** (`export.go`), no cgo/pandoc, CJK-safe. docx is a hand-built OOXML zip (marks→run props, headings→built-in styles, lists→prefix). RFC 5987 `filename*=UTF-8''` so Chinese titles download cleanly. `GET /api/docs/:id/export?format=`. **PDF is client-side** via the browser print dialog + a `@media print` stylesheet (uses the reader's fonts → CJK for free, no embedded-font bloat). `export_test.go` asserts every format incl. valid-zip docx with CJK.
|
||||||
|
- [x] **Frontend** — `ExportMenu` (download links + Print/PDF) and `HistoryPanel` (slide-over drawer: snapshot list w/ relative-time + kind badge, preview, restore; restore remounts the editor via an `editorEpoch` bump). Both bilingual zh-first, matching chrome. Wired into the title row; `.petal-no-print` strips all chrome for print.
|
||||||
|
- [x] **Stop saving empty docs** — blank `Untitled` drafts now self-discard: `handleCreate` reuses an existing blank instead of stacking another; `openDoc` deletes the blank doc being left. Cleaned the 2 existing orphan empties from the live DB. (Backend also refuses to snapshot empties.)
|
||||||
|
- Verified: go build/vet/test + tsc + vite all clean; live smoke on a throwaway binary — auto-snapshot on save, throttle holds at 1, manual snapshot, restore brings back exact text + leaves a `pre_restore`, empty doc → no snapshot, md/docx export with CJK+bold+heading+list, docx validates as "Microsoft Word 2007+".
|
||||||
|
|
||||||
|
### Phase 9 — ESL superpowers ✅
|
||||||
|
- [x] **Inline Chinese gloss (offline)** — embedded English→Chinese dictionary (`internal/lexicon/data/gloss.json.gz`, ~1.3MB, 57k common words built from ECDICT via `scripts/build_gloss.py`: frequency-gated to rank ≤50k, `[网络]`/slang/archaic sense-lines dropped, trimmed to ≤3 senses / 80 chars). `Lexicon` gains a `gloss` map + `Gloss(word)` (same `candidates()` de-inflection as defs/syns); `Result` gains a `Gloss` field. Two surfaces: the right-click **WordCard** now leads with the 中文 gloss, and a new lightweight `GET /api/gloss/{word}` (→ `{word, gloss}`, cached) backs the **hover tooltip**. Offline + instant, works with the LLM down (north-star reliability). Frontend: `GlossTip` (dark pointer-events-none bubble under the resting word; 350ms hover delay; reuses `wordAt` so CJK is never glossed — it's the source language), wired into `EditorCore`'s `onMouseMove`/`onMouseLeave` with a request-token guard, suppressed during selection/preview/other popovers.
|
||||||
|
- [x] **"Say it more naturally" / tone-rewrite** — selecting text pops a `SelectionBubble` (✨更自然 + the tone vocabulary 学术/专业/轻松/幽默/创意/说服, mirrored from `ToneSelect`/`styleGuidance`). Picking a style calls `POST /api/docs/:id/rewrite` (`{text, style}` → `{rewrite}`), shown in a `RewritePreview` (original struck-through → rewrite, 用这个/取消, breathing-dot loading, gentle retry on failure). Accept applies it in-editor via `insertContentAt` over the captured PM range. Backend: `llm.RunRewrite` (one-shot Complete, `RewriteMaxRunes` 2000 cap, `cleanRewrite` strips stray wrapping quotes) + `rewriteSystemTemplate`/`styleGuidance` in `prompts.go`; handler in `internal/suggestions/rewrite.go` (owner-scoped 404, 400 on empty/too-long, 502 on LLM-down). **Stateless** — not persisted as a suggestion; the version history captures the resulting doc change.
|
||||||
|
- Tests: `lexicon` gloss + Lookup-includes-gloss + inflection/miss; `suggestions` rewrite happy-path (style steering + de-quote asserted), empty→400, unknown-doc→404. go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM (fresh port 8055, throwaway DB; pre-existing dev servers on :8077/:8099 untouched): gloss for `river`/inflected/CJK-empty/nonsense-empty, `word/happy` carries the gloss, rewrite returns text, empty→400, unknown→404, LLM-down→502; new CSS classes present in the built bundle.
|
||||||
|
- **Known limitation**: `Gloss` tries the literal form first (matching defs/syns ordering), so an inflected word that is *itself* a separate ECDICT headword resolves to that entry rather than de-inflecting (e.g. `rivers` → the proper-noun "Rivers" sense, not `river`). The base form always glosses correctly; acceptable.
|
||||||
|
|
||||||
|
### Phase 10 — Organization & polish ✅
|
||||||
|
- [x] **Cross-document search (FTS5)** — migration `0004` adds a `documents_fts` virtual table over `title` + `content_text` using the **`trigram` tokenizer** (so search works for both English and space-free Chinese; the default unicode61 tokenizer treats a CJK run as one token). Kept in sync by `AFTER INSERT/UPDATE/DELETE` triggers on `documents`, back-filled from existing rows in the migration (verified: pre-existing docs are searchable immediately). `GET /api/search?q=` (`internal/docs/search.go`): queries of ≥3 runes use the FTS index (fast, `ORDER BY rank`); shorter queries fall back to a `LIKE` scan so **2-character Chinese words** (e.g. 公园) still resolve. Snippets are built in **Go** from the original text (clean word boundaries, rune-aware so CJK never splits mid-char), with the match wrapped in `\x01…\x02` sentinels; the client splits on these to highlight without `innerHTML`. Owner-scoped, capped at 50 hits. Frontend `SearchBox` in the sidebar: 220ms-debounced, results with highlighted two-line snippets, click to open.
|
||||||
|
- [x] **Tags (organize)** — migration `0004` adds `tags` (user-scoped, `UNIQUE(user_id, name)`, `color` = palette key) + `document_tags` join (both sides cascade). `internal/docs/tags.go`: `GET/POST/PATCH/DELETE /api/tags` (create is **idempotent** on name; unknown colors coerced to rose) + `POST /api/docs/:id/tags` / `DELETE /api/docs/:id/tags/:tagId` (owner-validated, idempotent assign). The doc-list and search responses carry each doc's tags (loaded in one `tagsByDoc` query, no N+1). Frontend: `useTags` (roster + counts), `TagChip`, `TagPicker` (assign existing / create-and-attach with a color swatch), tag chips on each doc row, a **filter bar** (client-side filter by tag, shows in-use tags with counts). Colors map to the existing design tokens via `tagColorVar`.
|
||||||
|
- [x] **Tablet / touch polish** — responsive sidebar: below 768px it becomes an overlay **drawer** toggled by a header hamburger, with a scrim (auto-closes on doc select). `@media (pointer: coarse)` enlarges tap targets (`.petal-tap` ≥44px, `.petal-tap-sm` ≥36px) and reveals the hover-only row actions (tag/delete). **Tap-to-open** for AI-suggestion cards (no hover on touch): a tap on a `.petal-suggestion` opens its card via the editor click handler, and a `pointerdown` outside the card/highlight dismisses it (mouse users keep the hover bridge).
|
||||||
|
- [x] **Warm LLM-down failure states** — `useCheckpoint` now tracks an `llmDown` flag (set when a check/voice pass hits the server's 502/network path, cleared on the next success or doc switch). The `StatusBar` shows a gentle bilingual note — 🌙 **小助手在休息 · Petal's helper is resting · 文字已保存** — reassuring that the writing still saved locally (saving is independent of the LLM). Rewrite already had a gentle retry from Phase 9.
|
||||||
|
- Tests: `tags_test.go` (full lifecycle — create/idempotent/color-coerce/rename-recolor/assign/unassign/doc-list inclusion/roster counts/delete-cascade/404s), `search_test.go` (EN FTS, CJK FTS, 2-char CJK LIKE fallback, title-only, case-insensitive, empty/no-match, **edit re-indexes via the update trigger**). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs the binary on a throwaway DB (port 8061, LLM pointed at a dead host): search EN/CJK/2-char all highlighted, tag create+assign+roster-counts+doc-list-tags+delete-cascade, check→502 (warm path), new CSS classes (`petal-tag-chip`/`petal-scrim`/`petal-drawer-open`/`pointer:coarse`) and the 小助手在休息 string present in the served bundle. FTS backfill of pre-existing docs verified separately.
|
||||||
|
- **Known limitations**: trigram FTS snippets/ranking treat the query as a contiguous phrase (multi-term relevance is substring, not BM25-per-term) — fine for a personal corpus. Search is title+body only (not tag names). Hover gloss and right-click word lookup remain pointer-oriented (long-press contextmenu on touch is browser-dependent); the spelling/suggestion cards and rewrite bubble are fully touch-reachable.
|
||||||
|
|
||||||
### Deferred (post-v1-local)
|
### Deferred (post-v1-local)
|
||||||
- [ ] Authentik OIDC auth + session middleware
|
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
|
||||||
- [ ] Copyleaks Tier-2 + webhook HMAC
|
- [ ] Copyleaks Tier-2 + webhook HMAC
|
||||||
- [ ] Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev
|
- [ ] Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev
|
||||||
|
|
||||||
|
### Next-up (post-v1 product, agreed with user 2026-06-26)
|
||||||
|
- [x] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. ✅ (see Phase 9 above)
|
||||||
|
- [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE)
|
||||||
|
|
||||||
## Session log
|
## Session log
|
||||||
|
- 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (0–7) + post-v1 product (8–10) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user.
|
||||||
|
- 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.py` builds `internal/lexicon/data/gloss.json.gz` from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: `lexicon` gloss map + `Gloss()`/`Result.Gloss` + `GET /api/gloss/{word}`; `llm.RunRewrite` + rewrite prompt/`styleGuidance`; `internal/suggestions/rewrite.go` (`POST /api/docs/:id/rewrite`, stateless, owner-scoped). Frontend: `GlossTip` hover tooltip (350ms delay, reuses `wordAt`, CJK-safe) + gloss line in `WordCard`; `SelectionBubble` + `RewritePreview` wired through `EditorCore` (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); `api.glossWord`/`api.rewriteSelection`; CSS for the three new surfaces (+ print-hidden). Tests added in `lexicon` and `suggestions`. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: **Phase 10 (organization & polish).**
|
||||||
|
- 2026-06-26: **Phase 8 complete** (Trust foundation: version history + export) + empty-doc fix. Backend: migration `0003_document_versions`; `internal/docs/versions.go` (throttled auto-snapshot wired into `update`, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and `internal/docs/export.go` (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). `DocumentVersion` model + kind constants. Tests: `versions_test.go`, `export_test.go` (incl. valid-zip docx assertion). Frontend: `api.client` version/export methods; `ExportMenu` + `HistoryPanel` components wired into the title row; `@media print` stylesheet + `.petal-no-print` for the browser PDF path; `editorEpoch` remount on restore. Empty-doc fix in `App.tsx` (blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; **Phase 9 (ESL gloss + tone-rewrite)** next, then **Phase 10 (search/folders/polish)**; **auth/deploy (was Phase 11) shelved** until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: **Phase 9.**
|
||||||
- 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).**
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
@@ -11,23 +13,73 @@ 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/lexicon"
|
||||||
|
"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)
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
// Build version: a hash of the embedded SPA shell. Vite rewrites index.html
|
||||||
|
// with content-hashed asset names on every build, so this string changes
|
||||||
|
// exactly when a new frontend is deployed — the client polls it to know when
|
||||||
|
// to offer a refresh.
|
||||||
|
version := buildVersion()
|
||||||
|
log.Printf("frontend build version %s", version)
|
||||||
|
|
||||||
r.Route("/api", func(api chi.Router) {
|
r.Route("/api", func(api chi.Router) {
|
||||||
api.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
|
api.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
api.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// Never cache: a stale cached version would defeat the whole check.
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write([]byte(`{"version":"` + version + `"}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
llmClient := llm.NewLLMClient(cfg)
|
||||||
|
sug := suggestions.New(database, llmClient)
|
||||||
|
|
||||||
|
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
|
||||||
|
// both under /api/docs.
|
||||||
|
docsHandler := docs.New(database)
|
||||||
|
docsRouter := docsHandler.Routes()
|
||||||
|
sug.RegisterDocRoutes(docsRouter)
|
||||||
|
api.Mount("/docs", docsRouter)
|
||||||
|
|
||||||
|
// Tag management (the roster) and cross-document full-text search.
|
||||||
|
api.Mount("/tags", docsHandler.TagRoutes())
|
||||||
|
api.Mount("/search", docsHandler.SearchRoutes())
|
||||||
|
|
||||||
|
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
|
||||||
|
api.Mount("/suggestions", sug.Routes())
|
||||||
|
|
||||||
|
// Offline lexicon: full word lookups (gloss + definition + synonyms) for
|
||||||
|
// the right-click popover, and the lightweight Chinese-only gloss for the
|
||||||
|
// inline hover/select tooltip. One handler so the datasets load once.
|
||||||
|
lex := lexicon.NewHandler()
|
||||||
|
api.Mount("/word", lex.Routes())
|
||||||
|
api.Mount("/gloss", lex.GlossRoutes())
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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).
|
||||||
@@ -40,6 +92,19 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildVersion derives a short, stable identifier for the currently embedded
|
||||||
|
// frontend by hashing dist/index.html. Vite stamps content-hashed asset names
|
||||||
|
// into that file each build, so the digest is a reliable "did the deploy
|
||||||
|
// change?" signal. Falls back to "dev" when the frontend hasn't been built.
|
||||||
|
func buildVersion() string {
|
||||||
|
data, err := fs.ReadFile(web.DistFS, "dist/index.html")
|
||||||
|
if err != nil {
|
||||||
|
return "dev"
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])[:12]
|
||||||
|
}
|
||||||
|
|
||||||
// spaHandler serves the embedded web/dist as a single-page app: static files
|
// spaHandler serves the embedded web/dist as a single-page app: static files
|
||||||
// when they exist, falling back to index.html for unknown paths. If the
|
// when they exist, falling back to index.html for unknown paths. If the
|
||||||
// frontend hasn't been built yet, it returns a friendly dev hint instead.
|
// frontend hasn't been built yet, it returns a friendly dev hint instead.
|
||||||
|
|||||||
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=
|
||||||
|
|||||||
277
internal/db/db.go
Normal file
277
internal/db/db.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
// 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);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Per-document tone: guides the grammar-checkpoint LLM so advice fits
|
||||||
|
// the writer's target register (academic essay vs casual journal).
|
||||||
|
// 'general' means no specific tone steering.
|
||||||
|
name: "0002_document_tone",
|
||||||
|
stmt: `ALTER TABLE documents ADD COLUMN tone TEXT NOT NULL DEFAULT 'general';`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Version history: point-in-time snapshots of a document's body so a
|
||||||
|
// bad edit or LLM mishap is always recoverable. `kind` distinguishes
|
||||||
|
// throttled background snapshots ('auto'), explicit user restore
|
||||||
|
// points ('manual'), and the safety copy taken right before a restore
|
||||||
|
// ('pre_restore') so restoring is itself undoable. Snapshots cascade
|
||||||
|
// with the document. Stored fully (content + content_text) so a
|
||||||
|
// restore is a plain copy with no re-derivation.
|
||||||
|
name: "0003_document_versions",
|
||||||
|
stmt: `
|
||||||
|
CREATE TABLE document_versions (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||||
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
content_text TEXT NOT NULL,
|
||||||
|
word_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'auto'
|
||||||
|
CHECK(kind IN ('auto','manual','pre_restore')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_versions_doc_id ON document_versions(doc_id, created_at DESC);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Organization & search (Phase 10). Two parts:
|
||||||
|
//
|
||||||
|
// 1. Tags. A small, user-scoped label set; `color` holds a palette key
|
||||||
|
// (rose/mint/peach/lavender/sky/honey) the frontend maps to CSS.
|
||||||
|
// document_tags is the many-to-many join; both sides cascade so
|
||||||
|
// deleting a doc or a tag cleans up its assignments.
|
||||||
|
//
|
||||||
|
// 2. Full-text search. A standalone FTS5 virtual table over title +
|
||||||
|
// content_text using the `trigram` tokenizer so search works for
|
||||||
|
// both English and space-free Chinese (the default tokenizer treats a
|
||||||
|
// CJK run as one token). It carries an UNINDEXED doc_id to map hits
|
||||||
|
// back to documents, kept in sync by AFTER INSERT/UPDATE/DELETE
|
||||||
|
// triggers, and is back-filled from the existing documents here.
|
||||||
|
// (Trigram needs ≥3 chars to MATCH; the search handler falls back to
|
||||||
|
// LIKE for shorter queries — common for 2-character Chinese words.)
|
||||||
|
name: "0004_tags_and_search",
|
||||||
|
stmt: `
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT 'rose',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE document_tags (
|
||||||
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (doc_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE documents_fts USING fts5(
|
||||||
|
doc_id UNINDEXED,
|
||||||
|
title,
|
||||||
|
content_text,
|
||||||
|
tokenize='trigram'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN
|
||||||
|
INSERT INTO documents_fts (doc_id, title, content_text)
|
||||||
|
VALUES (new.id, new.title, new.content_text);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
|
||||||
|
DELETE FROM documents_fts WHERE doc_id = old.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER documents_au AFTER UPDATE ON documents BEGIN
|
||||||
|
UPDATE documents_fts
|
||||||
|
SET title = new.title, content_text = new.content_text
|
||||||
|
WHERE doc_id = old.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
INSERT INTO documents_fts (doc_id, title, content_text)
|
||||||
|
SELECT id, title, content_text FROM documents;
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
internal/db/models.go
Normal file
107
internal/db/models.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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
|
||||||
|
Tone string `json:"tone"` // target writing tone; steers LLM advice
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentVersion is a point-in-time snapshot of a document's body, captured so
|
||||||
|
// a writer can recover from a bad edit or an unwanted change. `Content` mirrors
|
||||||
|
// the document's Tiptap JSON at snapshot time; `Kind` records why it was taken
|
||||||
|
// (see the kind constants). List responses omit the heavy Content/ContentText
|
||||||
|
// fields (the `omitempty`-friendly zero strings) and load them only on preview
|
||||||
|
// or restore.
|
||||||
|
type DocumentVersion struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DocID string `json:"doc_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content,omitempty"` // Tiptap JSON; omitted in list view
|
||||||
|
ContentText string `json:"content_text,omitempty"` // plain text; omitted in list view
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
Kind string `json:"kind"` // auto | manual | pre_restore
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document version kinds, mirrored from the schema CHECK constraint.
|
||||||
|
const (
|
||||||
|
VersionKindAuto = "auto" // throttled background snapshot on save
|
||||||
|
VersionKindManual = "manual" // explicit "save a restore point"
|
||||||
|
VersionKindPreRestore = "pre_restore" // safety copy taken just before a restore
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tag is a user-scoped label for organizing documents. `Color` is a palette key
|
||||||
|
// (rose, mint, peach, lavender, sky, honey) the frontend maps to a CSS color;
|
||||||
|
// storing the key (not a hex value) keeps tags in step with the design tokens.
|
||||||
|
// `DocCount` is populated only by the tag-list endpoint (how many documents wear
|
||||||
|
// the tag); it's omitted from per-document tag lists.
|
||||||
|
type Tag struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
DocCount int `json:"doc_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag color palette keys, mirrored on the frontend. Kept small and aligned with
|
||||||
|
// the existing design tokens; unknown values fall back to rose client-side.
|
||||||
|
const (
|
||||||
|
TagColorRose = "rose"
|
||||||
|
TagColorMint = "mint"
|
||||||
|
TagColorPeach = "peach"
|
||||||
|
TagColorLavender = "lavender"
|
||||||
|
TagColorSky = "sky"
|
||||||
|
TagColorHoney = "honey"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
629
internal/docs/export.go
Normal file
629
internal/docs/export.go
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// exportRoutes registers the download endpoint: GET /api/docs/{id}/export?format=md|html|txt|docx
|
||||||
|
func (h *Handler) exportRoutes(r chi.Router) {
|
||||||
|
r.Get("/{id}/export", h.export)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportFormat describes one downloadable format: how to render it and how to
|
||||||
|
// label the resulting file.
|
||||||
|
type exportFormat struct {
|
||||||
|
ext string
|
||||||
|
contentType string
|
||||||
|
render func(doc db.Document) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportFormats is the supported set. PDF is intentionally absent: a faithful,
|
||||||
|
// CJK-safe PDF needs an embedded Unicode font (~10MB into the single binary) or
|
||||||
|
// a headless browser (breaks the no-cgo, single-binary story). The frontend
|
||||||
|
// offers "Print / Save as PDF" via the browser instead, which uses the reader's
|
||||||
|
// own fonts and renders CJK correctly for free.
|
||||||
|
var exportFormats = map[string]exportFormat{
|
||||||
|
"md": {ext: "md", contentType: "text/markdown; charset=utf-8", render: renderMarkdown},
|
||||||
|
"html": {ext: "html", contentType: "text/html; charset=utf-8", render: renderHTMLFile},
|
||||||
|
"txt": {ext: "txt", contentType: "text/plain; charset=utf-8", render: renderPlainText},
|
||||||
|
"docx": {
|
||||||
|
ext: "docx",
|
||||||
|
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
render: renderDocx,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// export streams the document in the requested format as a download.
|
||||||
|
func (h *Handler) export(w http.ResponseWriter, r *http.Request) {
|
||||||
|
formatKey := r.URL.Query().Get("format")
|
||||||
|
if formatKey == "" {
|
||||||
|
formatKey = "md"
|
||||||
|
}
|
||||||
|
format, ok := exportFormats[formatKey]
|
||||||
|
if !ok {
|
||||||
|
badRequest(w, "unsupported export format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := h.fetch(chi.URLParam(r, "id"))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := format.render(doc)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := sanitizeFilename(doc.Title) + "." + format.ext
|
||||||
|
w.Header().Set("Content-Type", format.contentType)
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tiptap document model --------------------------------------------------
|
||||||
|
|
||||||
|
// pmNode is one ProseMirror/Tiptap node. The tree is what the editor stores in
|
||||||
|
// Document.Content; we walk it to render every export format from one source.
|
||||||
|
type pmNode struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Attrs map[string]any `json:"attrs"`
|
||||||
|
Marks []pmMark `json:"marks"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Content []pmNode `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pmMark struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDoc decodes Document.Content into a node tree. On empty or malformed JSON
|
||||||
|
// it falls back to wrapping the plain-text mirror in paragraphs, so export never
|
||||||
|
// fails just because the editor state is unusual.
|
||||||
|
func parseDoc(doc db.Document) pmNode {
|
||||||
|
var root pmNode
|
||||||
|
if err := json.Unmarshal([]byte(doc.Content), &root); err != nil || root.Type == "" {
|
||||||
|
return fallbackDoc(doc.ContentText)
|
||||||
|
}
|
||||||
|
if len(root.Content) == 0 && strings.TrimSpace(doc.ContentText) != "" {
|
||||||
|
return fallbackDoc(doc.ContentText)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallbackDoc builds a minimal doc node from plain text, one paragraph per line.
|
||||||
|
func fallbackDoc(text string) pmNode {
|
||||||
|
root := pmNode{Type: "doc"}
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
p := pmNode{Type: "paragraph"}
|
||||||
|
if line != "" {
|
||||||
|
p.Content = []pmNode{{Type: "text", Text: line}}
|
||||||
|
}
|
||||||
|
root.Content = append(root.Content, p)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n pmNode) hasMark(t string) bool {
|
||||||
|
for _, m := range n.Marks {
|
||||||
|
if m.Type == t {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n pmNode) level() int {
|
||||||
|
if n.Attrs == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if l, ok := n.Attrs["level"].(float64); ok && l >= 1 {
|
||||||
|
return int(l)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Markdown ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func renderMarkdown(doc db.Document) ([]byte, error) {
|
||||||
|
root := parseDoc(doc)
|
||||||
|
var blocks []string
|
||||||
|
for _, child := range root.Content {
|
||||||
|
if s := mdBlock(child, 0); s != "" {
|
||||||
|
blocks = append(blocks, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := "# " + doc.Title + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
|
||||||
|
return []byte(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mdBlock(n pmNode, depth int) string {
|
||||||
|
switch n.Type {
|
||||||
|
case "heading":
|
||||||
|
return strings.Repeat("#", n.level()) + " " + mdInline(n.Content)
|
||||||
|
case "paragraph":
|
||||||
|
return mdInline(n.Content)
|
||||||
|
case "blockquote":
|
||||||
|
var lines []string
|
||||||
|
for _, c := range n.Content {
|
||||||
|
for _, l := range strings.Split(mdBlock(c, depth), "\n") {
|
||||||
|
lines = append(lines, "> "+l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
case "codeBlock":
|
||||||
|
return "```\n" + textContent(n) + "\n```"
|
||||||
|
case "horizontalRule":
|
||||||
|
return "---"
|
||||||
|
case "bulletList", "orderedList":
|
||||||
|
var items []string
|
||||||
|
for i, item := range n.Content {
|
||||||
|
marker := "- "
|
||||||
|
if n.Type == "orderedList" {
|
||||||
|
marker = fmt.Sprintf("%d. ", i+1)
|
||||||
|
}
|
||||||
|
indent := strings.Repeat(" ", depth)
|
||||||
|
// A listItem holds block children (usually one paragraph).
|
||||||
|
var parts []string
|
||||||
|
for _, c := range item.Content {
|
||||||
|
parts = append(parts, mdBlock(c, depth+1))
|
||||||
|
}
|
||||||
|
items = append(items, indent+marker+strings.TrimSpace(strings.Join(parts, "\n")))
|
||||||
|
}
|
||||||
|
return strings.Join(items, "\n")
|
||||||
|
default:
|
||||||
|
// Unknown block: render any inline text it carries.
|
||||||
|
if len(n.Content) > 0 {
|
||||||
|
return mdInline(n.Content)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mdInline(nodes []pmNode) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, n := range nodes {
|
||||||
|
switch n.Type {
|
||||||
|
case "text":
|
||||||
|
b.WriteString(applyMdMarks(n))
|
||||||
|
case "hardBreak":
|
||||||
|
b.WriteString(" \n")
|
||||||
|
default:
|
||||||
|
b.WriteString(mdInline(n.Content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyMdMarks(n pmNode) string {
|
||||||
|
t := n.Text
|
||||||
|
if n.hasMark("code") {
|
||||||
|
return "`" + t + "`" // code spans don't combine with other emphasis
|
||||||
|
}
|
||||||
|
if n.hasMark("bold") {
|
||||||
|
t = "**" + t + "**"
|
||||||
|
}
|
||||||
|
if n.hasMark("italic") {
|
||||||
|
t = "*" + t + "*"
|
||||||
|
}
|
||||||
|
if n.hasMark("strike") {
|
||||||
|
t = "~~" + t + "~~"
|
||||||
|
}
|
||||||
|
if n.hasMark("underline") {
|
||||||
|
t = "<u>" + t + "</u>"
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Plain text -------------------------------------------------------------
|
||||||
|
|
||||||
|
func renderPlainText(doc db.Document) ([]byte, error) {
|
||||||
|
root := parseDoc(doc)
|
||||||
|
var blocks []string
|
||||||
|
for _, child := range root.Content {
|
||||||
|
if s := txtBlock(child); s != "" {
|
||||||
|
blocks = append(blocks, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := doc.Title + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
|
||||||
|
return []byte(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func txtBlock(n pmNode) string {
|
||||||
|
switch n.Type {
|
||||||
|
case "bulletList", "orderedList":
|
||||||
|
var items []string
|
||||||
|
for i, item := range n.Content {
|
||||||
|
marker := "• "
|
||||||
|
if n.Type == "orderedList" {
|
||||||
|
marker = fmt.Sprintf("%d. ", i+1)
|
||||||
|
}
|
||||||
|
items = append(items, marker+strings.TrimSpace(textContent(item)))
|
||||||
|
}
|
||||||
|
return strings.Join(items, "\n")
|
||||||
|
case "horizontalRule":
|
||||||
|
return "----------"
|
||||||
|
default:
|
||||||
|
return textContent(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// textContent flattens all descendant text, joining hardBreaks as newlines.
|
||||||
|
func textContent(n pmNode) string {
|
||||||
|
if n.Type == "text" {
|
||||||
|
return n.Text
|
||||||
|
}
|
||||||
|
if n.Type == "hardBreak" {
|
||||||
|
return "\n"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, c := range n.Content {
|
||||||
|
b.WriteString(textContent(c))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTML -------------------------------------------------------------------
|
||||||
|
|
||||||
|
func renderHTMLFile(doc db.Document) ([]byte, error) {
|
||||||
|
root := parseDoc(doc)
|
||||||
|
var body strings.Builder
|
||||||
|
for _, child := range root.Content {
|
||||||
|
body.WriteString(htmlBlock(child))
|
||||||
|
}
|
||||||
|
page := fmt.Sprintf(htmlTemplate, htmlEscape(doc.Title), htmlEscape(doc.Title), body.String())
|
||||||
|
return []byte(page), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlTemplate is a standalone, self-contained page with a warm, readable
|
||||||
|
// stylesheet and a CJK-first font stack so exported writing looks like Petal,
|
||||||
|
// not a raw dump. No external assets — opens offline anywhere.
|
||||||
|
const htmlTemplate = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>%s</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
|
body {
|
||||||
|
font-family: "Georgia", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", serif;
|
||||||
|
line-height: 1.75; color: #463a3f; background: #fffafb;
|
||||||
|
max-width: 42rem; margin: 3rem auto; padding: 0 1.5rem;
|
||||||
|
}
|
||||||
|
h1, h2, h3 { font-family: "Georgia", "Songti SC", serif; color: #b04a6a; line-height: 1.3; }
|
||||||
|
h1 { font-size: 1.9rem; border-bottom: 2px solid #f6d6e0; padding-bottom: .4rem; }
|
||||||
|
blockquote { border-left: 3px solid #f3b6c8; margin: 1rem 0; padding: .2rem 1rem; color: #6b5860; background: #fff2f6; }
|
||||||
|
code { background: #fdeef3; padding: .1rem .35rem; border-radius: .3rem; font-size: .9em; }
|
||||||
|
pre { background: #fdeef3; padding: 1rem; border-radius: .6rem; overflow-x: auto; }
|
||||||
|
pre code { background: none; padding: 0; }
|
||||||
|
hr { border: none; border-top: 1px solid #f3cdd9; margin: 2rem 0; }
|
||||||
|
a { color: #b04a6a; }
|
||||||
|
ul, ol { padding-left: 1.4rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>%s</h1>
|
||||||
|
%s</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
func htmlBlock(n pmNode) string {
|
||||||
|
switch n.Type {
|
||||||
|
case "heading":
|
||||||
|
tag := fmt.Sprintf("h%d", clampHeading(n.level()))
|
||||||
|
return fmt.Sprintf("<%s>%s</%s>\n", tag, htmlInline(n.Content), tag)
|
||||||
|
case "paragraph":
|
||||||
|
inner := htmlInline(n.Content)
|
||||||
|
if inner == "" {
|
||||||
|
return "<p><br></p>\n"
|
||||||
|
}
|
||||||
|
return "<p>" + inner + "</p>\n"
|
||||||
|
case "blockquote":
|
||||||
|
var b strings.Builder
|
||||||
|
for _, c := range n.Content {
|
||||||
|
b.WriteString(htmlBlock(c))
|
||||||
|
}
|
||||||
|
return "<blockquote>" + b.String() + "</blockquote>\n"
|
||||||
|
case "codeBlock":
|
||||||
|
return "<pre><code>" + htmlEscape(textContent(n)) + "</code></pre>\n"
|
||||||
|
case "horizontalRule":
|
||||||
|
return "<hr>\n"
|
||||||
|
case "bulletList", "orderedList":
|
||||||
|
tag := "ul"
|
||||||
|
if n.Type == "orderedList" {
|
||||||
|
tag = "ol"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("<" + tag + ">\n")
|
||||||
|
for _, item := range n.Content {
|
||||||
|
var inner strings.Builder
|
||||||
|
for _, c := range item.Content {
|
||||||
|
// Unwrap a lone paragraph so list items aren't double-spaced.
|
||||||
|
if c.Type == "paragraph" {
|
||||||
|
inner.WriteString(htmlInline(c.Content))
|
||||||
|
} else {
|
||||||
|
inner.WriteString(htmlBlock(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("<li>" + inner.String() + "</li>\n")
|
||||||
|
}
|
||||||
|
b.WriteString("</" + tag + ">\n")
|
||||||
|
return b.String()
|
||||||
|
default:
|
||||||
|
if len(n.Content) > 0 {
|
||||||
|
return "<p>" + htmlInline(n.Content) + "</p>\n"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func htmlInline(nodes []pmNode) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, n := range nodes {
|
||||||
|
switch n.Type {
|
||||||
|
case "text":
|
||||||
|
b.WriteString(applyHTMLMarks(n))
|
||||||
|
case "hardBreak":
|
||||||
|
b.WriteString("<br>")
|
||||||
|
default:
|
||||||
|
b.WriteString(htmlInline(n.Content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyHTMLMarks(n pmNode) string {
|
||||||
|
t := htmlEscape(n.Text)
|
||||||
|
if n.hasMark("code") {
|
||||||
|
return "<code>" + t + "</code>"
|
||||||
|
}
|
||||||
|
if n.hasMark("bold") {
|
||||||
|
t = "<strong>" + t + "</strong>"
|
||||||
|
}
|
||||||
|
if n.hasMark("italic") {
|
||||||
|
t = "<em>" + t + "</em>"
|
||||||
|
}
|
||||||
|
if n.hasMark("underline") {
|
||||||
|
t = "<u>" + t + "</u>"
|
||||||
|
}
|
||||||
|
if n.hasMark("strike") {
|
||||||
|
t = "<s>" + t + "</s>"
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampHeading(level int) int {
|
||||||
|
if level < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if level > 6 {
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DOCX -------------------------------------------------------------------
|
||||||
|
|
||||||
|
// renderDocx builds a minimal but valid .docx (Office Open XML) in pure Go: a
|
||||||
|
// zip of the few XML parts Word needs. Bold/italic/underline/strike map to run
|
||||||
|
// properties; headings use Word's built-in styles; lists are rendered with a
|
||||||
|
// bullet/number prefix (no numbering.xml dependency). CJK renders with the
|
||||||
|
// reader's own fonts, so no font embedding is required.
|
||||||
|
func renderDocx(doc db.Document) ([]byte, error) {
|
||||||
|
root := parseDoc(doc)
|
||||||
|
|
||||||
|
var body strings.Builder
|
||||||
|
body.WriteString(docxHeading(doc.Title, 1))
|
||||||
|
for _, child := range root.Content {
|
||||||
|
body.WriteString(docxBlock(child))
|
||||||
|
}
|
||||||
|
|
||||||
|
document := xmlHeader + `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
|
||||||
|
`<w:body>` + body.String() +
|
||||||
|
`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>` +
|
||||||
|
`</w:body></w:document>`
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&buf)
|
||||||
|
parts := []struct{ name, content string }{
|
||||||
|
{"[Content_Types].xml", docxContentTypes},
|
||||||
|
{"_rels/.rels", docxRootRels},
|
||||||
|
{"word/_rels/document.xml.rels", docxDocRels},
|
||||||
|
{"word/document.xml", document},
|
||||||
|
}
|
||||||
|
for _, p := range parts {
|
||||||
|
fw, err := zw.Create(p.name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := fw.Write([]byte(p.content)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
xmlHeader = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n"
|
||||||
|
docxContentTypes = xmlHeader + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
|
||||||
|
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
|
||||||
|
`<Default Extension="xml" ContentType="application/xml"/>` +
|
||||||
|
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>` +
|
||||||
|
`</Types>`
|
||||||
|
docxRootRels = xmlHeader + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
|
||||||
|
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>` +
|
||||||
|
`</Relationships>`
|
||||||
|
docxDocRels = xmlHeader + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
|
||||||
|
)
|
||||||
|
|
||||||
|
func docxBlock(n pmNode) string {
|
||||||
|
switch n.Type {
|
||||||
|
case "heading":
|
||||||
|
return docxHeading(textContent(n), n.level())
|
||||||
|
case "paragraph":
|
||||||
|
return docxPara(n, "")
|
||||||
|
case "blockquote":
|
||||||
|
var b strings.Builder
|
||||||
|
for _, c := range n.Content {
|
||||||
|
b.WriteString(docxPara(c, "Quote"))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
case "codeBlock":
|
||||||
|
// One paragraph per line preserves layout without a code style.
|
||||||
|
var b strings.Builder
|
||||||
|
for _, line := range strings.Split(textContent(n), "\n") {
|
||||||
|
b.WriteString(`<w:p><w:r><w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/></w:rPr>` +
|
||||||
|
`<w:t xml:space="preserve">` + xmlEscape(line) + `</w:t></w:r></w:p>`)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
case "horizontalRule":
|
||||||
|
return `<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/></w:pBdr></w:pPr></w:p>`
|
||||||
|
case "bulletList", "orderedList":
|
||||||
|
var b strings.Builder
|
||||||
|
for i, item := range n.Content {
|
||||||
|
prefix := "• "
|
||||||
|
if n.Type == "orderedList" {
|
||||||
|
prefix = fmt.Sprintf("%d. ", i+1)
|
||||||
|
}
|
||||||
|
for _, c := range item.Content {
|
||||||
|
b.WriteString(docxPara(c, "", prefix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
default:
|
||||||
|
if len(n.Content) > 0 {
|
||||||
|
return docxPara(n, "")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// docxPara renders a block's inline children as a Word paragraph. An optional
|
||||||
|
// style name (e.g. "Quote") and an optional literal text prefix (for list
|
||||||
|
// markers) may be supplied.
|
||||||
|
func docxPara(n pmNode, style string, prefix ...string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("<w:p>")
|
||||||
|
if style != "" {
|
||||||
|
b.WriteString(`<w:pPr><w:pStyle w:val="` + style + `"/></w:pPr>`)
|
||||||
|
}
|
||||||
|
if len(prefix) > 0 && prefix[0] != "" {
|
||||||
|
b.WriteString(docxRun(pmNode{Type: "text", Text: prefix[0]}))
|
||||||
|
}
|
||||||
|
b.WriteString(docxInline(n.Content))
|
||||||
|
b.WriteString("</w:p>")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func docxHeading(text string, level int) string {
|
||||||
|
return `<w:p><w:pPr><w:pStyle w:val="Heading` + fmt.Sprintf("%d", clampHeading(level)) + `"/></w:pPr>` +
|
||||||
|
docxRun(pmNode{Type: "text", Text: text}) + `</w:p>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func docxInline(nodes []pmNode) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, n := range nodes {
|
||||||
|
switch n.Type {
|
||||||
|
case "text":
|
||||||
|
b.WriteString(docxRun(n))
|
||||||
|
case "hardBreak":
|
||||||
|
b.WriteString(`<w:r><w:br/></w:r>`)
|
||||||
|
default:
|
||||||
|
b.WriteString(docxInline(n.Content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func docxRun(n pmNode) string {
|
||||||
|
var props strings.Builder
|
||||||
|
if n.hasMark("bold") {
|
||||||
|
props.WriteString("<w:b/>")
|
||||||
|
}
|
||||||
|
if n.hasMark("italic") {
|
||||||
|
props.WriteString("<w:i/>")
|
||||||
|
}
|
||||||
|
if n.hasMark("underline") {
|
||||||
|
props.WriteString(`<w:u w:val="single"/>`)
|
||||||
|
}
|
||||||
|
if n.hasMark("strike") {
|
||||||
|
props.WriteString("<w:strike/>")
|
||||||
|
}
|
||||||
|
rpr := ""
|
||||||
|
if props.Len() > 0 {
|
||||||
|
rpr = "<w:rPr>" + props.String() + "</w:rPr>"
|
||||||
|
}
|
||||||
|
return `<w:r>` + rpr + `<w:t xml:space="preserve">` + xmlEscape(n.Text) + `</w:t></w:r>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- small helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
func htmlEscape(s string) string {
|
||||||
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func xmlEscape(s string) string {
|
||||||
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeFilename makes a title safe as a download filename, preserving CJK and
|
||||||
|
// most letters while dropping path separators and control characters.
|
||||||
|
func sanitizeFilename(title string) string {
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
if title == "" {
|
||||||
|
return "untitled"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range title {
|
||||||
|
switch {
|
||||||
|
case r < 0x20, r == '/', r == '\\', r == ':', r == '*', r == '?', r == '"', r == '<', r == '>', r == '|':
|
||||||
|
b.WriteRune('-')
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.TrimSpace(b.String())
|
||||||
|
if out == "" {
|
||||||
|
return "untitled"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlEscapeFilename percent-encodes a filename for the RFC 5987 filename*=
|
||||||
|
// Content-Disposition form, which carries UTF-8 (CJK titles) safely.
|
||||||
|
func urlEscapeFilename(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, c := range []byte(s) {
|
||||||
|
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
|
||||||
|
c == '-' || c == '_' || c == '.' || c == '~' {
|
||||||
|
b.WriteByte(c)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, "%%%02X", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
135
internal/docs/export_test.go
Normal file
135
internal/docs/export_test.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// richDocJSON is a Tiptap document exercising headings, marks, and a list —
|
||||||
|
// including CJK text, which every format must carry through intact.
|
||||||
|
const richDocJSON = `{"type":"doc","content":[` +
|
||||||
|
`{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"My Section"}]},` +
|
||||||
|
`{"type":"paragraph","content":[{"type":"text","text":"Hello "},{"type":"text","marks":[{"type":"bold"}],"text":"world"},{"type":"text","text":" 你好"}]},` +
|
||||||
|
`{"type":"bulletList","content":[` +
|
||||||
|
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]},` +
|
||||||
|
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"second"}]}]}` +
|
||||||
|
`]}` +
|
||||||
|
`]}`
|
||||||
|
|
||||||
|
func seedRichDoc(t *testing.T, srv http.Handler) string {
|
||||||
|
t.Helper()
|
||||||
|
id := newDoc(t, srv)
|
||||||
|
body := `{"title":"日记 Diary","content":` + jsonString(richDocJSON) + `,"content_text":"My Section\nHello world 你好\nfirst\nsecond","word_count":6}`
|
||||||
|
if rec := do(t, srv, http.MethodPut, "/"+id, body); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("seed rich doc: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonString quotes a string as a JSON string literal (so the Tiptap JSON can be
|
||||||
|
// embedded as the "content" field value).
|
||||||
|
func jsonString(s string) string {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportMarkdown(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
id := seedRichDoc(t, srv)
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=md", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("export md: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
|
||||||
|
t.Fatalf("unexpected content-type: %q", ct)
|
||||||
|
}
|
||||||
|
out := rec.Body.String()
|
||||||
|
for _, want := range []string{"## My Section", "**world**", "你好", "- first", "- second"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("markdown missing %q in:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CJK filename must survive in the RFC 5987 form.
|
||||||
|
if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, "filename*=UTF-8''") {
|
||||||
|
t.Fatalf("expected RFC 5987 filename, got %q", cd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportHTML(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
id := seedRichDoc(t, srv)
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=html", "")
|
||||||
|
out := rec.Body.String()
|
||||||
|
for _, want := range []string{"<!doctype html>", "<h2>My Section</h2>", "<strong>world</strong>", "你好", "<li>first</li>"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("html missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPlainText(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
id := seedRichDoc(t, srv)
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=txt", "")
|
||||||
|
out := rec.Body.String()
|
||||||
|
for _, want := range []string{"My Section", "Hello world 你好", "• first"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("txt missing %q in:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportDocx(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
id := seedRichDoc(t, srv)
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=docx", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("export docx: code=%d", rec.Code)
|
||||||
|
}
|
||||||
|
raw := rec.Body.Bytes()
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("docx is not a valid zip: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]bool{"[Content_Types].xml": false, "word/document.xml": false}
|
||||||
|
var docXML string
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if _, ok := want[f.Name]; ok {
|
||||||
|
want[f.Name] = true
|
||||||
|
}
|
||||||
|
if f.Name == "word/document.xml" {
|
||||||
|
rc, _ := f.Open()
|
||||||
|
b, _ := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
docXML = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, found := range want {
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("docx missing part %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range []string{"My Section", "你好", "<w:b/>", "Heading2"} {
|
||||||
|
if !strings.Contains(docXML, w) {
|
||||||
|
t.Fatalf("document.xml missing %q", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportUnsupportedFormat(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
id := newDoc(t, srv)
|
||||||
|
if rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=pdf", ""); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for unsupported format, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
242
internal/docs/handlers.go
Normal file
242
internal/docs/handlers.go
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
// 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"
|
||||||
|
"log"
|
||||||
|
"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)
|
||||||
|
h.versionRoutes(r)
|
||||||
|
h.exportRoutes(r)
|
||||||
|
h.registerTagRoutes(r)
|
||||||
|
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. Tags
|
||||||
|
// ride along so the sidebar can show chips and filter without a second request.
|
||||||
|
type docSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Tags []db.Tag `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// list returns the local user's documents, most-recently-updated first, each
|
||||||
|
// decorated with its tags.
|
||||||
|
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
|
||||||
|
ids := []string{}
|
||||||
|
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)
|
||||||
|
ids = append(ids, d.ID)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
byDoc, err := h.tagsByDoc(ids)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
out[i].Tags = byDoc[out[i].ID] // nil → JSON null is fine; client treats as none
|
||||||
|
if out[i].Tags == nil {
|
||||||
|
out[i].Tags = []db.Tag{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, tone, word_count, created_at, updated_at`,
|
||||||
|
db.LocalUserID,
|
||||||
|
).Scan(
|
||||||
|
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||||
|
&doc.Tone, &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"`
|
||||||
|
Tone *string `json:"tone"`
|
||||||
|
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),
|
||||||
|
tone = COALESCE(?, tone),
|
||||||
|
word_count = COALESCE(?, word_count),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND user_id = ?`,
|
||||||
|
req.Title, req.Content, req.ContentText, req.Tone, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture a throttled history snapshot when a real body save came through
|
||||||
|
// (not a bare rename) and the document has content. Best-effort: a failed
|
||||||
|
// snapshot must never fail the save itself.
|
||||||
|
if req.Content != nil && doc.ContentText != "" {
|
||||||
|
if err := h.maybeAutoSnapshot(doc); err != nil {
|
||||||
|
log.Printf("docs: auto-snapshot for %s failed: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, tone, word_count, created_at, updated_at
|
||||||
|
FROM documents
|
||||||
|
WHERE id = ? AND user_id = ?`,
|
||||||
|
id, db.LocalUserID,
|
||||||
|
).Scan(
|
||||||
|
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||||
|
&doc.Tone, &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") }
|
||||||
|
func notFoundMsg(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusNotFound, msg) }
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
261
internal/docs/search.go
Normal file
261
internal/docs/search.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Search snippet shaping.
|
||||||
|
const (
|
||||||
|
// ftsMinRunes is the shortest query the trigram FTS index can match. Shorter
|
||||||
|
// queries (common for 2-character Chinese words) fall back to a LIKE scan.
|
||||||
|
ftsMinRunes = 3
|
||||||
|
|
||||||
|
// snippetContext is how many runes of context to show on each side of the
|
||||||
|
// matched term in a result snippet.
|
||||||
|
snippetContext = 28
|
||||||
|
|
||||||
|
// maxSearchResults caps how many hits we return — plenty for a personal
|
||||||
|
// corpus, and keeps the response small.
|
||||||
|
maxSearchResults = 50
|
||||||
|
|
||||||
|
// hlStart/hlEnd wrap the matched span in a snippet. They're control-character
|
||||||
|
// sentinels that never occur in real text, so the client can split on them to
|
||||||
|
// highlight the match without escaping user content.
|
||||||
|
hlStart = "\x01"
|
||||||
|
hlEnd = "\x02"
|
||||||
|
)
|
||||||
|
|
||||||
|
// searchResult is one hit: a document summary plus a highlighted snippet showing
|
||||||
|
// where the query matched.
|
||||||
|
type searchResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
Tags []db.Tag `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchRoutes returns the router mounted at /api/search.
|
||||||
|
func (h *Handler) SearchRoutes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/", h.search)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// search runs a cross-document full-text search for the local user. Queries of
|
||||||
|
// three or more runes use the trigram FTS index (fast, ranked); shorter queries
|
||||||
|
// fall back to a LIKE scan so 2-character Chinese words still resolve. Either way
|
||||||
|
// the snippet is built in Go from the original text, for clean word boundaries
|
||||||
|
// and a uniform highlight format.
|
||||||
|
func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
if q == "" {
|
||||||
|
writeJSON(w, http.StatusOK, []searchResult{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
id, title, contentText, updatedAt string
|
||||||
|
wordCount int
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
|
||||||
|
if utf8.RuneCountInString(q) >= ftsMinRunes {
|
||||||
|
// Wrap the whole query as one FTS phrase (doubling embedded quotes), so
|
||||||
|
// special characters are treated literally and trigram does a contiguous
|
||||||
|
// substring match.
|
||||||
|
phrase := `"` + strings.ReplaceAll(q, `"`, `""`) + `"`
|
||||||
|
sqlRows, err := h.DB.Query(
|
||||||
|
`SELECT d.id, d.title, d.content_text, d.word_count, d.updated_at
|
||||||
|
FROM documents_fts f
|
||||||
|
JOIN documents d ON d.id = f.doc_id
|
||||||
|
WHERE documents_fts MATCH ? AND d.user_id = ?
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT ?`,
|
||||||
|
phrase, db.LocalUserID, maxSearchResults,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer sqlRows.Close()
|
||||||
|
for sqlRows.Next() {
|
||||||
|
var rw row
|
||||||
|
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows = append(rows, rw)
|
||||||
|
}
|
||||||
|
if err := sqlRows.Err(); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Short query: LIKE scan over title + body. Escape LIKE wildcards so a
|
||||||
|
// literal % or _ in the query matches itself.
|
||||||
|
like := "%" + escapeLike(q) + "%"
|
||||||
|
sqlRows, err := h.DB.Query(
|
||||||
|
`SELECT id, title, content_text, word_count, updated_at
|
||||||
|
FROM documents
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND (title LIKE ? ESCAPE '\' OR content_text LIKE ? ESCAPE '\')
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
db.LocalUserID, like, like, maxSearchResults,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer sqlRows.Close()
|
||||||
|
for sqlRows.Next() {
|
||||||
|
var rw row
|
||||||
|
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows = append(rows, rw)
|
||||||
|
}
|
||||||
|
if err := sqlRows.Err(); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]searchResult, 0, len(rows))
|
||||||
|
ids := make([]string, 0, len(rows))
|
||||||
|
for _, rw := range rows {
|
||||||
|
out = append(out, searchResult{
|
||||||
|
ID: rw.id,
|
||||||
|
Title: rw.title,
|
||||||
|
WordCount: rw.wordCount,
|
||||||
|
UpdatedAt: rw.updatedAt,
|
||||||
|
Snippet: buildSnippet(rw.title, rw.contentText, q),
|
||||||
|
})
|
||||||
|
ids = append(ids, rw.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
byDoc, err := h.tagsByDoc(ids)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
out[i].Tags = byDoc[out[i].ID]
|
||||||
|
if out[i].Tags == nil {
|
||||||
|
out[i].Tags = []db.Tag{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeLike escapes the LIKE metacharacters (% and _) and the escape character
|
||||||
|
// itself so the query is matched literally. Pairs with `ESCAPE '\'`.
|
||||||
|
func escapeLike(s string) string {
|
||||||
|
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSnippet returns a short excerpt around the first case-insensitive match of
|
||||||
|
// query, with the matched span wrapped in the hl sentinels. It prefers a body
|
||||||
|
// match (with surrounding context); if the query only appears in the title it
|
||||||
|
// highlights the title instead; otherwise it shows the body's opening so a result
|
||||||
|
// always shows something. Windowing is rune-aware so CJK is never split
|
||||||
|
// mid-character.
|
||||||
|
func buildSnippet(title, body, query string) string {
|
||||||
|
runes := []rune(body)
|
||||||
|
qLen := utf8.RuneCountInString(query)
|
||||||
|
if idx := runeIndexFold(runes, query); idx >= 0 {
|
||||||
|
start := idx - snippetContext
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
end := idx + qLen + snippetContext
|
||||||
|
if end > len(runes) {
|
||||||
|
end = len(runes)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
if start > 0 {
|
||||||
|
b.WriteString("…")
|
||||||
|
}
|
||||||
|
b.WriteString(string(runes[start:idx]))
|
||||||
|
b.WriteString(hlStart)
|
||||||
|
b.WriteString(string(runes[idx : idx+qLen]))
|
||||||
|
b.WriteString(hlEnd)
|
||||||
|
b.WriteString(string(runes[idx+qLen : end]))
|
||||||
|
if end < len(runes) {
|
||||||
|
b.WriteString("…")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body had no literal match (title-only hit, or an FTS span the literal scan
|
||||||
|
// can't reproduce). Highlight the title if the query is there.
|
||||||
|
tRunes := []rune(title)
|
||||||
|
if idx := runeIndexFold(tRunes, query); idx >= 0 {
|
||||||
|
return string(tRunes[:idx]) + hlStart + string(tRunes[idx:idx+qLen]) + hlEnd + string(tRunes[idx+qLen:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: the body's opening as plain context.
|
||||||
|
return clip(runes, 0, 2*snippetContext+qLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clip returns runes[start:start+n] (bounded), with a trailing ellipsis when the
|
||||||
|
// body continues. Used for the no-direct-match fallback.
|
||||||
|
func clip(runes []rune, start, n int) string {
|
||||||
|
if start >= len(runes) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
end := start + n
|
||||||
|
trailing := ""
|
||||||
|
if end < len(runes) {
|
||||||
|
trailing = "…"
|
||||||
|
} else {
|
||||||
|
end = len(runes)
|
||||||
|
}
|
||||||
|
return string(runes[start:end]) + trailing
|
||||||
|
}
|
||||||
|
|
||||||
|
// runeIndexFold finds the first index (in runes) where query occurs in runes,
|
||||||
|
// case-insensitively. Returns -1 if absent. Simple O(n*m) scan — fine for
|
||||||
|
// single-document snippet building.
|
||||||
|
func runeIndexFold(runes []rune, query string) int {
|
||||||
|
q := []rune(strings.ToLower(query))
|
||||||
|
if len(q) == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
lower := make([]rune, len(runes))
|
||||||
|
for i, r := range runes {
|
||||||
|
lower[i] = toLowerRune(r)
|
||||||
|
}
|
||||||
|
for i := 0; i+len(q) <= len(lower); i++ {
|
||||||
|
match := true
|
||||||
|
for j := 0; j < len(q); j++ {
|
||||||
|
if lower[i+j] != q[j] {
|
||||||
|
match = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// toLowerRune lowercases ASCII letters (the only case-bearing script here); CJK
|
||||||
|
// and other runes pass through unchanged.
|
||||||
|
func toLowerRune(r rune) rune {
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
return r + ('a' - 'A')
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
111
internal/docs/search_test.go
Normal file
111
internal/docs/search_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearch(t *testing.T) {
|
||||||
|
srv := newFullServer(t)
|
||||||
|
|
||||||
|
createDoc(t, srv, "My Essay", "The quick brown fox jumps over the lazy dog")
|
||||||
|
createDoc(t, srv, "我的日记", "今天天气很好,我去公园散步,心情非常愉快")
|
||||||
|
createDoc(t, srv, "Notes", "groceries and errands for the weekend")
|
||||||
|
|
||||||
|
search := func(q string) []searchResult {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(t, srv, http.MethodGet, "/search?q="+urlQuery(q), "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("search %q: %d %s", q, rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var out []searchResult
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode search %q: %v", q, err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// English, ≥3 chars → FTS path. Snippet wraps the match in sentinels.
|
||||||
|
res := search("quick")
|
||||||
|
if len(res) != 1 || res[0].Title != "My Essay" {
|
||||||
|
t.Fatalf("english search: %+v", res)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res[0].Snippet, hlStart+"quick"+hlEnd) {
|
||||||
|
t.Fatalf("snippet not highlighted: %q", res[0].Snippet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chinese, ≥3 chars → FTS path (trigram handles CJK).
|
||||||
|
res = search("天气很好")
|
||||||
|
if len(res) != 1 || res[0].Title != "我的日记" {
|
||||||
|
t.Fatalf("chinese fts search: %+v", res)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res[0].Snippet, hlStart+"天气很好"+hlEnd) {
|
||||||
|
t.Fatalf("cjk snippet not highlighted: %q", res[0].Snippet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2-character Chinese word → LIKE fallback (below the trigram minimum).
|
||||||
|
res = search("公园")
|
||||||
|
if len(res) != 1 || res[0].Title != "我的日记" {
|
||||||
|
t.Fatalf("cjk short (LIKE) search: %+v", res)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res[0].Snippet, hlStart+"公园"+hlEnd) {
|
||||||
|
t.Fatalf("short cjk snippet not highlighted: %q", res[0].Snippet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title-only match still returns the doc, highlighting the title.
|
||||||
|
res = search("Notes")
|
||||||
|
if len(res) != 1 || res[0].Title != "Notes" {
|
||||||
|
t.Fatalf("title search: %+v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive.
|
||||||
|
if got := search("QUICK"); len(got) != 1 {
|
||||||
|
t.Fatalf("case-insensitive search: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match → empty (non-nil) array.
|
||||||
|
if got := search("zzzznotthere"); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no results, got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty query → empty array, no error.
|
||||||
|
rec := do(t, srv, http.MethodGet, "/search?q=", "")
|
||||||
|
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" {
|
||||||
|
t.Fatalf("empty query: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An edit re-indexes via the update trigger: the old term stops matching, the
|
||||||
|
// new term starts.
|
||||||
|
res = search("quick")
|
||||||
|
docID := res[0].ID
|
||||||
|
body := `{"content":"{}","content_text":"the slow purple turtle ambles along","word_count":6}`
|
||||||
|
do(t, srv, http.MethodPut, "/docs/"+docID, body)
|
||||||
|
if got := search("quick"); len(got) != 0 {
|
||||||
|
t.Fatalf("stale term still matches after edit: %+v", got)
|
||||||
|
}
|
||||||
|
if got := search("purple turtle"); len(got) != 1 || got[0].ID != docID {
|
||||||
|
t.Fatalf("new term not indexed after edit: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlQuery percent-encodes a search term for the query string.
|
||||||
|
func urlQuery(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range []byte(s) {
|
||||||
|
if r == ' ' {
|
||||||
|
b.WriteByte('+')
|
||||||
|
} else if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||||
|
b.WriteByte(r)
|
||||||
|
} else {
|
||||||
|
b.WriteString(percentByte(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentByte(b byte) string {
|
||||||
|
const hex = "0123456789ABCDEF"
|
||||||
|
return string([]byte{'%', hex[b>>4], hex[b&0xf]})
|
||||||
|
}
|
||||||
303
internal/docs/tags.go
Normal file
303
internal/docs/tags.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validTagColors is the palette a tag may use, mirrored from the design tokens.
|
||||||
|
// An unknown color on input is coerced to rose so the UI always has a token.
|
||||||
|
var validTagColors = map[string]bool{
|
||||||
|
db.TagColorRose: true, db.TagColorMint: true, db.TagColorPeach: true,
|
||||||
|
db.TagColorLavender: true, db.TagColorSky: true, db.TagColorHoney: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeColor(c string) string {
|
||||||
|
if validTagColors[c] {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return db.TagColorRose
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagRoutes returns the router mounted at /api/tags for tag management (the
|
||||||
|
// roster the writer creates and recolors), separate from the per-document
|
||||||
|
// assignment routes registered on the docs router.
|
||||||
|
func (h *Handler) TagRoutes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/", h.listTags)
|
||||||
|
r.Post("/", h.createTag)
|
||||||
|
r.Patch("/{id}", h.updateTag)
|
||||||
|
r.Delete("/{id}", h.deleteTag)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerTagRoutes adds the per-document tag assignment routes onto the docs
|
||||||
|
// sub-router so they resolve under /api/docs/{id}/tags.
|
||||||
|
func (h *Handler) registerTagRoutes(r chi.Router) {
|
||||||
|
r.Post("/{id}/tags", h.assignTag)
|
||||||
|
r.Delete("/{id}/tags/{tagId}", h.unassignTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listTags returns the user's tags alphabetically, each with the number of
|
||||||
|
// documents that wear it (so the sidebar can show counts and hide empties later
|
||||||
|
// if desired).
|
||||||
|
func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(
|
||||||
|
`SELECT t.id, t.name, t.color, COUNT(dt.doc_id)
|
||||||
|
FROM tags t
|
||||||
|
LEFT JOIN document_tags dt ON dt.tag_id = t.id
|
||||||
|
WHERE t.user_id = ?
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY t.name COLLATE NOCASE`,
|
||||||
|
db.LocalUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []db.Tag{} // non-nil so an empty roster serializes as []
|
||||||
|
for rows.Next() {
|
||||||
|
var t db.Tag
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.DocCount); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTag adds a tag. It's idempotent on (user, name): re-creating an existing
|
||||||
|
// tag returns it unchanged rather than erroring, so the client can "create or
|
||||||
|
// reuse" in one call. Recoloring is done via updateTag.
|
||||||
|
func (h *Handler) createTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req tagRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
badRequest(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
badRequest(w, "tag name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var t db.Tag
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, name) DO UPDATE SET name = excluded.name
|
||||||
|
RETURNING id, name, color`,
|
||||||
|
db.LocalUserID, name, normalizeColor(req.Color),
|
||||||
|
).Scan(&t.ID, &t.Name, &t.Color)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTag renames and/or recolors a tag. Both fields optional via pointers so
|
||||||
|
// a recolor needn't resend the name.
|
||||||
|
func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Color *string `json:"color"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
badRequest(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var namePtr, colorPtr any
|
||||||
|
if req.Name != nil {
|
||||||
|
n := strings.TrimSpace(*req.Name)
|
||||||
|
if n == "" {
|
||||||
|
badRequest(w, "tag name cannot be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
namePtr = n
|
||||||
|
}
|
||||||
|
if req.Color != nil {
|
||||||
|
colorPtr = normalizeColor(*req.Color)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.DB.Exec(
|
||||||
|
`UPDATE tags
|
||||||
|
SET name = COALESCE(?, name),
|
||||||
|
color = COALESCE(?, color)
|
||||||
|
WHERE id = ? AND user_id = ?`,
|
||||||
|
namePtr, colorPtr, id, db.LocalUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
notFoundMsg(w, "tag not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var t db.Tag
|
||||||
|
if err := h.DB.QueryRow(
|
||||||
|
`SELECT id, name, color FROM tags WHERE id = ? AND user_id = ?`,
|
||||||
|
id, db.LocalUserID,
|
||||||
|
).Scan(&t.ID, &t.Name, &t.Color); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteTag removes a tag; its document assignments cascade away via the FK.
|
||||||
|
func (h *Handler) deleteTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
res, err := h.DB.Exec(
|
||||||
|
`DELETE FROM tags WHERE id = ? AND user_id = ?`,
|
||||||
|
chi.URLParam(r, "id"), db.LocalUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
notFoundMsg(w, "tag not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignTag attaches a tag to a document. Both must belong to the local user;
|
||||||
|
// the assignment is idempotent (re-assigning is a no-op, not an error).
|
||||||
|
func (h *Handler) assignTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
TagID string `json:"tag_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
badRequest(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.TagID == "" {
|
||||||
|
badRequest(w, "tag_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify both the doc and the tag belong to the user before linking, so a
|
||||||
|
// stray id can't cross-link another account's rows.
|
||||||
|
if !h.ownsDoc(docID) {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.ownsTag(req.TagID) {
|
||||||
|
notFoundMsg(w, "tag not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.DB.Exec(
|
||||||
|
`INSERT INTO document_tags (doc_id, tag_id) VALUES (?, ?)
|
||||||
|
ON CONFLICT(doc_id, tag_id) DO NOTHING`,
|
||||||
|
docID, req.TagID,
|
||||||
|
); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unassignTag detaches a tag from a document.
|
||||||
|
func (h *Handler) unassignTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
tagID := chi.URLParam(r, "tagId")
|
||||||
|
|
||||||
|
if !h.ownsDoc(docID) {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.DB.Exec(
|
||||||
|
`DELETE FROM document_tags WHERE doc_id = ? AND tag_id = ?`,
|
||||||
|
docID, tagID,
|
||||||
|
); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownsDoc reports whether a document belongs to the local user.
|
||||||
|
func (h *Handler) ownsDoc(docID string) bool {
|
||||||
|
var exists bool
|
||||||
|
_ = h.DB.QueryRow(
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM documents WHERE id = ? AND user_id = ?)`,
|
||||||
|
docID, db.LocalUserID,
|
||||||
|
).Scan(&exists)
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownsTag reports whether a tag belongs to the local user.
|
||||||
|
func (h *Handler) ownsTag(tagID string) bool {
|
||||||
|
var exists bool
|
||||||
|
_ = h.DB.QueryRow(
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = ? AND user_id = ?)`,
|
||||||
|
tagID, db.LocalUserID,
|
||||||
|
).Scan(&exists)
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagsByDoc loads the tags for a set of documents in one query and groups them
|
||||||
|
// by doc id. Used to decorate the document list and search results without an
|
||||||
|
// N+1 of per-doc queries. Returns an empty (non-nil) map when ids is empty.
|
||||||
|
func (h *Handler) tagsByDoc(ids []string) (map[string][]db.Tag, error) {
|
||||||
|
out := map[string][]db.Tag{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the IN (?, ?, …) placeholder list.
|
||||||
|
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||||
|
args := make([]any, 0, len(ids)+1)
|
||||||
|
for _, id := range ids {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
args = append(args, db.LocalUserID)
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(
|
||||||
|
`SELECT dt.doc_id, t.id, t.name, t.color
|
||||||
|
FROM document_tags dt
|
||||||
|
JOIN tags t ON t.id = dt.tag_id
|
||||||
|
WHERE dt.doc_id IN (`+ph+`) AND t.user_id = ?
|
||||||
|
ORDER BY t.name COLLATE NOCASE`,
|
||||||
|
args...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var docID string
|
||||||
|
var t db.Tag
|
||||||
|
if err := rows.Scan(&docID, &t.ID, &t.Name, &t.Color); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[docID] = append(out[docID], t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
167
internal/docs/tags_test.go
Normal file
167
internal/docs/tags_test.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newFullServer mounts the document, tag, and search routers under the same base
|
||||||
|
// paths as the real server (/docs, /tags, /search) so tests can exercise the
|
||||||
|
// cross-router flows (create a doc, tag it, list, search).
|
||||||
|
func newFullServer(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() })
|
||||||
|
|
||||||
|
h := New(database)
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Mount("/docs", h.Routes())
|
||||||
|
r.Mount("/tags", h.TagRoutes())
|
||||||
|
r.Mount("/search", h.SearchRoutes())
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDoc makes a document with the given title/body and returns its id.
|
||||||
|
func createDoc(t *testing.T, srv http.Handler, title, text string) string {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs", "")
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create doc: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var d db.Document
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &d)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"title": title, "content": "{}", "content_text": text, "word_count": 1,
|
||||||
|
})
|
||||||
|
rec = do(t, srv, http.MethodPut, "/docs/"+d.ID, string(body))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("save doc: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
return d.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagLifecycle(t *testing.T) {
|
||||||
|
srv := newFullServer(t)
|
||||||
|
|
||||||
|
// Empty roster serializes as [].
|
||||||
|
rec := do(t, srv, http.MethodGet, "/tags", "")
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() == "null\n" {
|
||||||
|
t.Fatalf("empty tags: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a tag.
|
||||||
|
rec = do(t, srv, http.MethodPost, "/tags", `{"name":"日记","color":"lavender"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create tag: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var tag db.Tag
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &tag)
|
||||||
|
if tag.ID == "" || tag.Name != "日记" || tag.Color != "lavender" {
|
||||||
|
t.Fatalf("bad created tag: %+v", tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-creating the same name is idempotent (returns the same id, keeps color).
|
||||||
|
rec = do(t, srv, http.MethodPost, "/tags", `{"name":"日记","color":"sky"}`)
|
||||||
|
var again db.Tag
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &again)
|
||||||
|
if again.ID != tag.ID || again.Color != "lavender" {
|
||||||
|
t.Fatalf("create not idempotent: %+v vs %+v", again, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown color is coerced to rose.
|
||||||
|
rec = do(t, srv, http.MethodPost, "/tags", `{"name":"work","color":"chartreuse"}`)
|
||||||
|
var work db.Tag
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &work)
|
||||||
|
if work.Color != "rose" {
|
||||||
|
t.Fatalf("unknown color not coerced: %+v", work)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty name is rejected.
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/tags", `{"name":" "}`); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("empty name should be 400: %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recolor + rename via PATCH.
|
||||||
|
rec = do(t, srv, http.MethodPatch, "/tags/"+tag.ID, `{"color":"honey"}`)
|
||||||
|
var recolored db.Tag
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &recolored)
|
||||||
|
if recolored.Color != "honey" || recolored.Name != "日记" {
|
||||||
|
t.Fatalf("patch clobbered/failed: %+v", recolored)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign both tags to a document.
|
||||||
|
docID := createDoc(t, srv, "My Journal", "today was a good day")
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+tag.ID+`"}`); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("assign tag: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
// Re-assigning is idempotent.
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+tag.ID+`"}`); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("re-assign tag: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+work.ID+`"}`); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("assign 2nd tag: %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The doc list now carries both tags, ordered by name.
|
||||||
|
rec = do(t, srv, http.MethodGet, "/docs", "")
|
||||||
|
var docs []docSummary
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &docs)
|
||||||
|
if len(docs) != 1 || len(docs[0].Tags) != 2 {
|
||||||
|
t.Fatalf("doc list tags: %+v", docs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag list reports doc counts.
|
||||||
|
rec = do(t, srv, http.MethodGet, "/tags", "")
|
||||||
|
var roster []db.Tag
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &roster)
|
||||||
|
var found bool
|
||||||
|
for _, rt := range roster {
|
||||||
|
if rt.ID == tag.ID {
|
||||||
|
found = true
|
||||||
|
if rt.DocCount != 1 {
|
||||||
|
t.Fatalf("expected doc_count 1, got %d", rt.DocCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("tag missing from roster: %+v", roster)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unassign one tag.
|
||||||
|
if rec = do(t, srv, http.MethodDelete, "/docs/"+docID+"/tags/"+work.ID, ""); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("unassign: %d", rec.Code)
|
||||||
|
}
|
||||||
|
rec = do(t, srv, http.MethodGet, "/docs", "")
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &docs)
|
||||||
|
if len(docs[0].Tags) != 1 || docs[0].Tags[0].ID != tag.ID {
|
||||||
|
t.Fatalf("after unassign: %+v", docs[0].Tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting a tag cascades its assignments away.
|
||||||
|
if rec = do(t, srv, http.MethodDelete, "/tags/"+tag.ID, ""); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete tag: %d", rec.Code)
|
||||||
|
}
|
||||||
|
rec = do(t, srv, http.MethodGet, "/docs", "")
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &docs)
|
||||||
|
if len(docs[0].Tags) != 0 {
|
||||||
|
t.Fatalf("tag delete did not cascade: %+v", docs[0].Tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assigning a non-existent tag 404s; tagging a non-existent doc 404s.
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"nope"}`); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("assign unknown tag: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec = do(t, srv, http.MethodPost, "/docs/nope/tags", `{"tag_id":"`+work.ID+`"}`); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("assign to unknown doc: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
245
internal/docs/versions.go
Normal file
245
internal/docs/versions.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version-history tuning.
|
||||||
|
const (
|
||||||
|
// autoSnapshotInterval is the minimum gap between background ('auto')
|
||||||
|
// snapshots. Auto-save fires every ~1.5s; without a floor we'd store a
|
||||||
|
// version per keystroke-burst. A few minutes keeps history useful for
|
||||||
|
// recovery without unbounded growth.
|
||||||
|
autoSnapshotInterval = 3 * time.Minute
|
||||||
|
|
||||||
|
// maxAutoVersions caps how many 'auto' snapshots we retain per document.
|
||||||
|
// 'manual' and 'pre_restore' versions are never pruned — they're explicit
|
||||||
|
// restore points the writer (or a restore) deliberately created.
|
||||||
|
maxAutoVersions = 40
|
||||||
|
)
|
||||||
|
|
||||||
|
// versionRoutes registers the history endpoints on the docs sub-router. Paths
|
||||||
|
// resolve to /api/docs/{id}/versions...
|
||||||
|
func (h *Handler) versionRoutes(r chi.Router) {
|
||||||
|
r.Get("/{id}/versions", h.listVersions)
|
||||||
|
r.Post("/{id}/versions", h.createVersion) // explicit "save a restore point"
|
||||||
|
r.Get("/{id}/versions/{vid}", h.getVersion) // full body for preview
|
||||||
|
r.Post("/{id}/versions/{vid}/restore", h.restoreVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listVersions returns the document's snapshots, newest first, without the heavy
|
||||||
|
// content fields (those load on preview/restore).
|
||||||
|
func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
// Scope through the documents table so a snapshot is only visible to the
|
||||||
|
// owner of its parent document.
|
||||||
|
rows, err := h.DB.Query(
|
||||||
|
`SELECT v.id, v.doc_id, v.title, v.word_count, v.kind, v.created_at
|
||||||
|
FROM document_versions v
|
||||||
|
JOIN documents d ON d.id = v.doc_id
|
||||||
|
WHERE v.doc_id = ? AND d.user_id = ?
|
||||||
|
ORDER BY v.created_at DESC`,
|
||||||
|
docID, db.LocalUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []db.DocumentVersion{} // non-nil so an empty history serializes as []
|
||||||
|
for rows.Next() {
|
||||||
|
var v db.DocumentVersion
|
||||||
|
if err := rows.Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVersion returns one snapshot in full (including content) for preview.
|
||||||
|
func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
v, err := h.fetchVersion(chi.URLParam(r, "id"), chi.URLParam(r, "vid"))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
notFoundMsg(w, "version not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createVersion takes an explicit, user-requested ('manual') restore point from
|
||||||
|
// the document's current saved state.
|
||||||
|
func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
doc, err := h.fetch(docID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := h.insertVersion(doc, db.VersionKindManual)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreVersion copies a snapshot back onto the live document. Before
|
||||||
|
// overwriting, it captures the current state as a 'pre_restore' version so the
|
||||||
|
// restore is itself undoable. Returns the restored document.
|
||||||
|
func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
vid := chi.URLParam(r, "vid")
|
||||||
|
|
||||||
|
v, err := h.fetchVersion(docID, vid)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
notFoundMsg(w, "version not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := h.fetch(docID)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.DB.Exec(
|
||||||
|
`UPDATE documents
|
||||||
|
SET title = ?, content = ?, content_text = ?, word_count = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND user_id = ?`,
|
||||||
|
v.Title, v.Content, v.ContentText, v.WordCount, docID, db.LocalUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := h.fetch(docID)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeAutoSnapshot records a throttled background snapshot of the just-saved
|
||||||
|
// document. It no-ops when the newest snapshot is younger than
|
||||||
|
// autoSnapshotInterval, or when the body is unchanged since the last snapshot,
|
||||||
|
// so an idle or rename-only save costs nothing. Best-effort: callers log but do
|
||||||
|
// not fail the save if this errors. Prunes old 'auto' versions on success.
|
||||||
|
func (h *Handler) maybeAutoSnapshot(doc db.Document) error {
|
||||||
|
var (
|
||||||
|
lastAt time.Time
|
||||||
|
lastText string
|
||||||
|
hasPrev bool
|
||||||
|
)
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`SELECT created_at, content_text FROM document_versions
|
||||||
|
WHERE doc_id = ? ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
doc.ID,
|
||||||
|
).Scan(&lastAt, &lastText)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
hasPrev = false
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
hasPrev = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasPrev {
|
||||||
|
if lastText == doc.ContentText {
|
||||||
|
return nil // nothing meaningful changed
|
||||||
|
}
|
||||||
|
if time.Since(lastAt) < autoSnapshotInterval {
|
||||||
|
return nil // too soon; let edits accumulate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.insertVersion(doc, db.VersionKindAuto); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return h.pruneAutoVersions(doc.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertVersion writes a snapshot row of the given kind and returns it (without
|
||||||
|
// the heavy content fields, matching the list shape).
|
||||||
|
func (h *Handler) insertVersion(doc db.Document, kind string) (db.DocumentVersion, error) {
|
||||||
|
var v db.DocumentVersion
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`INSERT INTO document_versions (doc_id, title, content, content_text, word_count, kind)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
RETURNING id, doc_id, title, word_count, kind, created_at`,
|
||||||
|
doc.ID, doc.Title, doc.Content, doc.ContentText, doc.WordCount, kind,
|
||||||
|
).Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt)
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneAutoVersions trims a document's 'auto' snapshots to the newest
|
||||||
|
// maxAutoVersions, leaving 'manual' and 'pre_restore' restore points intact.
|
||||||
|
func (h *Handler) pruneAutoVersions(docID string) error {
|
||||||
|
_, err := h.DB.Exec(
|
||||||
|
`DELETE FROM document_versions
|
||||||
|
WHERE doc_id = ? AND kind = 'auto'
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT id FROM document_versions
|
||||||
|
WHERE doc_id = ? AND kind = 'auto'
|
||||||
|
ORDER BY created_at DESC LIMIT ?
|
||||||
|
)`,
|
||||||
|
docID, docID, maxAutoVersions,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchVersion loads one full snapshot, scoped to its owner via the parent doc.
|
||||||
|
func (h *Handler) fetchVersion(docID, vid string) (db.DocumentVersion, error) {
|
||||||
|
var v db.DocumentVersion
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`SELECT v.id, v.doc_id, v.title, v.content, v.content_text, v.word_count, v.kind, v.created_at
|
||||||
|
FROM document_versions v
|
||||||
|
JOIN documents d ON d.id = v.doc_id
|
||||||
|
WHERE v.id = ? AND v.doc_id = ? AND d.user_id = ?`,
|
||||||
|
vid, docID, db.LocalUserID,
|
||||||
|
).Scan(
|
||||||
|
&v.ID, &v.DocID, &v.Title, &v.Content, &v.ContentText,
|
||||||
|
&v.WordCount, &v.Kind, &v.CreatedAt,
|
||||||
|
)
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
107
internal/docs/versions_test.go
Normal file
107
internal/docs/versions_test.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newDoc creates a document and returns its id.
|
||||||
|
func newDoc(t *testing.T, srv http.Handler) string {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(t, srv, http.MethodPost, "/", "")
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create doc: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var d db.Document
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &d); err != nil {
|
||||||
|
t.Fatalf("decode doc: %v", err)
|
||||||
|
}
|
||||||
|
return d.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func listVersions(t *testing.T, srv http.Handler, id string) []db.DocumentVersion {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(t, srv, http.MethodGet, "/"+id+"/versions", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list versions: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var vs []db.DocumentVersion
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &vs); err != nil {
|
||||||
|
t.Fatalf("decode versions: %v", err)
|
||||||
|
}
|
||||||
|
return vs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionLifecycle(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
id := newDoc(t, srv)
|
||||||
|
|
||||||
|
// A bare rename (no content) must not snapshot.
|
||||||
|
do(t, srv, http.MethodPut, "/"+id, `{"title":"Just A Name"}`)
|
||||||
|
if vs := listVersions(t, srv, id); len(vs) != 0 {
|
||||||
|
t.Fatalf("rename should not snapshot, got %d", len(vs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// First real body save takes one auto snapshot (no prior version to throttle).
|
||||||
|
body := `{"content":"{\"type\":\"doc\"}","content_text":"first draft","word_count":2}`
|
||||||
|
do(t, srv, http.MethodPut, "/"+id, body)
|
||||||
|
vs := listVersions(t, srv, id)
|
||||||
|
if len(vs) != 1 || vs[0].Kind != db.VersionKindAuto {
|
||||||
|
t.Fatalf("expected 1 auto version, got %+v", vs)
|
||||||
|
}
|
||||||
|
// List view omits heavy content fields.
|
||||||
|
if vs[0].Content != "" {
|
||||||
|
t.Fatalf("list should omit content, got %q", vs[0].Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A near-immediate second save is throttled (no new auto version).
|
||||||
|
do(t, srv, http.MethodPut, "/"+id, `{"content":"{\"type\":\"doc\"}","content_text":"first draft v2","word_count":3}`)
|
||||||
|
if vs := listVersions(t, srv, id); len(vs) != 1 {
|
||||||
|
t.Fatalf("second save within interval should be throttled, got %d", len(vs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual snapshot always records, capturing current saved state.
|
||||||
|
rec := do(t, srv, http.MethodPost, "/"+id+"/versions", "")
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("manual snapshot: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var manual db.DocumentVersion
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &manual)
|
||||||
|
if manual.Kind != db.VersionKindManual {
|
||||||
|
t.Fatalf("expected manual kind, got %q", manual.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutate the live doc, then restore the manual snapshot.
|
||||||
|
do(t, srv, http.MethodPut, "/"+id, `{"content":"{\"type\":\"doc\"}","content_text":"ruined everything","word_count":2}`)
|
||||||
|
rec = do(t, srv, http.MethodPost, "/"+id+"/versions/"+manual.ID+"/restore", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("restore: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var restored db.Document
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &restored)
|
||||||
|
if restored.ContentText != "first draft v2" {
|
||||||
|
t.Fatalf("restore did not bring back snapshot text: %q", restored.ContentText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore left a pre_restore safety copy, so the bad state is itself recoverable.
|
||||||
|
var foundPreRestore bool
|
||||||
|
for _, v := range listVersions(t, srv, id) {
|
||||||
|
if v.Kind == db.VersionKindPreRestore {
|
||||||
|
foundPreRestore = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundPreRestore {
|
||||||
|
t.Fatal("restore should record a pre_restore safety snapshot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionNotFound(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
id := newDoc(t, srv)
|
||||||
|
if rec := do(t, srv, http.MethodGet, "/"+id+"/versions/nope", ""); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404 for unknown version, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/lexicon/data.go
Normal file
29
internal/lexicon/data.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Package lexicon serves offline word lookups — a Chinese gloss, a definition,
|
||||||
|
// and a list of synonyms for a single word — from public datasets compiled into
|
||||||
|
// the binary. Definitions come from the Wordset dictionary (modern, concise
|
||||||
|
// glosses with a part of speech and example, which read kindly for an ESL
|
||||||
|
// writer); synonyms come from the Moby Thesaurus; the Chinese gloss comes from
|
||||||
|
// ECDICT. All are gzipped JSON, decompressed lazily on first use so a writer who
|
||||||
|
// never looks up a word pays nothing.
|
||||||
|
package lexicon
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
// definitionsGz is the gzipped Wordset definitions map: word → [[pos, def,
|
||||||
|
// example], …], lowercase keys. Built by the data-prep step (see commit notes).
|
||||||
|
//
|
||||||
|
//go:embed data/definitions.json.gz
|
||||||
|
var definitionsGz []byte
|
||||||
|
|
||||||
|
// synonymsGz is the gzipped Moby thesaurus map: headword → [synonym, …],
|
||||||
|
// lowercase keys, capped per word to keep the popover (and the binary) small.
|
||||||
|
//
|
||||||
|
//go:embed data/synonyms.json.gz
|
||||||
|
var synonymsGz []byte
|
||||||
|
|
||||||
|
// glossGz is the gzipped English→Chinese gloss map: word → 中文 gloss, lowercase
|
||||||
|
// keys. Built from ECDICT (scripts/build_gloss.py), filtered to common words so
|
||||||
|
// an ESL writer who speaks Mandarin gets an instant translation on hover/lookup.
|
||||||
|
//
|
||||||
|
//go:embed data/gloss.json.gz
|
||||||
|
var glossGz []byte
|
||||||
BIN
internal/lexicon/data/definitions.json.gz
Normal file
BIN
internal/lexicon/data/definitions.json.gz
Normal file
Binary file not shown.
BIN
internal/lexicon/data/gloss.json.gz
Normal file
BIN
internal/lexicon/data/gloss.json.gz
Normal file
Binary file not shown.
BIN
internal/lexicon/data/synonyms.json.gz
Normal file
BIN
internal/lexicon/data/synonyms.json.gz
Normal file
Binary file not shown.
81
internal/lexicon/handlers.go
Normal file
81
internal/lexicon/handlers.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package lexicon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler serves the word-lookup endpoint backed by a single shared Lexicon.
|
||||||
|
type Handler struct {
|
||||||
|
Lex *Lexicon
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Handler with a fresh (lazily-loaded) Lexicon.
|
||||||
|
func NewHandler() *Handler { return &Handler{Lex: New()} }
|
||||||
|
|
||||||
|
// Routes returns the router mounted at /api/word. The word is a path segment so
|
||||||
|
// "/api/word/happy" reads naturally; it's URL-decoded to tolerate the rare
|
||||||
|
// punctuated token.
|
||||||
|
func (h *Handler) Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/{word}", h.lookup)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlossRoutes returns the router mounted at /api/gloss — the lightweight
|
||||||
|
// Chinese-only lookup behind the inline hover/select gloss. It shares the
|
||||||
|
// Handler's Lexicon, so the datasets still load just once.
|
||||||
|
func (h *Handler) GlossRoutes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/{word}", h.gloss)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup returns the definition + synonyms for one word. A word found in neither
|
||||||
|
// dataset still returns 200 with empty lists, so the popover can show a friendly
|
||||||
|
// "nothing found" rather than an error state.
|
||||||
|
func (h *Handler) lookup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
word := chi.URLParam(r, "word")
|
||||||
|
if decoded, err := url.PathUnescape(word); err == nil {
|
||||||
|
word = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.Lex.Lookup(word)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// Word lookups are static for the life of the build; let the browser cache
|
||||||
|
// them so repeated right-clicks on the same word are instant.
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
_ = json.NewEncoder(w).Encode(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gloss returns just the Chinese translation for one word. Like lookup, a miss
|
||||||
|
// is a 200 with an empty gloss so the hover tooltip can quietly skip rather than
|
||||||
|
// error.
|
||||||
|
func (h *Handler) gloss(w http.ResponseWriter, r *http.Request) {
|
||||||
|
word := chi.URLParam(r, "word")
|
||||||
|
if decoded, err := url.PathUnescape(word); err == nil {
|
||||||
|
word = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.Lex.Gloss(word)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
_ = json.NewEncoder(w).Encode(res)
|
||||||
|
}
|
||||||
249
internal/lexicon/lexicon.go
Normal file
249
internal/lexicon/lexicon.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package lexicon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Meaning is one sense of a word: its part of speech, the gloss, and an optional
|
||||||
|
// usage example. The frontend renders a few of these in the word popover.
|
||||||
|
type Meaning struct {
|
||||||
|
PartOfSpeech string `json:"part_of_speech"`
|
||||||
|
Definition string `json:"definition"`
|
||||||
|
Example string `json:"example,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the full lookup for one word. Either list may be empty (the word
|
||||||
|
// isn't a headword in that dataset); the frontend handles a partial or empty
|
||||||
|
// result gracefully. Gloss is the Chinese translation (empty when the word isn't
|
||||||
|
// in the gloss dataset) — shown first in the popover for the Mandarin-speaking
|
||||||
|
// writer.
|
||||||
|
type Result struct {
|
||||||
|
Word string `json:"word"`
|
||||||
|
Gloss string `json:"gloss"`
|
||||||
|
Definitions []Meaning `json:"definitions"`
|
||||||
|
Synonyms []string `json:"synonyms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlossResult is the lightweight payload for the inline hover/select gloss: just
|
||||||
|
// the word and its Chinese translation, no definitions or synonyms. Kept small
|
||||||
|
// so the hover tooltip is instant and trivially cacheable.
|
||||||
|
type GlossResult struct {
|
||||||
|
Word string `json:"word"`
|
||||||
|
Gloss string `json:"gloss"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxSynonyms caps how many synonyms we hand the popover, even though the dataset
|
||||||
|
// stores up to ~50 per word — a long flat wall of words overwhelms more than it
|
||||||
|
// helps, especially for an ESL reader scanning for the right fit.
|
||||||
|
const maxSynonyms = 16
|
||||||
|
|
||||||
|
// maxDefinitions caps the senses shown so the popover stays a glance, not an
|
||||||
|
// essay.
|
||||||
|
const maxDefinitions = 4
|
||||||
|
|
||||||
|
// Lexicon holds the lazily-loaded datasets. The maps are populated once, on the
|
||||||
|
// first Lookup, behind a sync.Once so startup stays instant and a load error is
|
||||||
|
// remembered rather than retried on every request.
|
||||||
|
type Lexicon struct {
|
||||||
|
once sync.Once
|
||||||
|
loadErr error
|
||||||
|
defs map[string][][]string // word → [[pos, def, example], …]
|
||||||
|
synonyms map[string][]string // word → [synonym, …]
|
||||||
|
gloss map[string]string // word → Chinese gloss
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Lexicon. The datasets aren't read until the first Lookup.
|
||||||
|
func New() *Lexicon { return &Lexicon{} }
|
||||||
|
|
||||||
|
func (l *Lexicon) load() {
|
||||||
|
l.once.Do(func() {
|
||||||
|
if err := gunzipJSON(definitionsGz, &l.defs); err != nil {
|
||||||
|
l.loadErr = fmt.Errorf("load definitions: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := gunzipJSON(synonymsGz, &l.synonyms); err != nil {
|
||||||
|
l.loadErr = fmt.Errorf("load synonyms: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := gunzipJSON(glossGz, &l.gloss); err != nil {
|
||||||
|
l.loadErr = fmt.Errorf("load gloss: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns the definition senses and synonyms for word. It first tries the
|
||||||
|
// word as written (lowercased), then a few simple morphological reductions
|
||||||
|
// (plurals, -ed/-ing/-ly) so "running" or "happily" still resolve. A word found
|
||||||
|
// in neither dataset yields a Result with empty lists (not an error).
|
||||||
|
func (l *Lexicon) Lookup(word string) (Result, error) {
|
||||||
|
l.load()
|
||||||
|
if l.loadErr != nil {
|
||||||
|
return Result{}, l.loadErr
|
||||||
|
}
|
||||||
|
|
||||||
|
norm := strings.ToLower(strings.TrimSpace(word))
|
||||||
|
res := Result{Word: word, Definitions: []Meaning{}, Synonyms: []string{}}
|
||||||
|
if norm == "" {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Gloss = lookupGloss(l.gloss, norm)
|
||||||
|
|
||||||
|
if raw := lookupDefs(l.defs, norm); raw != nil {
|
||||||
|
for _, m := range raw {
|
||||||
|
res.Definitions = append(res.Definitions, toMeaning(m))
|
||||||
|
if len(res.Definitions) >= maxDefinitions {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if syns := lookupSyns(l.synonyms, norm); syns != nil {
|
||||||
|
if len(syns) > maxSynonyms {
|
||||||
|
syns = syns[:maxSynonyms]
|
||||||
|
}
|
||||||
|
res.Synonyms = append(res.Synonyms, syns...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gloss returns just the Chinese translation for word (empty when absent). This
|
||||||
|
// is the fast path behind the inline hover/select gloss — it skips the
|
||||||
|
// definition and synonym datasets entirely.
|
||||||
|
func (l *Lexicon) Gloss(word string) (GlossResult, error) {
|
||||||
|
l.load()
|
||||||
|
if l.loadErr != nil {
|
||||||
|
return GlossResult{}, l.loadErr
|
||||||
|
}
|
||||||
|
norm := strings.ToLower(strings.TrimSpace(word))
|
||||||
|
return GlossResult{Word: word, Gloss: lookupGloss(l.gloss, norm)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupGloss walks the candidate forms of a word and returns the first gloss
|
||||||
|
// hit (so "running"/"studies" resolve via the same de-inflection as defs/syns).
|
||||||
|
func lookupGloss(m map[string]string, word string) string {
|
||||||
|
if word == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, c := range candidates(word) {
|
||||||
|
if v, ok := m[c]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupDefs / lookupSyns walk the candidate forms of a word and return the
|
||||||
|
// first dataset hit. They're separate (rather than a generic helper) only
|
||||||
|
// because the two maps have different value types.
|
||||||
|
func lookupDefs(m map[string][][]string, word string) [][]string {
|
||||||
|
for _, c := range candidates(word) {
|
||||||
|
if v, ok := m[c]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupSyns(m map[string][]string, word string) []string {
|
||||||
|
for _, c := range candidates(word) {
|
||||||
|
if v, ok := m[c]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidates returns the lemma forms to try, in priority order: the word itself,
|
||||||
|
// then conservative de-inflections. This is deliberately lightweight — a full
|
||||||
|
// stemmer would over-reduce ("business" → "busy") and surface wrong entries; a
|
||||||
|
// handful of common English suffix rules covers the everyday cases without a
|
||||||
|
// dependency. Duplicates are fine (map lookup is cheap); order is what matters.
|
||||||
|
func candidates(word string) []string {
|
||||||
|
out := []string{word}
|
||||||
|
add := func(s string) {
|
||||||
|
if len(s) >= 2 && s != word {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(word, "ies"): // studies → study
|
||||||
|
add(word[:len(word)-3] + "y")
|
||||||
|
case strings.HasSuffix(word, "es"): // boxes → box, wishes → wish
|
||||||
|
add(word[:len(word)-2])
|
||||||
|
add(word[:len(word)-1])
|
||||||
|
case strings.HasSuffix(word, "s"): // cats → cat
|
||||||
|
add(word[:len(word)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(word, "ing") { // running → run, making → make
|
||||||
|
stem := word[:len(word)-3]
|
||||||
|
add(stem)
|
||||||
|
add(stem + "e")
|
||||||
|
add(undouble(stem))
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(word, "ed") { // hoped → hope, stopped → stop
|
||||||
|
stem := word[:len(word)-2]
|
||||||
|
add(stem)
|
||||||
|
add(word[:len(word)-1])
|
||||||
|
add(undouble(stem))
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(word, "ly") { // happily handled above via ies path? no — quickly → quick
|
||||||
|
add(word[:len(word)-2])
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(word, "ily") { // happily → happy
|
||||||
|
add(word[:len(word)-3] + "y")
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// undouble collapses a doubled final consonant (stopp → stop, runn → run) so the
|
||||||
|
// -ed/-ing stems of doubled-consonant verbs resolve to their base form.
|
||||||
|
func undouble(stem string) string {
|
||||||
|
n := len(stem)
|
||||||
|
if n >= 2 && stem[n-1] == stem[n-2] {
|
||||||
|
return stem[:n-1]
|
||||||
|
}
|
||||||
|
return stem
|
||||||
|
}
|
||||||
|
|
||||||
|
// toMeaning maps a compact [pos, def, example] triple from the dataset onto the
|
||||||
|
// JSON-friendly Meaning. The dataset always stores three elements, but we guard
|
||||||
|
// the length so a malformed row can't panic.
|
||||||
|
func toMeaning(m []string) Meaning {
|
||||||
|
var out Meaning
|
||||||
|
if len(m) > 0 {
|
||||||
|
out.PartOfSpeech = m[0]
|
||||||
|
}
|
||||||
|
if len(m) > 1 {
|
||||||
|
out.Definition = m[1]
|
||||||
|
}
|
||||||
|
if len(m) > 2 {
|
||||||
|
out.Example = m[2]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// gunzipJSON decompresses gz and decodes the JSON into v.
|
||||||
|
func gunzipJSON(gz []byte, v any) error {
|
||||||
|
r, err := gzip.NewReader(bytes.NewReader(gz))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, v)
|
||||||
|
}
|
||||||
118
internal/lexicon/lexicon_test.go
Normal file
118
internal/lexicon/lexicon_test.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package lexicon
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestLookupKnownWord(t *testing.T) {
|
||||||
|
l := New()
|
||||||
|
res, err := l.Lookup("happy")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Definitions) == 0 {
|
||||||
|
t.Errorf("expected definitions for %q, got none", "happy")
|
||||||
|
}
|
||||||
|
if len(res.Synonyms) == 0 {
|
||||||
|
t.Errorf("expected synonyms for %q, got none", "happy")
|
||||||
|
}
|
||||||
|
if len(res.Synonyms) > maxSynonyms {
|
||||||
|
t.Errorf("synonyms not capped: got %d, want <= %d", len(res.Synonyms), maxSynonyms)
|
||||||
|
}
|
||||||
|
if len(res.Definitions) > maxDefinitions {
|
||||||
|
t.Errorf("definitions not capped: got %d, want <= %d", len(res.Definitions), maxDefinitions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupMorphology(t *testing.T) {
|
||||||
|
l := New()
|
||||||
|
// Inflected forms should resolve to their base entry via candidates().
|
||||||
|
for _, w := range []string{"running", "studies", "boxes", "quickly", "stopped"} {
|
||||||
|
res, err := l.Lookup(w)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup(%q): %v", w, err)
|
||||||
|
}
|
||||||
|
if len(res.Definitions) == 0 && len(res.Synonyms) == 0 {
|
||||||
|
t.Errorf("expected some result for inflected %q, got nothing", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupUnknownWord(t *testing.T) {
|
||||||
|
l := New()
|
||||||
|
res, err := l.Lookup("zzzxqqq")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Definitions) != 0 || len(res.Synonyms) != 0 {
|
||||||
|
t.Errorf("expected empty result for nonsense word, got %+v", res)
|
||||||
|
}
|
||||||
|
// Empty result must still serialize as [] not null for the frontend.
|
||||||
|
if res.Definitions == nil || res.Synonyms == nil {
|
||||||
|
t.Errorf("empty slices must be non-nil for JSON []: %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGloss(t *testing.T) {
|
||||||
|
l := New()
|
||||||
|
// A common word carries a Chinese gloss...
|
||||||
|
res, err := l.Gloss("river")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Gloss: %v", err)
|
||||||
|
}
|
||||||
|
if res.Word != "river" {
|
||||||
|
t.Errorf("Word = %q, want %q", res.Word, "river")
|
||||||
|
}
|
||||||
|
if res.Gloss == "" {
|
||||||
|
t.Errorf("expected a Chinese gloss for %q, got none", "river")
|
||||||
|
}
|
||||||
|
// ...and the inflected form resolves via the same de-inflection.
|
||||||
|
inflected, err := l.Gloss("rivers")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Gloss(rivers): %v", err)
|
||||||
|
}
|
||||||
|
if inflected.Gloss == "" {
|
||||||
|
t.Errorf("expected a gloss for inflected %q, got none", "rivers")
|
||||||
|
}
|
||||||
|
// A nonsense word is a clean empty (not an error).
|
||||||
|
miss, err := l.Gloss("zzzxqqq")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Gloss(miss): %v", err)
|
||||||
|
}
|
||||||
|
if miss.Gloss != "" {
|
||||||
|
t.Errorf("expected empty gloss for nonsense word, got %q", miss.Gloss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupIncludesGloss(t *testing.T) {
|
||||||
|
l := New()
|
||||||
|
res, err := l.Lookup("happy")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup: %v", err)
|
||||||
|
}
|
||||||
|
if res.Gloss == "" {
|
||||||
|
t.Errorf("expected Lookup to include a Chinese gloss for %q", "happy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidates(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"cats": "cat",
|
||||||
|
"studies": "study",
|
||||||
|
"running": "run",
|
||||||
|
"hoped": "hope",
|
||||||
|
"quickly": "quick",
|
||||||
|
"happily": "happy",
|
||||||
|
}
|
||||||
|
for word, want := range cases {
|
||||||
|
got := candidates(word)
|
||||||
|
found := false
|
||||||
|
for _, c := range got {
|
||||||
|
if c == want {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("candidates(%q) = %v, missing expected base %q", word, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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, tone string) ([]RawSuggestion, error) {
|
||||||
|
raw, err := client.Complete(ctx, CompletionRequest{
|
||||||
|
Messages: CheckpointMessages(TruncateDoc(contentText), tone),
|
||||||
|
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)
|
||||||
|
}
|
||||||
167
internal/llm/prompts.go
Normal file
167
internal/llm/prompts.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
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.%s
|
||||||
|
|
||||||
|
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": []}`
|
||||||
|
|
||||||
|
// toneGuidance returns a sentence steering the checkpoint toward the writer's
|
||||||
|
// chosen tone, or "" for the neutral default. The clause is appended to the
|
||||||
|
// checkpoint instructions so the model's phrasing suggestions fit the target
|
||||||
|
// register (e.g. an academic essay vs a casual journal). Unknown values fall
|
||||||
|
// back to no steering, so a stray tone string is harmless.
|
||||||
|
func toneGuidance(tone string) string {
|
||||||
|
clause, ok := map[string]string{
|
||||||
|
"academic": "formal, academic, and objective — suited to a school essay or research paper",
|
||||||
|
"professional": "polished and professional — suited to a workplace email or report",
|
||||||
|
"casual": "relaxed, friendly, and conversational",
|
||||||
|
"humorous": "light, playful, and good-humored",
|
||||||
|
"creative": "vivid, expressive, and imaginative — suited to a story or personal narrative",
|
||||||
|
"persuasive": "confident and persuasive — suited to an argument or opinion piece",
|
||||||
|
}[tone]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "\n\nThe writer wants this document to read as " + clause + ". When phrasing could " +
|
||||||
|
"be improved, prefer suggestions that fit that tone, and gently flag wording that clashes with it."
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckpointMessages builds the message array for a grammar checkpoint over the
|
||||||
|
// given (already-truncated) document text, steered toward the document's tone.
|
||||||
|
func CheckpointMessages(contentText, tone string) []Message {
|
||||||
|
return []Message{
|
||||||
|
{Role: "system", Content: fmt.Sprintf(checkpointSystemPrompt, toneGuidance(tone))},
|
||||||
|
{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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteSystemTemplate drives the "say it more naturally" / tone-rewrite tool.
|
||||||
|
// The writer selects a passage and picks a style; the model rewrites that
|
||||||
|
// passage in place. The instruction is deliberately strict about returning ONLY
|
||||||
|
// the rewritten passage so the result can be dropped straight into the editor —
|
||||||
|
// no quotes, no preamble, no commentary to strip.
|
||||||
|
const rewriteSystemTemplate = `You are Petal, a warm English writing assistant helping someone who speaks English ` +
|
||||||
|
`as a second language. Rewrite the passage the user sends so that it %s, while preserving its original ` +
|
||||||
|
`meaning. Fix any grammar mistakes and awkward phrasing along the way. Keep it about the same length — ` +
|
||||||
|
`do not add new ideas, explanations, or commentary.
|
||||||
|
|
||||||
|
Respond with ONLY the rewritten passage. No quotation marks around it, no preamble, no notes — just the ` +
|
||||||
|
`rewritten English text, ready to drop back into the document.`
|
||||||
|
|
||||||
|
// styleGuidance maps a rewrite style onto the clause describing the target
|
||||||
|
// register. "natural" is the default "say it more naturally" action; the rest
|
||||||
|
// mirror the document-tone vocabulary (see toneGuidance / the ToneSelect UI).
|
||||||
|
// An unknown style falls back to the natural rewrite.
|
||||||
|
func styleGuidance(style string) string {
|
||||||
|
switch style {
|
||||||
|
case "academic":
|
||||||
|
return "reads as formal, academic English suited to a school essay or research paper"
|
||||||
|
case "professional":
|
||||||
|
return "reads as polished, professional English suited to a workplace email or report"
|
||||||
|
case "casual":
|
||||||
|
return "sounds relaxed, friendly, and conversational"
|
||||||
|
case "humorous":
|
||||||
|
return "has a light, playful, good-humored tone"
|
||||||
|
case "creative":
|
||||||
|
return "is vivid, expressive, and imaginative"
|
||||||
|
case "persuasive":
|
||||||
|
return "is confident and persuasive"
|
||||||
|
default: // "natural"
|
||||||
|
return "sounds natural and fluent, the way a native English speaker would naturally say it"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RewriteMessages builds the message array for a tone-rewrite: the styled system
|
||||||
|
// instruction plus the passage to rewrite as the user turn.
|
||||||
|
func RewriteMessages(text, style string) []Message {
|
||||||
|
return []Message{
|
||||||
|
{Role: "system", Content: fmt.Sprintf(rewriteSystemTemplate, styleGuidance(style))},
|
||||||
|
{Role: "user", Content: text},
|
||||||
|
}
|
||||||
|
}
|
||||||
46
internal/llm/rewrite.go
Normal file
46
internal/llm/rewrite.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RewriteMaxRunes caps how much selected text a single rewrite will accept. A
|
||||||
|
// rewrite is a focused "fix this sentence/paragraph" action, not a whole-doc
|
||||||
|
// pass — bounding it keeps latency sane and the model on-task. The handler
|
||||||
|
// rejects longer selections before calling the model.
|
||||||
|
const RewriteMaxRunes = 2000
|
||||||
|
|
||||||
|
// RunRewrite rewrites a selected passage in the requested style (e.g. "natural",
|
||||||
|
// "academic"). It is a one-shot Complete — the result is shown as a preview the
|
||||||
|
// writer accepts or discards, so we want the whole rewrite before rendering.
|
||||||
|
func RunRewrite(ctx context.Context, client LLMClient, text, style string) (string, error) {
|
||||||
|
out, err := client.Complete(ctx, CompletionRequest{
|
||||||
|
Messages: RewriteMessages(text, style),
|
||||||
|
MaxTokens: 1024,
|
||||||
|
Temperature: 0.7,
|
||||||
|
TopP: 0.9,
|
||||||
|
RepetitionPenalty: 1.1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return cleanRewrite(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanRewrite trims the model's output down to just the rewritten passage. The
|
||||||
|
// prompt asks for no quotes or preamble, but small instruct models occasionally
|
||||||
|
// wrap the answer in matching quotes — strip a single surrounding pair so the
|
||||||
|
// text drops cleanly into the editor.
|
||||||
|
func cleanRewrite(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) >= 2 {
|
||||||
|
first, last := s[0], s[len(s)-1]
|
||||||
|
if (first == '"' && last == '"') ||
|
||||||
|
(first == '\'' && last == '\'') ||
|
||||||
|
(strings.HasPrefix(s, "“") && strings.HasSuffix(s, "”")) {
|
||||||
|
s = strings.TrimSpace(strings.Trim(s, "\"'“”"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
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)))
|
||||||
|
}
|
||||||
35
internal/llm/voice.go
Normal file
35
internal/llm/voice.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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).
|
||||||
|
// The tone argument is accepted for a uniform pass signature but ignored: voice
|
||||||
|
// consistency is judged against the document's own established voice, not an
|
||||||
|
// externally-chosen register.
|
||||||
|
func RunVoice(ctx context.Context, client LLMClient, contentText, _ string) ([]RawSuggestion, error) {
|
||||||
|
raw, err := client.Complete(ctx, CompletionRequest{
|
||||||
|
Messages: VoiceMessages(contentText),
|
||||||
|
MaxTokens: 2048,
|
||||||
|
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()
|
||||||
|
}
|
||||||
289
internal/suggestions/handlers.go
Normal file
289
internal/suggestions/handlers.go
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// 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.Post("/{id}/rewrite", h.rewrite)
|
||||||
|
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 and the document's tone it returns the model's raw
|
||||||
|
// suggestions. The voice pass ignores tone (see llm.RunVoice).
|
||||||
|
type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string) ([]llm.RawSuggestion, error)
|
||||||
|
|
||||||
|
// runPass is the shared body for both LLM passes. It loads the document text,
|
||||||
|
// enforces the pass's per-document rate limit, runs the model, swaps in the
|
||||||
|
// 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, tone string
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`SELECT content_text, tone FROM documents WHERE id = ? AND user_id = ?`,
|
||||||
|
docID, db.LocalUserID,
|
||||||
|
).Scan(&contentText, &tone)
|
||||||
|
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, tone)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
76
internal/suggestions/rewrite.go
Normal file
76
internal/suggestions/rewrite.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rewriteRequest is the body the selection bubble posts: the selected passage
|
||||||
|
// and the target style ("natural", "academic", …). The text is the client's
|
||||||
|
// live selection — unlike a checkpoint, there is nothing to anchor server-side,
|
||||||
|
// so the rewrite is stateless and never persisted (the editor applies it
|
||||||
|
// directly, and the version history captures the resulting document change).
|
||||||
|
type rewriteRequest struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Style string `json:"style"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rewriteResponse struct {
|
||||||
|
Rewrite string `json:"rewrite"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewrite runs a one-shot tone-rewrite over a selected passage and returns the
|
||||||
|
// rewritten text for the editor to preview. The document id scopes the request
|
||||||
|
// to the owner (and reserves room to feed document context to the model later),
|
||||||
|
// even though the passage itself rides in the body.
|
||||||
|
func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var body rewriteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
errorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
text := strings.TrimSpace(body.Text)
|
||||||
|
if text == "" {
|
||||||
|
errorJSON(w, http.StatusBadRequest, "no text to rewrite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len([]rune(text)) > llm.RewriteMaxRunes {
|
||||||
|
errorJSON(w, http.StatusBadRequest, "selection too long to rewrite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope to the owner so a stray id can't drive rewrites against another
|
||||||
|
// user's document (and 404 cleanly when it doesn't exist).
|
||||||
|
var exists int
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
|
||||||
|
docID, db.LocalUserID,
|
||||||
|
).Scan(&exists)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
errorJSON(w, http.StatusNotFound, "document not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style)
|
||||||
|
if err != nil {
|
||||||
|
errorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, rewriteResponse{Rewrite: out})
|
||||||
|
}
|
||||||
79
internal/suggestions/rewrite_test.go
Normal file
79
internal/suggestions/rewrite_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package suggestions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordingClient captures the last Complete request so the rewrite test can
|
||||||
|
// assert the styled system prompt and the selected passage reached the model.
|
||||||
|
type recordingClient struct {
|
||||||
|
response string
|
||||||
|
last llm.CompletionRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *recordingClient) Complete(_ context.Context, req llm.CompletionRequest) (string, error) {
|
||||||
|
c.last = req
|
||||||
|
return c.response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *recordingClient) Stream(_ context.Context, _ llm.CompletionRequest) (<-chan string, error) {
|
||||||
|
ch := make(chan string)
|
||||||
|
close(ch)
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewrite(t *testing.T) {
|
||||||
|
// The model wraps its answer in quotes; cleanRewrite should strip them.
|
||||||
|
client := &recordingClient{response: `"I have two apples."`}
|
||||||
|
srv, docID, _ := newTestServer(t, client)
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/rewrite",
|
||||||
|
`{"text":"I has two apple.","style":"academic"}`)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("rewrite: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var resp rewriteResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Rewrite != "I have two apples." {
|
||||||
|
t.Fatalf("rewrite = %q, want the de-quoted text", resp.Rewrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The passage rode in as the user turn, and the style steered the system turn.
|
||||||
|
msgs := client.last.Messages
|
||||||
|
if len(msgs) != 2 || msgs[0].Role != "system" || msgs[1].Role != "user" {
|
||||||
|
t.Fatalf("unexpected message shape: %+v", msgs)
|
||||||
|
}
|
||||||
|
if msgs[1].Content != "I has two apple." {
|
||||||
|
t.Fatalf("passage not forwarded: %q", msgs[1].Content)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msgs[0].Content, "academic") {
|
||||||
|
t.Fatalf("system prompt missing academic style guidance:\n%s", msgs[0].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteEmptyText(t *testing.T) {
|
||||||
|
client := &recordingClient{response: "anything"}
|
||||||
|
srv, docID, _ := newTestServer(t, client)
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/rewrite", `{"text":" ","style":"natural"}`)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("empty text: want 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteUnknownDoc(t *testing.T) {
|
||||||
|
client := &recordingClient{response: "anything"}
|
||||||
|
srv, _, _ := newTestServer(t, client)
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/does-not-exist/rewrite",
|
||||||
|
`{"text":"hello there","style":"natural"}`)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("unknown doc: want 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
778
petal-spec.md
778
petal-spec.md
@@ -1,778 +0,0 @@
|
|||||||
# Petal — AI Writing Assistant
|
|
||||||
## Project Specification for Claude Code
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Petal is a self-hosted, privacy-first writing editor for an ESL user. It replaces Grammarly with a warm, bubbly web app that combines Tiptap rich text editing, auto-save cloud storage, and periodic AI-powered grammar/ESL suggestions backed by a local vLLM inference endpoint.
|
|
||||||
|
|
||||||
Single-user initially (wife's Authentik account). Deployed to `write.parodia.dev`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Layer | Choice | Why |
|
|
||||||
|---|---|---|
|
|
||||||
| Frontend | React + Vite | Tiptap is React-native; best extension ecosystem |
|
|
||||||
| Editor | Tiptap v2 | ProseMirror with sane API; supports custom marks/decorations |
|
|
||||||
| Styling | Tailwind v4 | Utility-first; pairs well with custom design tokens |
|
|
||||||
| Backend | Go + chi | Consistent with rest of stack (PGX Comics, Veola) |
|
|
||||||
| Database | SQLite (modernc — pure Go, no cgo) | Simple, single-binary friendly |
|
|
||||||
| Spell check | nspell (JS, browser-side) | Zero latency, no backend call, loads hunspell dictionaries |
|
|
||||||
| AI suggestions | vLLM HTTP endpoint | Already running; configurable model + base URL |
|
|
||||||
| Auth | Authentik OIDC | Already running on parodia.dev |
|
|
||||||
| Deployment | Docker, Traefik | Consistent with existing infra |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design System
|
|
||||||
|
|
||||||
**Aesthetic:** soft, warm, bubbly. The UI should feel like a cozy stationery app, not a productivity tool.
|
|
||||||
|
|
||||||
### Color Palette
|
|
||||||
```
|
|
||||||
--color-bg: #FDF6F0 /* warm cream canvas */
|
|
||||||
--color-surface: #FFFFFF /* card/panel surfaces */
|
|
||||||
--color-surface-alt: #FFF0F5 /* rose-tinted alt surface */
|
|
||||||
--color-border: #F0E0EB /* soft pink-grey border */
|
|
||||||
--color-text: #3D2E39 /* warm dark plum, not harsh black */
|
|
||||||
--color-text-muted: #9B8CA3 /* muted lavender-grey */
|
|
||||||
--color-accent: #E8A0BF /* soft rose — primary interactive */
|
|
||||||
--color-accent-hover: #D98AAF /* slightly deeper on hover */
|
|
||||||
--color-mint: #A8D8C8 /* suggestion type: grammar */
|
|
||||||
--color-peach: #F4B8A0 /* suggestion type: phrasing */
|
|
||||||
--color-lavender: #C5B4E8 /* suggestion type: idiom */
|
|
||||||
--color-sky: #A8CCE8 /* suggestion type: clarity */
|
|
||||||
--color-honey: #CE9B4F /* suggestion type: voice — deepened amber for underline contrast on cream */
|
|
||||||
--color-success: #8FCFA8 /* saved, accepted */
|
|
||||||
--color-shadow: rgba(180, 130, 160, 0.12) /* warm-tinted shadow */
|
|
||||||
```
|
|
||||||
|
|
||||||
### Typography
|
|
||||||
- **UI / Headings:** Nunito (Google Fonts) — rounded, bubbly, friendly
|
|
||||||
- **Editor body:** Lora (Google Fonts) — warm serif, reads beautifully at paragraph length
|
|
||||||
- **Monospace / data:** JetBrains Mono (used sparingly for word counts etc.)
|
|
||||||
|
|
||||||
### Shape Language
|
|
||||||
- Border radius: `16px` on cards, `24px` on modals, `999px` on buttons/pills, `12px` on input fields
|
|
||||||
- Shadows: `0 4px 20px var(--color-shadow)` — soft, warm, lifted
|
|
||||||
- Transitions: `200ms ease` across all interactive elements
|
|
||||||
|
|
||||||
### Signature Element
|
|
||||||
Suggestion decorations animate in with a gentle fade + slight upward float (translateY 4px → 0). The acceptance animation plays a tiny confetti burst (CSS only, 3–4 colored dots). The pulsing LLM checkpoint indicator is a soft rose dot that breathes (opacity 0.4 → 1 → 0.4 in 2s).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
petal/
|
|
||||||
├── cmd/
|
|
||||||
│ └── server/
|
|
||||||
│ └── main.go # Entry point; embeds /web/dist
|
|
||||||
├── internal/
|
|
||||||
│ ├── config/
|
|
||||||
│ │ └── config.go # Env var loading
|
|
||||||
│ ├── db/
|
|
||||||
│ │ ├── db.go # SQLite init + migrations
|
|
||||||
│ │ └── models.go # Document, Suggestion, User types
|
|
||||||
│ ├── auth/
|
|
||||||
│ │ ├── oidc.go # Authentik OIDC flow
|
|
||||||
│ │ └── middleware.go # Session auth middleware
|
|
||||||
│ ├── docs/
|
|
||||||
│ │ └── handlers.go # Document CRUD handlers
|
|
||||||
│ ├── plagiarism/
|
|
||||||
│ │ ├── copyleaks.go # Copyleaks API client
|
|
||||||
│ │ └── voice.go # Local LLM voice consistency check
|
|
||||||
│ └── llm/
|
|
||||||
│ ├── client.go # LLMClient interface + factory function
|
|
||||||
│ ├── vllm.go # vLLM concrete implementation (OpenAI-compat)
|
|
||||||
│ ├── ollama.go # Ollama concrete implementation (native API)
|
|
||||||
│ ├── checkpoint.go # Checkpoint trigger + debounce logic
|
|
||||||
│ └── prompts.go # System prompts
|
|
||||||
├── web/
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── components/
|
|
||||||
│ │ │ ├── Editor/
|
|
||||||
│ │ │ │ ├── EditorCore.tsx # Tiptap instance + config
|
|
||||||
│ │ │ │ ├── SuggestionMark.tsx # Custom Tiptap mark extension
|
|
||||||
│ │ │ │ ├── SuggestionCard.tsx # Hover card (suggestion + explanation)
|
|
||||||
│ │ │ │ └── CheckpointIndicator.tsx
|
|
||||||
│ │ │ ├── DocList/
|
|
||||||
│ │ │ │ ├── DocList.tsx # Document browser sidebar
|
|
||||||
│ │ │ │ └── DocListItem.tsx
|
|
||||||
│ │ │ ├── Toolbar/
|
|
||||||
│ │ │ │ └── Toolbar.tsx # Formatting toolbar
|
|
||||||
│ │ │ └── StatusBar/
|
|
||||||
│ │ │ └── StatusBar.tsx # Word count, save status, checkpoint dot
|
|
||||||
│ │ ├── hooks/
|
|
||||||
│ │ │ ├── useAutoSave.ts # Debounced save (1.5s)
|
|
||||||
│ │ │ └── useCheckpoint.ts # LLM checkpoint trigger (4s pause)
|
|
||||||
│ │ ├── api/
|
|
||||||
│ │ │ └── client.ts # Fetch wrappers for all API routes
|
|
||||||
│ │ ├── App.tsx
|
|
||||||
│ │ └── main.tsx
|
|
||||||
│ ├── index.html
|
|
||||||
│ ├── package.json
|
|
||||||
│ ├── vite.config.ts
|
|
||||||
│ └── tailwind.config.ts
|
|
||||||
├── Dockerfile
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── .env.example
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE users (
|
|
||||||
id TEXT PRIMARY KEY, -- Authentik subject claim
|
|
||||||
email TEXT NOT NULL,
|
|
||||||
display_name TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE documents (
|
|
||||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
title TEXT NOT NULL DEFAULT 'Untitled',
|
|
||||||
content TEXT NOT NULL DEFAULT '{}', -- Tiptap JSON
|
|
||||||
content_text TEXT NOT NULL DEFAULT '', -- Plain text for LLM
|
|
||||||
word_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE suggestions (
|
|
||||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
||||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
||||||
from_pos INTEGER NOT NULL,
|
|
||||||
to_pos INTEGER NOT NULL,
|
|
||||||
original TEXT NOT NULL,
|
|
||||||
replacement TEXT NOT NULL,
|
|
||||||
explanation TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice')),
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE plagiarism_reports (
|
|
||||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
||||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
||||||
backend TEXT NOT NULL DEFAULT 'copyleaks',
|
|
||||||
similarity_pct REAL, -- overall similarity score 0–100
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending'
|
|
||||||
CHECK(status IN ('pending','complete','error')),
|
|
||||||
result_json TEXT, -- full Copyleaks response, stored raw
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
|
|
||||||
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Routes
|
|
||||||
|
|
||||||
All routes under `/api/`. Auth middleware validates session cookie on all except `/api/auth/*`.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/me → current user info
|
|
||||||
POST /api/auth/login → redirect to Authentik
|
|
||||||
GET /api/auth/callback → OIDC callback, set session cookie
|
|
||||||
POST /api/auth/logout → clear session
|
|
||||||
|
|
||||||
GET /api/docs → list user's documents (id, title, word_count, updated_at)
|
|
||||||
POST /api/docs → create new document → returns full document
|
|
||||||
GET /api/docs/:id → get document + pending suggestions
|
|
||||||
PUT /api/docs/:id → update document content (auto-save endpoint)
|
|
||||||
DELETE /api/docs/:id → delete document
|
|
||||||
|
|
||||||
POST /api/docs/:id/check → run grammar checkpoint (returns new suggestions)
|
|
||||||
POST /api/docs/:id/voice → run voice-consistency pass (returns voice suggestions)
|
|
||||||
|
|
||||||
POST /api/docs/:id/plagiarism → trigger Copyleaks check (returns report id)
|
|
||||||
GET /api/docs/:id/plagiarism/latest → get latest report for document
|
|
||||||
PUT /api/suggestions/:id → update suggestion status (accepted/rejected)
|
|
||||||
DELETE /api/docs/:id/suggestions → clear all suggestions for a doc
|
|
||||||
POST /api/suggestions/:id/chat → Ask Petal conversational follow-up (SSE stream)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Plagiarism Detection
|
|
||||||
|
|
||||||
### Architecture: Two Tiers
|
|
||||||
|
|
||||||
**Tier 1 — Voice Consistency Check (local, LLM, separate pass)**
|
|
||||||
The LLM reviews the document for passages that feel tonally inconsistent with the rest of her writing — formulaic phrasing, suspiciously polished sentences, register shifts. Surfaces these as a new suggestion type (`type: "voice"`) with a warm honey-amber decoration (`--color-honey`, distinct from the lavender used for `idiom`; deepened from a pale yellow so the underline stays legible against the cream canvas). This is not plagiarism detection; it's a writing consistency tool. It runs locally with no privacy cost and is useful for ESL writers who may have paraphrased too closely from a source.
|
|
||||||
|
|
||||||
**This is a distinct pass from the grammar checkpoint — do not bundle them.** Voice-consistency detection is inherently whole-document (it compares a passage against the established voice *everywhere else*), so it needs the full document as context and the full window is available (see Context Window Management). That makes it too expensive to fire on the fast 4s checkpoint cadence. Run it instead either:
|
|
||||||
- on a slow cadence (every 30–60s of idle), separate from the grammar checkpoint debounce, or
|
|
||||||
- on an explicit "Check my voice" action in the toolbar.
|
|
||||||
|
|
||||||
Send the full `content_text` (no trailing-window truncation — the model window is 256K, the document will fit). Use a higher `max_tokens` than the grammar checkpoint since it may flag several passages. The grammar checkpoint stays fast and local-context; voice runs slow and whole-document. Each uses the context size and cadence appropriate to its job.
|
|
||||||
|
|
||||||
**Tier 2 — Academic Plagiarism Check (Copyleaks, opt-in, manual)**
|
|
||||||
A dedicated "Check for plagiarism" button in the editor toolbar. On first use, shows a consent modal explaining that document text will be sent to Copyleaks servers for comparison against web and academic databases. User must explicitly confirm. After confirmation, preference is saved per-user and the modal does not reappear.
|
|
||||||
|
|
||||||
Copyleaks checks against web content and academic paper databases — the same corpus type schools use with Turnitin. Not identical to Turnitin, but meaningful for pre-submission review.
|
|
||||||
|
|
||||||
### Copyleaks Integration
|
|
||||||
|
|
||||||
**Env vars:**
|
|
||||||
```
|
|
||||||
COPYLEAKS_ENABLED=false # off by default
|
|
||||||
COPYLEAKS_API_KEY=
|
|
||||||
COPYLEAKS_EMAIL= # account email, required by their API
|
|
||||||
```
|
|
||||||
|
|
||||||
**API flow:**
|
|
||||||
Copyleaks uses an async model — you submit text, get a scan ID, then poll or receive webhook callback when complete.
|
|
||||||
|
|
||||||
```
|
|
||||||
1. POST https://api.copyleaks.com/v3/education/submit/url/{scanId}
|
|
||||||
(or text submission endpoint)
|
|
||||||
→ 201 Accepted, scanId stored in plagiarism_reports
|
|
||||||
|
|
||||||
2. Copyleaks calls webhook: POST /api/webhooks/copyleaks
|
|
||||||
→ Go handler updates plagiarism_reports with result_json + similarity_pct + status=complete
|
|
||||||
|
|
||||||
3. Frontend polls GET /api/docs/:id/plagiarism/latest every 5s
|
|
||||||
while status=pending, renders results when complete
|
|
||||||
```
|
|
||||||
|
|
||||||
**Webhook endpoint:** `POST /api/webhooks/copyleaks` — must be publicly reachable (it is, via write.parodia.dev). No auth middleware on this route; validate using Copyleaks HMAC signature header instead.
|
|
||||||
|
|
||||||
**Results display (`PlagiarismPanel` component):**
|
|
||||||
- Slide-in right panel (same pattern as AskPetal expanded card but full height)
|
|
||||||
- Top: large similarity percentage with color coding (green <15%, amber 15–30%, red >30%)
|
|
||||||
- Below: list of matched sources with URL, matched percentage, and matched passage
|
|
||||||
- Matched passages highlighted in the editor with a new red-tinted decoration type
|
|
||||||
- "Last checked: X minutes ago" + "Re-check" button
|
|
||||||
- Privacy reminder at panel footer: "Text was sent to Copyleaks for this check"
|
|
||||||
|
|
||||||
### Voice Consistency Prompt Addition
|
|
||||||
|
|
||||||
Add to the checkpoint system prompt (after existing ESL instructions):
|
|
||||||
|
|
||||||
```
|
|
||||||
Also identify any passages (2+ sentences) that feel tonally inconsistent with
|
|
||||||
the surrounding writing — unusually formal, unusually polished, or phrased in
|
|
||||||
a way that differs from the writer's established voice elsewhere in the document.
|
|
||||||
Flag these with type "voice". Do not flag the first paragraph (no baseline yet).
|
|
||||||
```
|
|
||||||
|
|
||||||
Return format addition:
|
|
||||||
```json
|
|
||||||
{ "original": "...", "replacement": null, "explanation": "This passage sounds more formal than the rest of your writing — worth reviewing.", "type": "voice" }
|
|
||||||
```
|
|
||||||
|
|
||||||
`replacement` is `null` for voice flags — there is no correction, just awareness. The frontend SuggestionCard handles `null` replacement by hiding the replacement row and showing only the explanation + Dismiss.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## LLM Integration
|
|
||||||
|
|
||||||
### Client Config (env vars)
|
|
||||||
```
|
|
||||||
LLM_BACKEND=vllm # vllm | ollama — default: vllm
|
|
||||||
LLM_ENDPOINT=http://192.168.x.x:8000 # vLLM default port 8000, Ollama default 11434
|
|
||||||
LLM_MODEL=gemma4 # Checkpoint model — small, fast. Treat as config, never hardcode.
|
|
||||||
LLM_CHAT_MODEL=gemma4 # Ask Petal model — can be much larger. Defaults to LLM_MODEL if unset.
|
|
||||||
LLM_TIMEOUT=30s
|
|
||||||
```
|
|
||||||
|
|
||||||
**Model sizing guidance:**
|
|
||||||
Checkpoint runs every 4 seconds while the user types — latency is UX. Keep `LLM_MODEL` at 4B–7B. Gemma4 is a solid default.
|
|
||||||
|
|
||||||
Ask Petal is a deliberate conversational turn with small input context (~2,000 tokens) and short output (2–4 sentences). Since the user asks questions in Mandarin, `LLM_CHAT_MODEL` should be a Chinese-native model — **Qwen3.5 9B** is the right pick. Native Mandarin capability rather than learned multilingual coverage makes a real difference for grammar explanation quality. With 64GB VRAM you have plenty of headroom for a larger Qwen3 variant.
|
|
||||||
|
|
||||||
If `LLM_CHAT_MODEL` is unset, fall back to `LLM_MODEL`. Single-model setups work fine.
|
|
||||||
|
|
||||||
### Backend Abstraction
|
|
||||||
|
|
||||||
Define a `LLMClient` interface in `internal/llm/client.go`. All handler code calls the interface only — never a concrete type directly.
|
|
||||||
|
|
||||||
```go
|
|
||||||
// internal/llm/client.go
|
|
||||||
|
|
||||||
type Message struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CompletionRequest struct {
|
|
||||||
Messages []Message
|
|
||||||
MaxTokens int
|
|
||||||
Temperature float64
|
|
||||||
RepetitionPenalty float64
|
|
||||||
TopP float64
|
|
||||||
Stop []string
|
|
||||||
Stream bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type LLMClient interface {
|
|
||||||
// Complete returns the full response in one shot (used for checkpoint JSON)
|
|
||||||
Complete(ctx context.Context, req CompletionRequest) (string, error)
|
|
||||||
// Stream returns a channel of text chunks (used for Ask Petal SSE)
|
|
||||||
Stream(ctx context.Context, req CompletionRequest) (<-chan string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Factory — selects backend from config
|
|
||||||
func NewLLMClient(cfg *config.Config) LLMClient {
|
|
||||||
// LLM_CHAT_MODEL falls back to LLM_MODEL if unset
|
|
||||||
chatModel := cfg.LLMChatModel
|
|
||||||
if chatModel == "" {
|
|
||||||
chatModel = cfg.LLMModel
|
|
||||||
}
|
|
||||||
switch cfg.LLMBackend {
|
|
||||||
case "ollama":
|
|
||||||
return &OllamaClient{
|
|
||||||
endpoint: cfg.LLMEndpoint,
|
|
||||||
checkpointModel: cfg.LLMModel,
|
|
||||||
chatModel: chatModel,
|
|
||||||
timeout: cfg.LLMTimeout,
|
|
||||||
}
|
|
||||||
default: // "vllm"
|
|
||||||
return &VLLMClient{
|
|
||||||
endpoint: cfg.LLMEndpoint,
|
|
||||||
checkpointModel: cfg.LLMModel,
|
|
||||||
chatModel: chatModel,
|
|
||||||
timeout: cfg.LLMTimeout,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`internal/llm/vllm.go`** — OpenAI-compatible endpoint:
|
|
||||||
```
|
|
||||||
POST {endpoint}/v1/chat/completions
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"model": "{model}",
|
|
||||||
"messages": [...],
|
|
||||||
"max_tokens": {MaxTokens},
|
|
||||||
"temperature": {Temperature},
|
|
||||||
"repetition_penalty": {RepetitionPenalty},
|
|
||||||
"top_p": {TopP},
|
|
||||||
"stop": [...],
|
|
||||||
"stream": {Stream}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Streaming: SSE, parse `data:` lines, extract `choices[0].delta.content`, stop on `data: [DONE]`.
|
|
||||||
|
|
||||||
**`internal/llm/ollama.go`** — Ollama native API:
|
|
||||||
```
|
|
||||||
POST {endpoint}/api/chat
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"model": "{model}",
|
|
||||||
"messages": [...],
|
|
||||||
"stream": {Stream},
|
|
||||||
"options": {
|
|
||||||
"num_predict": {MaxTokens},
|
|
||||||
"temperature": {Temperature},
|
|
||||||
"repeat_penalty": {RepetitionPenalty},
|
|
||||||
"top_p": {TopP},
|
|
||||||
"stop": [...]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Streaming: newline-delimited JSON objects, extract `message.content`, stop when `done: true`.
|
|
||||||
|
|
||||||
**Parameter mapping cheatsheet:**
|
|
||||||
|
|
||||||
| CompletionRequest field | vLLM key | Ollama key |
|
|
||||||
|---|---|---|
|
|
||||||
| `MaxTokens` | `max_tokens` | `options.num_predict` |
|
|
||||||
| `Temperature` | `temperature` | `options.temperature` |
|
|
||||||
| `RepetitionPenalty` | `repetition_penalty` | `options.repeat_penalty` |
|
|
||||||
| `TopP` | `top_p` | `options.top_p` |
|
|
||||||
| `Stop` | `stop` | `options.stop` |
|
|
||||||
|
|
||||||
Both backends live behind the same interface — checkpoint.go and the Ask Petal handler call `client.Complete()` and `client.Stream()` with no awareness of which backend is active.
|
|
||||||
|
|
||||||
### Checkpoint Logic
|
|
||||||
- **Trigger:** user stops typing for 4 seconds (debounced in frontend via `useCheckpoint.ts`)
|
|
||||||
- **Rate limit:** backend enforces minimum 30s between checks per document
|
|
||||||
- **Payload:** full `content_text` of the document (not just the changed section — context matters for ESL)
|
|
||||||
- **Max tokens:** 1024 (suggestions are structured JSON, should be compact)
|
|
||||||
- **Visual feedback:** pulsing rose dot in StatusBar while check in flight
|
|
||||||
|
|
||||||
### System Prompt
|
|
||||||
```
|
|
||||||
You are a warm, encouraging writing assistant helping someone who speaks English as a second language.
|
|
||||||
Analyze the text below and identify up to 5 issues: grammar errors, unnatural phrasing,
|
|
||||||
incorrect idiom usage, or unclear sentences that are common ESL patterns.
|
|
||||||
|
|
||||||
Be specific, friendly, and explain WHY each suggestion improves the writing.
|
|
||||||
|
|
||||||
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
|
||||||
{
|
|
||||||
"suggestions": [
|
|
||||||
{
|
|
||||||
"original": "exact text from the document that needs fixing",
|
|
||||||
"replacement": "corrected version",
|
|
||||||
"explanation": "friendly one-sentence explanation",
|
|
||||||
"type": "grammar|phrasing|idiom|clarity"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
If the writing looks good, return: {"suggestions": []}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ask Petal: Conversational Follow-Up
|
|
||||||
|
|
||||||
When a user doesn't understand a suggestion, they tap **"Ask Petal ✨"** inside the suggestion card. This opens a mini chat panel anchored to the suggestion card (expands downward, max-height 320px, scrollable). The conversation is maintained in React state — not persisted to the DB. Closing the card clears the history.
|
|
||||||
|
|
||||||
**API route:** `POST /api/suggestions/:id/chat`
|
|
||||||
|
|
||||||
**Request payload:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"messages": [
|
|
||||||
{ "role": "user", "content": "why is this wrong?" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Server behavior:** The Go handler fetches the suggestion record (original, replacement, explanation, type) and the surrounding paragraph from the parent document's `content_text`. It injects these as system context, then appends the user's message history and streams the response back via SSE.
|
|
||||||
|
|
||||||
**Response:** SSE stream, `data:` events containing response text chunks. Frontend accumulates into assistant message bubble.
|
|
||||||
|
|
||||||
**Ask Petal System Prompt:**
|
|
||||||
```
|
|
||||||
You are Petal, a warm and patient English writing tutor helping someone who is learning English
|
|
||||||
as a second language. You are currently discussing a specific writing suggestion.
|
|
||||||
|
|
||||||
Suggestion context:
|
|
||||||
- Original text: "{{original}}"
|
|
||||||
- Suggested replacement: "{{replacement}}"
|
|
||||||
- Issue type: {{type}}
|
|
||||||
- Initial explanation: "{{explanation}}"
|
|
||||||
- Surrounding paragraph: "{{paragraph}}"
|
|
||||||
|
|
||||||
The user wants to understand this suggestion better. Detect the language of the user's message
|
|
||||||
and respond in that same language. If they write in Mandarin Chinese, respond entirely in
|
|
||||||
Mandarin. If they write in English, respond in English. Never mix languages in a single response.
|
|
||||||
|
|
||||||
Explain clearly and kindly. Use simple language appropriate to the user's message. Give examples
|
|
||||||
when helpful. If they ask "why" (or "为什么"), explain the grammar rule or idiom behind it.
|
|
||||||
If they suggest an alternative phrasing, evaluate it honestly.
|
|
||||||
|
|
||||||
Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encouraging —
|
|
||||||
learning a language is hard and they're doing great.
|
|
||||||
```
|
|
||||||
|
|
||||||
**AskPetal Component (`web/src/components/Editor/AskPetal.tsx`):**
|
|
||||||
- Renders inside the expanded SuggestionCard
|
|
||||||
- Input field at bottom, message bubbles above (Petal messages: rose-tinted left-aligned; user messages: right-aligned lavender)
|
|
||||||
- "Ask Petal ✨" trigger button is a small pill link below the suggestion explanation
|
|
||||||
- Petal's first message pre-populates with the suggestion's explanation so context is immediate
|
|
||||||
- Streaming response renders token-by-token into the latest assistant bubble
|
|
||||||
- No conversation persistence — state lives in the SuggestionCard component
|
|
||||||
|
|
||||||
### LLM Sampling Parameters & Stability
|
|
||||||
|
|
||||||
vLLM is stateless per request. There are no server-side sessions — conversation history for Ask Petal is managed entirely at the app layer (client sends full message array each request). This is correct behavior; do not attempt to implement vLLM-side session persistence.
|
|
||||||
|
|
||||||
**Checkpoint requests (structured JSON output):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model": "${LLM_MODEL}",
|
|
||||||
"messages": [...],
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"temperature": 0.3,
|
|
||||||
"repetition_penalty": 1.15,
|
|
||||||
"top_p": 0.9,
|
|
||||||
"stop": ["```", "\n\n\n\n"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ask Petal chat requests (conversational):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model": "${LLM_MODEL}",
|
|
||||||
"messages": [...],
|
|
||||||
"max_tokens": 512,
|
|
||||||
"temperature": 0.7,
|
|
||||||
"repetition_penalty": 1.15,
|
|
||||||
"top_p": 0.92,
|
|
||||||
"stop": ["\n\n\n"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why these values:**
|
|
||||||
- `temperature: 0.3` for checkpoint — low enough to produce structured JSON reliably, but not zero. Zero temperature means greedy decoding, which is paradoxically *more* prone to repetition loops in smaller models, not less.
|
|
||||||
- `temperature: 0.7` for chat — conversational range; allows natural variation without going incoherent.
|
|
||||||
- `repetition_penalty: 1.15` on both — primary defense against repetition breakdown. Applies a multiplicative penalty to already-seen tokens. 1.1–1.2 is the safe range; above 1.3 starts degrading output quality.
|
|
||||||
- `stop` sequences — catches runaway generation before it fills the output buffer. Triple newline is a reliable signal the model has looped past the end of a coherent response.
|
|
||||||
- `max_tokens: 512` for chat — enforces conciseness and prevents the model from rambling into a degraded state mid-response.
|
|
||||||
|
|
||||||
### Context Window Management
|
|
||||||
|
|
||||||
The model window is **not** the binding constraint here — Qwen 3.5 ships a 256K context window, and on a 64GB dual-GPU setup there is ample KV-cache headroom. The truncation caps below exist to protect **checkpoint latency** (prefill time scales with input length, and the grammar checkpoint must feel responsive), not memory. Documents will almost always fit uncut; the cap only fires on unusually long ones.
|
|
||||||
|
|
||||||
**Grammar checkpoint** (fast, latency-sensitive — keep input modest):
|
|
||||||
|
|
||||||
| Component | Token Budget |
|
|
||||||
|---|---|
|
|
||||||
| System prompt | ~300 |
|
|
||||||
| Document text | ~10,000 (hard cap; trailing window) |
|
|
||||||
| Response (JSON) | ~1,024 |
|
|
||||||
| Buffer | rest of window (ample) |
|
|
||||||
|
|
||||||
**Voice-consistency pass** (slow cadence — send the whole document, no trailing-window truncation; see Plagiarism Detection → Tier 1).
|
|
||||||
|
|
||||||
**Ask Petal chat:**
|
|
||||||
|
|
||||||
| Component | Token Budget |
|
|
||||||
|---|---|
|
|
||||||
| System prompt + suggestion context | ~600 |
|
|
||||||
| Conversation history (rolling) | ~3,000 |
|
|
||||||
| Latest user message | ~200 |
|
|
||||||
| Response | ~512 |
|
|
||||||
| Buffer | ~3,880 |
|
|
||||||
|
|
||||||
**Truncation logic (implement in `internal/llm/client.go`):**
|
|
||||||
|
|
||||||
```go
|
|
||||||
const maxDocChars = 40000 // ~10000 tokens at ~4 chars/token — grammar checkpoint only
|
|
||||||
const maxHistoryMsgs = 10 // 5 turns; drop oldest pairs first
|
|
||||||
|
|
||||||
// Document truncation (grammar checkpoint) — keep the recent end (user is actively writing there).
|
|
||||||
// The cap is a latency guard, not a window limit; the model window (256K) is far larger.
|
|
||||||
// The voice-consistency pass does NOT apply this truncation — it sends the full content_text.
|
|
||||||
if len(contentText) > maxDocChars {
|
|
||||||
contentText = contentText[len(contentText)-maxDocChars:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conversation history truncation — drop oldest messages first
|
|
||||||
if len(messages) > maxHistoryMsgs {
|
|
||||||
messages = messages[len(messages)-maxHistoryMsgs:]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Always log a `WARN` when truncation fires so it's visible in production. Never fail silently.
|
|
||||||
|
|
||||||
The Go handler receives the LLM suggestions (which reference `original` text strings) and records the plain-text offsets via `strings.Index(content_text, original)` as `from_pos` / `to_pos`. **These stored offsets are for server-side use only** (paragraph extraction for Ask Petal context) and are *not* ProseMirror positions — see Note #6. The frontend anchors decorations by searching the live document for the `original` string in ProseMirror coordinates at render time, not by trusting a numeric position. This is the single most important correctness detail in the suggestion pipeline; get it wrong and every multi-block document mis-renders.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend: Tiptap Configuration
|
|
||||||
|
|
||||||
### Extensions to enable
|
|
||||||
```typescript
|
|
||||||
StarterKit, // Bold, italic, headings, lists, paragraphs
|
|
||||||
Underline,
|
|
||||||
TextAlign,
|
|
||||||
Placeholder.configure({ placeholder: 'Start writing...' }),
|
|
||||||
SpellChecker, // Custom extension wrapping nspell
|
|
||||||
SuggestionMark, // Custom extension for AI suggestion decorations
|
|
||||||
CharacterCount, // For word count in StatusBar
|
|
||||||
```
|
|
||||||
|
|
||||||
### SuggestionMark Extension
|
|
||||||
Custom Tiptap mark that:
|
|
||||||
- Accepts `{ suggestionId, type }` attributes
|
|
||||||
- **Anchors by string match, not stored position** — when suggestions arrive, walk the live ProseMirror document, find the `original` text in PM coordinates, and apply the mark over that range. Do not use the server's `from_pos`/`to_pos` (those are plain-text offsets, not PM positions — see Note #6). If `original` isn't found in the current document (already edited), skip that suggestion silently.
|
|
||||||
- Renders as a colored underline (color varies by type using CSS vars)
|
|
||||||
- On hover, shows `<SuggestionCard>` positioned absolutely above the text
|
|
||||||
- SuggestionCard shows: original → replacement, explanation, Accept button, Dismiss button
|
|
||||||
- Accept → calls `PUT /api/suggestions/:id` with `status: accepted`, applies replacement via Tiptap command
|
|
||||||
- Dismiss → calls `PUT /api/suggestions/:id` with `status: rejected`, removes mark
|
|
||||||
|
|
||||||
### useAutoSave Hook
|
|
||||||
```typescript
|
|
||||||
// Debounce 1500ms after last content change
|
|
||||||
// Calls PUT /api/docs/:id with { content, content_text, word_count }
|
|
||||||
// StatusBar shows: "Saving..." → "Saved just now" → fades to nothing after 3s
|
|
||||||
```
|
|
||||||
|
|
||||||
### useCheckpoint Hook
|
|
||||||
```typescript
|
|
||||||
// Debounce 4000ms after last content change
|
|
||||||
// Fires POST /api/docs/:id/check
|
|
||||||
// On response: dispatch suggestion decorations to Tiptap editor
|
|
||||||
// Sets checkpointActive state → StatusBar indicator
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ [🌸 Petal] [New Doc] [User Avatar] │ ← Header (48px)
|
|
||||||
├──────────────┬──────────────────────────────────────────────┤
|
|
||||||
│ │ │
|
|
||||||
│ Doc List │ Editor Canvas │
|
|
||||||
│ (260px) │ (centered, max-width 720px) │
|
|
||||||
│ │ │
|
|
||||||
│ [Doc 1] │ ┌─────────────────────────────┐ │
|
|
||||||
│ [Doc 2] ← │ │ [B] [I] [U] [H1] [H2] [≡] │ │ ← Toolbar
|
|
||||||
│ [Doc 3] │ └─────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ + New │ Title (editable, large Nunito) │
|
|
||||||
│ │ │
|
|
||||||
│ │ Body text in Lora... │
|
|
||||||
│ │ ~~~~~~ suggestion underline ~~~~~~ │
|
|
||||||
│ │ │
|
|
||||||
│ │ │
|
|
||||||
├──────────────┴──────────────────────────────────────────────┤
|
|
||||||
│ 342 words · ● Checking... · Saved just now │ ← StatusBar (36px)
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Distraction-free mode: clicking into the editor collapses the doc list sidebar (slides left), expands editor canvas full width. Click outside canvas or press Escape to restore.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Config
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# .env.example
|
|
||||||
|
|
||||||
# Server
|
|
||||||
PORT=8080
|
|
||||||
BASE_URL=https://write.parodia.dev
|
|
||||||
SESSION_SECRET=change-me-to-random-64-char-string
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_PATH=/data/petal.db
|
|
||||||
|
|
||||||
# Authentik OIDC
|
|
||||||
AUTHENTIK_URL=https://auth.parodia.dev
|
|
||||||
AUTHENTIK_CLIENT_ID=petal
|
|
||||||
AUTHENTIK_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# LLM
|
|
||||||
LLM_BACKEND=vllm # vllm | ollama
|
|
||||||
LLM_ENDPOINT=http://192.168.x.x:8000
|
|
||||||
LLM_MODEL=gemma4 # Checkpoint: small and fast (4B–7B)
|
|
||||||
LLM_CHAT_MODEL=qwen3.5:9b # Ask Petal: Qwen3.5 9B recommended for native Mandarin support
|
|
||||||
LLM_TIMEOUT=30s
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dockerfile
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# Stage 1: Build frontend
|
|
||||||
FROM node:22-alpine AS frontend
|
|
||||||
WORKDIR /app/web
|
|
||||||
COPY web/package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY web/ ./
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Stage 2: Build Go binary
|
|
||||||
FROM golang:1.23-alpine AS backend
|
|
||||||
WORKDIR /app
|
|
||||||
COPY go.* ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY . .
|
|
||||||
COPY --from=frontend /app/web/dist ./web/dist
|
|
||||||
RUN CGO_ENABLED=0 go build -o petal ./cmd/server
|
|
||||||
|
|
||||||
# Stage 3: Runtime
|
|
||||||
FROM alpine:3.20
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=backend /app/petal .
|
|
||||||
VOLUME ["/data"]
|
|
||||||
EXPOSE 8080
|
|
||||||
CMD ["./petal"]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## docker-compose.yml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
petal:
|
|
||||||
image: petal:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- petal_data:/data
|
|
||||||
env_file: .env
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.petal.rule=Host(`write.parodia.dev`)"
|
|
||||||
- "traefik.http.routers.petal.entrypoints=web-secure"
|
|
||||||
- "traefik.http.routers.petal.tls.certresolver=default"
|
|
||||||
- "traefik.http.services.petal.loadbalancer.server.port=8080"
|
|
||||||
networks:
|
|
||||||
- traefik
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
petal_data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
traefik:
|
|
||||||
external: true
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1 Scope (Ship This)
|
|
||||||
|
|
||||||
- [x] Tiptap rich text editor (bold, italic, underline, headings H1/H2, ordered/unordered lists)
|
|
||||||
- [x] Document list sidebar (create, rename, delete)
|
|
||||||
- [x] Auto-save to SQLite (1.5s debounce)
|
|
||||||
- [x] LLM checkpoint every 4s idle → inline suggestion decorations
|
|
||||||
- [x] Suggestion hover card (replacement + explanation + accept/dismiss)
|
|
||||||
- [x] nspell browser-side spell check (English dictionary)
|
|
||||||
- [x] Word count + save status + checkpoint indicator in StatusBar
|
|
||||||
- [x] Authentik OIDC auth
|
|
||||||
- [x] Plagiarism check (two-tier: local voice consistency + opt-in Copyleaks academic check)
|
|
||||||
- [x] Ask Petal conversational follow-up chat on any suggestion
|
|
||||||
- [x] Distraction-free mode
|
|
||||||
- [x] Soft/bubbly pastel design system
|
|
||||||
- [x] Single Docker binary deployment to write.parodia.dev
|
|
||||||
|
|
||||||
## Out of Scope for v1
|
|
||||||
|
|
||||||
- Export to DOCX / PDF (v1.1)
|
|
||||||
- Mobile layout (desktop first)
|
|
||||||
- Real-time collaboration
|
|
||||||
- Version history / change tracking
|
|
||||||
- Multiple language support (English first)
|
|
||||||
- Offline mode
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes for Claude Code
|
|
||||||
|
|
||||||
1. **Model names are always config values** — `LLM_MODEL` drives checkpoint, `LLM_CHAT_MODEL` drives Ask Petal. Both read from env vars. Never hardcode either. If `LLM_CHAT_MODEL` is unset, fall back to `LLM_MODEL` — handle this in the factory, not in individual handlers.
|
|
||||||
2. **modernc sqlite** (`modernc.org/sqlite`) — pure Go, no cgo, no build friction.
|
|
||||||
3. **Tiptap JSON format** is stored verbatim in `documents.content`. `content_text` is the plain text extracted for the LLM — keep these in sync on every save.
|
|
||||||
4. **nspell dictionaries** — load `en-US` dictionary files from CDN or vendor them into `web/public/dictionaries/`. Don't shell out to hunspell binary.
|
|
||||||
5. **Go embeds frontend** — use `//go:embed web/dist` in `cmd/server/main.go`. Single binary deployment.
|
|
||||||
6. **Suggestion anchoring — resolve by string, not by stored position.** The `original` text string is the source of truth for where a suggestion belongs, **not** a stored numeric position. Two reasons:
|
|
||||||
- **Coordinate mismatch:** `strings.Index(contentText, original)` returns a *plain-text* character offset. Tiptap/ProseMirror positions are **not** plain-text offsets — every node boundary (paragraph, heading, list item) consumes position units, so a plaintext offset of N does not equal ProseMirror position N. Handing a plaintext offset to a Tiptap decoration places it in the wrong spot, and the error grows with each block in the document.
|
|
||||||
- **Staleness:** the user keeps typing (auto-save every 1.5s) after a checkpoint fires. Any position captured at checkpoint time is stale by the time the suggestion is stored and rendered.
|
|
||||||
|
|
||||||
**Therefore:** the frontend resolves each suggestion at *render time* by searching the live ProseMirror document for the `original` string (in PM coordinates) and applying the decoration there. The Go handler still runs `strings.Index(contentText, original)` and stores `from_pos`/`to_pos`, but these are **plain-text offsets for server-side use only** (e.g. extracting the surrounding paragraph for Ask Petal context — see Note #10), never shipped to the client as ProseMirror positions. If `original` appears multiple times, take the first occurrence; if not found at all, discard the suggestion rather than erroring. On the client, if `original` is no longer present in the live document (the user already edited that text), silently drop the suggestion — it's obsolete.
|
|
||||||
7. **Session management** — use `gorilla/sessions` with a cookie store. Session key is `petal_session`. Store `user_id` in session after OIDC callback.
|
|
||||||
8. **Authentik app** — needs to be created in Authentik with redirect URI `https://write.parodia.dev/api/auth/callback` and the OIDC client credentials added to `.env`.
|
|
||||||
9. **Ask Petal SSE streaming** — use `text/event-stream` response in Go, flush after each token chunk. Frontend uses `fetch` with `ReadableStream` (not `EventSource` — needs POST) to accumulate tokens into the assistant bubble in real time. Don't buffer the full response.
|
|
||||||
10. **Ask Petal context injection** — the Go handler for `/api/suggestions/:id/chat` fetches the suggestion + parent document in one query, extracts the paragraph containing `from_pos` from `content_text`, and injects all of it into the system prompt before the user messages. Never trust the client to send this context — always load it server-side.
|
|
||||||
11. **LLM backend abstraction** — `NewLLMClient` is the only place that references `LLM_BACKEND`. Every other package receives an `LLMClient` interface value. `checkpoint.go` and the Ask Petal handler must import only the interface, not either concrete type. This keeps adding a third backend (e.g. LM Studio) to a single new file in the future.
|
|
||||||
12. **Ollama streaming format differs from vLLM** — Ollama streams newline-delimited JSON objects (`{"message":{"content":"..."},"done":false}`), not SSE `data:` lines. The `OllamaClient.Stream()` method must handle this format; do not reuse the vLLM SSE parser for Ollama.
|
|
||||||
13. **Copyleaks async flow** — submission returns immediately with a scan ID. Results arrive via webhook, not the submission response. Store the scan ID in `plagiarism_reports` immediately on submission, update the row when the webhook fires. Frontend polls `/api/docs/:id/plagiarism/latest` every 5s while `status=pending` — don't try to make this synchronous.
|
|
||||||
14. **Copyleaks webhook HMAC** — validate the `X-Copyleaks-Signature` header on every webhook call. Reject without processing if invalid. Never skip this in production.
|
|
||||||
15. **`replacement: null` in voice suggestions** — the SuggestionCard must check for null replacement and conditionally render. Don't assume replacement is always a string.
|
|
||||||
17. **CJK font fallback in Ask Petal chat** — Nunito has no CJK coverage. The chat bubble `font-family` must include system CJK fallbacks: `'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif`. Apply this specifically to the AskPetal message bubbles, not the editor body.
|
|
||||||
18. **Mandarin detection is the model's job** — do not attempt language detection in Go or the frontend. The prompt instructs the model to detect and match. This works fine with Gemma4 which has solid Mandarin capability. If a future model swap degrades Mandarin quality, that's a prompt/model problem, not an architecture problem.
|
|
||||||
106
scripts/build_gloss.py
Normal file
106
scripts/build_gloss.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the embedded English->Chinese gloss dataset from ECDICT.
|
||||||
|
|
||||||
|
Petal shows an instant Chinese gloss when the writer (an English-as-a-second-
|
||||||
|
language user whose first language is Mandarin) hovers or right-clicks a word.
|
||||||
|
The gloss is served offline from a small map compiled into the Go binary, the
|
||||||
|
same way the English definitions/synonyms are (see internal/lexicon).
|
||||||
|
|
||||||
|
Source: ECDICT (https://github.com/skywind3000/ECDICT), MIT-licensed. We keep
|
||||||
|
only common single words that carry a Chinese translation, trim each gloss to a
|
||||||
|
couple of senses, and drop the noisy "[网络]" (internet-slang) lines — the result
|
||||||
|
gzips to a few MB, in line with the other lexicon assets.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
curl -sL https://raw.githubusercontent.com/skywind3000/ECDICT/master/ecdict.csv -o ecdict.csv
|
||||||
|
python3 scripts/build_gloss.py ecdict.csv internal/lexicon/data/gloss.json.gz
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Frequency gate: keep a word only if ECDICT ranks it in the BNC or COCA
|
||||||
|
# frequency lists (frq/bnc > 0). That bounds the asset to the words an ESL
|
||||||
|
# writer actually meets, dropping the long tail of archaic/technical headwords.
|
||||||
|
# Words below this rank but present nowhere in the frequency lists are skipped.
|
||||||
|
MAX_RANK = 50000
|
||||||
|
|
||||||
|
# A gloss is at most this many sense-lines and characters, so the hover bubble
|
||||||
|
# stays a glance, not a wall of text.
|
||||||
|
MAX_SENSES = 3
|
||||||
|
MAX_CHARS = 80
|
||||||
|
|
||||||
|
# Single English word: letters, with internal apostrophe/hyphen (so "don't",
|
||||||
|
# "well-being" survive but multi-word phrases and codes are dropped).
|
||||||
|
WORD_RE = re.compile(r"^[a-z][a-z'\-]*[a-z]$|^[a-z]$")
|
||||||
|
|
||||||
|
# Tag lines we drop from a translation: "[网络]" is crowd-sourced internet slang,
|
||||||
|
# "[俚]" slang, "[古]" archaic — none help an ESL writer pick everyday meaning.
|
||||||
|
DROP_TAG_RE = re.compile(r"^\[(网络|俚|古|罕|废)\]")
|
||||||
|
|
||||||
|
|
||||||
|
def clean_gloss(translation: str) -> str:
|
||||||
|
"""Trim an ECDICT translation to a compact, glanceable Chinese gloss."""
|
||||||
|
# ECDICT separates senses with a literal backslash-n; normalize to real
|
||||||
|
# newlines (and tolerate genuine newlines) before splitting.
|
||||||
|
translation = translation.replace("\\n", "\n")
|
||||||
|
senses = []
|
||||||
|
for line in translation.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line or DROP_TAG_RE.match(line):
|
||||||
|
continue
|
||||||
|
senses.append(line)
|
||||||
|
if len(senses) >= MAX_SENSES:
|
||||||
|
break
|
||||||
|
gloss = ";".join(senses)
|
||||||
|
if len(gloss) > MAX_CHARS:
|
||||||
|
gloss = gloss[:MAX_CHARS].rstrip(";,;, ") + "…"
|
||||||
|
return gloss
|
||||||
|
|
||||||
|
|
||||||
|
def rank(row: dict) -> int:
|
||||||
|
"""Best (lowest, non-zero) frequency rank across COCA and BNC."""
|
||||||
|
ranks = []
|
||||||
|
for key in ("frq", "bnc"):
|
||||||
|
try:
|
||||||
|
v = int(row.get(key) or 0)
|
||||||
|
except ValueError:
|
||||||
|
v = 0
|
||||||
|
if v > 0:
|
||||||
|
ranks.append(v)
|
||||||
|
return min(ranks) if ranks else 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(src: str, dst: str) -> None:
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
kept_rank: dict[str, int] = {}
|
||||||
|
with open(src, newline="", encoding="utf-8") as f:
|
||||||
|
for row in csv.DictReader(f):
|
||||||
|
word = (row.get("word") or "").strip().lower()
|
||||||
|
translation = (row.get("translation") or "").strip()
|
||||||
|
if not word or not translation or not WORD_RE.match(word):
|
||||||
|
continue
|
||||||
|
r = rank(row)
|
||||||
|
if r == 0 or r > MAX_RANK:
|
||||||
|
continue
|
||||||
|
gloss = clean_gloss(translation)
|
||||||
|
if not gloss:
|
||||||
|
continue
|
||||||
|
# On a duplicate headword keep the more frequent (lower-rank) entry.
|
||||||
|
if word in kept_rank and kept_rank[word] <= r:
|
||||||
|
continue
|
||||||
|
out[word] = gloss
|
||||||
|
kept_rank[word] = r
|
||||||
|
|
||||||
|
payload = json.dumps(out, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||||
|
with gzip.open(dst, "wb", compresslevel=9) as gz:
|
||||||
|
gz.write(payload)
|
||||||
|
print(f"{len(out)} words -> {dst} ({len(payload)} bytes json)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
sys.exit("usage: build_gloss.py <ecdict.csv> <out.json.gz>")
|
||||||
|
main(sys.argv[1], sys.argv[2])
|
||||||
165
scripts/gen_sounds.py
Normal file
165
scripts/gen_sounds.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate Petal's cute UI sound palette as small WAV assets.
|
||||||
|
|
||||||
|
These are warm, rounded little sounds — bubble pops, water drops, soft bells —
|
||||||
|
played when suggestions and companion bubbles appear. We synthesize them here
|
||||||
|
(rather than ship someone else's clips) so they match the app's gentle aesthetic
|
||||||
|
exactly and stay tiny. Output lands in web/src/assets/sounds/ where Vite bundles
|
||||||
|
and fingerprints them into dist (and so into the embedded Go binary).
|
||||||
|
|
||||||
|
Run: python3 scripts/gen_sounds.py
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import wave
|
||||||
|
|
||||||
|
SR = 44100
|
||||||
|
OUT = os.path.join(os.path.dirname(__file__), "..", "web", "src", "assets", "sounds")
|
||||||
|
|
||||||
|
|
||||||
|
def env(n, attack, decay, total):
|
||||||
|
"""Smooth attack + exponential decay envelope over `total` seconds."""
|
||||||
|
a = max(1, int(attack * SR))
|
||||||
|
out = []
|
||||||
|
for i in range(n):
|
||||||
|
t = i / SR
|
||||||
|
if i < a:
|
||||||
|
amp = i / a
|
||||||
|
else:
|
||||||
|
amp = math.exp(-(t - attack) / decay)
|
||||||
|
out.append(amp)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def sine(freq_at, n):
|
||||||
|
"""Sine with a per-sample frequency function freq_at(t)->Hz (phase-accurate)."""
|
||||||
|
out = []
|
||||||
|
phase = 0.0
|
||||||
|
for i in range(n):
|
||||||
|
t = i / SR
|
||||||
|
f = freq_at(t)
|
||||||
|
phase += 2 * math.pi * f / SR
|
||||||
|
out.append(math.sin(phase))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def mix(*layers):
|
||||||
|
n = max(len(l) for l in layers)
|
||||||
|
out = [0.0] * n
|
||||||
|
for l in layers:
|
||||||
|
for i, v in enumerate(l):
|
||||||
|
out[i] += v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def soft_clip(s):
|
||||||
|
return [math.tanh(v * 1.2) for v in s]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(s, peak=0.9):
|
||||||
|
m = max(1e-9, max(abs(v) for v in s))
|
||||||
|
g = peak / m
|
||||||
|
return [v * g for v in s]
|
||||||
|
|
||||||
|
|
||||||
|
def write_wav(name, samples):
|
||||||
|
samples = soft_clip(samples)
|
||||||
|
samples = normalize(samples, 0.85)
|
||||||
|
# 4ms fade-out tail so nothing clicks at the end.
|
||||||
|
tail = int(0.004 * SR)
|
||||||
|
for i in range(tail):
|
||||||
|
samples[-1 - i] *= i / tail
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
path = os.path.join(OUT, name)
|
||||||
|
with wave.open(path, "w") as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(SR)
|
||||||
|
frames = b"".join(struct.pack("<h", int(max(-1, min(1, v)) * 32767)) for v in samples)
|
||||||
|
w.writeframes(frames)
|
||||||
|
print(f"wrote {name} ({len(samples)/SR*1000:.0f} ms, {len(frames)} bytes)")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_env(sig, e):
|
||||||
|
return [s * a for s, a in zip(sig, e)]
|
||||||
|
|
||||||
|
|
||||||
|
def bubble_pop():
|
||||||
|
"""A cute bubble 'bloop' — pitch swoops up fast then a rounded body."""
|
||||||
|
dur = 0.16
|
||||||
|
n = int(dur * SR)
|
||||||
|
# Rising swoop is the signature of a bubble.
|
||||||
|
body = sine(lambda t: 360 + 720 * (1 - math.exp(-t * 38)), n)
|
||||||
|
body = apply_env(body, env(n, 0.004, 0.05, dur))
|
||||||
|
# A soft octave shimmer on top.
|
||||||
|
shimmer = sine(lambda t: 1080 + 400 * (1 - math.exp(-t * 38)), n)
|
||||||
|
shimmer = apply_env(shimmer, env(n, 0.003, 0.03, dur))
|
||||||
|
shimmer = [v * 0.25 for v in shimmer]
|
||||||
|
return mix(body, shimmer)
|
||||||
|
|
||||||
|
|
||||||
|
def droplet():
|
||||||
|
"""A clean little water-drop ping — high, downward, watery tail."""
|
||||||
|
dur = 0.22
|
||||||
|
n = int(dur * SR)
|
||||||
|
main = sine(lambda t: 1500 * math.exp(-t * 3.2) + 760, n)
|
||||||
|
main = apply_env(main, env(n, 0.003, 0.06, dur))
|
||||||
|
# Tiny resonant echo for a wet feel.
|
||||||
|
echo = sine(lambda t: 980, n)
|
||||||
|
echo = apply_env(echo, env(n, 0.05, 0.07, dur))
|
||||||
|
echo = [v * 0.2 for v in echo]
|
||||||
|
return mix(main, droop_delay(echo, 0.04))
|
||||||
|
|
||||||
|
|
||||||
|
def droop_delay(sig, delay_s):
|
||||||
|
d = int(delay_s * SR)
|
||||||
|
return [0.0] * d + sig
|
||||||
|
|
||||||
|
|
||||||
|
def bell(freq, dur, partials=((1, 1.0), (2.01, 0.5), (2.78, 0.28), (4.1, 0.12))):
|
||||||
|
"""A soft inharmonic bell tone (sum of slightly detuned partials)."""
|
||||||
|
n = int(dur * SR)
|
||||||
|
layers = []
|
||||||
|
for ratio, amp in partials:
|
||||||
|
s = sine(lambda t, f=freq * ratio: f, n)
|
||||||
|
s = apply_env(s, env(n, 0.005, dur * 0.45, dur))
|
||||||
|
layers.append([v * amp for v in s])
|
||||||
|
return mix(*layers)
|
||||||
|
|
||||||
|
|
||||||
|
def chime():
|
||||||
|
"""Two gentle bell notes a soft fifth apart."""
|
||||||
|
a = bell(784, 0.42) # G5
|
||||||
|
b = bell(1175, 0.40) # D6
|
||||||
|
b = droop_delay(b, 0.10)
|
||||||
|
return mix(a, [v * 0.85 for v in b])
|
||||||
|
|
||||||
|
|
||||||
|
def shimmer():
|
||||||
|
"""Three quick ascending sparkles — glockenspiel-ish."""
|
||||||
|
notes = [988, 1319, 1976] # B5, E6, B6
|
||||||
|
layers = []
|
||||||
|
for i, f in enumerate(notes):
|
||||||
|
s = bell(f, 0.30, partials=((1, 1.0), (2.7, 0.3), (5.1, 0.1)))
|
||||||
|
layers.append(droop_delay([v * 0.8 for v in s], 0.06 * i))
|
||||||
|
return mix(*layers)
|
||||||
|
|
||||||
|
|
||||||
|
def cheer():
|
||||||
|
"""A happy little major arpeggio C5-E5-G5-C6 on bells."""
|
||||||
|
notes = [523, 659, 784, 1047]
|
||||||
|
layers = []
|
||||||
|
for i, f in enumerate(notes):
|
||||||
|
s = bell(f, 0.36)
|
||||||
|
layers.append(droop_delay([v * 0.8 for v in s], 0.075 * i))
|
||||||
|
return mix(*layers)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
write_wav("pop.wav", bubble_pop())
|
||||||
|
write_wav("droplet.wav", droplet())
|
||||||
|
write_wav("chime.wav", chime())
|
||||||
|
write_wav("shimmer.wav", shimmer())
|
||||||
|
write_wav("cheer.wav", cheer())
|
||||||
|
print("done →", os.path.normpath(OUT))
|
||||||
416
web/package-lock.json
generated
416
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,9 +25,11 @@
|
|||||||
"@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",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -1175,6 +1179,13 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
|
||||||
@@ -1931,6 +1942,24 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -2007,12 +2036,135 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "4.1.9",
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"chai": "^6.2.2",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "4.1.9",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.21"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.9",
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.9",
|
||||||
|
"convert-source-map": "^2.0.0",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.40",
|
"version": "2.10.40",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||||
@@ -2081,6 +2233,16 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -2129,6 +2291,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",
|
||||||
@@ -2162,6 +2335,13 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-module-lexer": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -2226,6 +2406,26 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -2282,6 +2482,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 +2828,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,12 +2923,42 @@
|
|||||||
"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/obug": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/sxzz",
|
||||||
|
"https://opencollective.com/debug"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.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",
|
||||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3051,6 +3310,13 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -3061,6 +3327,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
|
||||||
@@ -3082,6 +3362,23 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -3099,6 +3396,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tippy.js": {
|
"node_modules/tippy.js": {
|
||||||
"version": "6.3.7",
|
"version": "6.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
|
||||||
@@ -3243,12 +3550,119 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/expect": "4.1.9",
|
||||||
|
"@vitest/mocker": "4.1.9",
|
||||||
|
"@vitest/pretty-format": "4.1.9",
|
||||||
|
"@vitest/runner": "4.1.9",
|
||||||
|
"@vitest/snapshot": "4.1.9",
|
||||||
|
"@vitest/spy": "4.1.9",
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"es-module-lexer": "^2.0.0",
|
||||||
|
"expect-type": "^1.3.0",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"std-env": "^4.0.0-rc.1",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^1.0.2",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"tinyrainbow": "^3.1.0",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
|
"@vitest/browser-playwright": "4.1.9",
|
||||||
|
"@vitest/browser-preview": "4.1.9",
|
||||||
|
"@vitest/browser-webdriverio": "4.1.9",
|
||||||
|
"@vitest/coverage-istanbul": "4.1.9",
|
||||||
|
"@vitest/coverage-v8": "4.1.9",
|
||||||
|
"@vitest/ui": "4.1.9",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-playwright": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-preview": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-webdriverio": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-istanbul": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-v8": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/w3c-keyname": {
|
"node_modules/w3c-keyname": {
|
||||||
"version": "2.2.8",
|
"version": "2.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -6,26 +6,32 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
505
web/src/App.tsx
505
web/src/App.tsx
@@ -1,45 +1,484 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, type DocSummary, type Document, type Suggestion, type Tag, type TagColor } from './api/client'
|
||||||
|
import { useAutoSave } from './hooks/useAutoSave'
|
||||||
|
import { useCheckpoint } from './hooks/useCheckpoint'
|
||||||
|
import { useSpellChecker } from './hooks/useSpellChecker'
|
||||||
|
import { useTags } from './hooks/useTags'
|
||||||
|
import { DocList } from './components/DocList/DocList'
|
||||||
|
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||||
|
import { ToneSelect } from './components/Editor/ToneSelect'
|
||||||
|
import { ExportMenu } from './components/Export/ExportMenu'
|
||||||
|
import { HistoryPanel } from './components/History/HistoryPanel'
|
||||||
|
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||||
|
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||||
|
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||||
|
import { useVersionWatch } from './hooks/useVersionWatch'
|
||||||
|
import { PetalFall } from './effects/PetalFall'
|
||||||
|
import { playSuggestionSound } from './audio/sounds'
|
||||||
|
|
||||||
// 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 updateAvailable = useVersionWatch()
|
||||||
|
const [docs, setDocs] = useState<DocSummary[]>([])
|
||||||
|
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [wordCount, setWordCount] = useState(0)
|
||||||
|
// The current document's target tone (steers checkpoint advice) and its live
|
||||||
|
// plain text (drives the expanded writing-stats panel in the StatusBar).
|
||||||
|
const [tone, setTone] = useState('general')
|
||||||
|
const [docText, setDocText] = useState('')
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
// Distraction-free mode: entered on editor focus, collapses the doc-list
|
||||||
|
// sidebar. Escape or a click outside the editor canvas restores it.
|
||||||
|
const [focusMode, setFocusMode] = useState(false)
|
||||||
|
// Mobile drawer: below the tablet breakpoint the sidebar is an overlay toggled
|
||||||
|
// by the header hamburger. Ignored on wide screens (sidebar is always in-flow).
|
||||||
|
const [drawerOpen, setDrawerOpen] = 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)
|
||||||
|
// History drawer visibility, and an epoch bumped on restore to force the
|
||||||
|
// editor to remount with the restored content (its initialContent is read
|
||||||
|
// only on mount).
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
|
const [editorEpoch, setEditorEpoch] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
// Live mirrors of the current doc's editable fields so the "discard blank
|
||||||
fetch('/api/health')
|
// drafts on navigation" logic can read the latest values without rebuilding
|
||||||
.then((r) => setApi(r.ok ? 'ok' : 'down'))
|
// callbacks on every keystroke.
|
||||||
.catch(() => setApi('down'))
|
const currentDocRef = useRef(currentDoc)
|
||||||
|
const titleRef = useRef(title)
|
||||||
|
const wordCountRef = useRef(wordCount)
|
||||||
|
currentDocRef.current = currentDoc
|
||||||
|
titleRef.current = title
|
||||||
|
wordCountRef.current = wordCount
|
||||||
|
|
||||||
|
// A throwaway blank draft: no words and still the default/empty title. We
|
||||||
|
// delete these on navigation rather than leave orphan "Untitled" docs behind.
|
||||||
|
const isBlankDraft = useCallback(() => {
|
||||||
|
const t = titleRef.current.trim()
|
||||||
|
return wordCountRef.current === 0 && (t === '' || t === 'Untitled')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
|
||||||
|
const {
|
||||||
|
suggestions,
|
||||||
|
checking,
|
||||||
|
voicing,
|
||||||
|
llmDown,
|
||||||
|
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()
|
||||||
|
// The tag roster (with counts). Assignments live on the doc summaries below.
|
||||||
|
const { tags: tagRoster, refresh: refreshTags, createTag } = useTags()
|
||||||
|
|
||||||
|
// 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)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Attach or detach a tag on a document, updating the sidebar optimistically and
|
||||||
|
// refreshing the roster so its counts stay current. Tags are kept sorted by
|
||||||
|
// name to match the server's ordering.
|
||||||
|
const setDocTag = useCallback(
|
||||||
|
async (docId: string, tag: Tag, attach: boolean) => {
|
||||||
|
setDocs((prev) =>
|
||||||
|
prev.map((d) => {
|
||||||
|
if (d.id !== docId) return d
|
||||||
|
const without = d.tags.filter((t) => t.id !== tag.id)
|
||||||
|
const next = attach ? [...without, tag] : without
|
||||||
|
next.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
||||||
|
return { ...d, tags: next }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (attach) await api.assignTag(docId, tag.id)
|
||||||
|
else await api.unassignTag(docId, tag.id)
|
||||||
|
void refreshTags()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('tag assignment failed', err)
|
||||||
|
void refreshTags()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[refreshTags],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleToggleTag = useCallback(
|
||||||
|
(docId: string, tag: Tag) => {
|
||||||
|
const doc = docs.find((d) => d.id === docId)
|
||||||
|
const has = doc?.tags.some((t) => t.id === tag.id) ?? false
|
||||||
|
void setDocTag(docId, tag, !has)
|
||||||
|
},
|
||||||
|
[docs, setDocTag],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleCreateTag = useCallback(
|
||||||
|
async (docId: string, name: string, color: TagColor) => {
|
||||||
|
const tag = await createTag(name, color)
|
||||||
|
if (tag) void setDocTag(docId, tag, true)
|
||||||
|
},
|
||||||
|
[createTag, setDocTag],
|
||||||
|
)
|
||||||
|
|
||||||
|
const openDoc = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
setDrawerOpen(false) // close the mobile drawer when a doc is chosen
|
||||||
|
// Decide the leaving doc's fate before any await mutates state.
|
||||||
|
const leaving = currentDocRef.current
|
||||||
|
const leavingBlank = isBlankDraft()
|
||||||
|
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)
|
||||||
|
setTone(doc.tone || 'general')
|
||||||
|
setDocText(doc.content_text)
|
||||||
|
// Leaving an untouched blank draft for a different doc? Drop it silently
|
||||||
|
// (best-effort cleanup — a 404 just means it's already gone).
|
||||||
|
if (leaving && leaving.id !== id && leavingBlank) {
|
||||||
|
api.deleteDoc(leaving.id).catch(() => {})
|
||||||
|
setDocs((prev) => prev.filter((d) => d.id !== leaving.id))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[saveNow, isBlankDraft],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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, tags: [] }]
|
||||||
|
setDocs(list)
|
||||||
|
setCurrentDoc(fresh)
|
||||||
|
setTitle(fresh.title)
|
||||||
|
setWordCount(0)
|
||||||
|
setTone(fresh.tone || 'general')
|
||||||
|
setDocText('')
|
||||||
|
} 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 () => {
|
||||||
|
// Already sitting on a fresh blank draft? Reuse it instead of stacking
|
||||||
|
// another empty "Untitled" on top.
|
||||||
|
if (currentDocRef.current && isBlankDraft()) return
|
||||||
|
await saveNow()
|
||||||
|
const fresh = await api.createDoc()
|
||||||
|
setDocs((prev) => [
|
||||||
|
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] },
|
||||||
|
...prev,
|
||||||
|
])
|
||||||
|
setCurrentDoc(fresh)
|
||||||
|
setTitle(fresh.title)
|
||||||
|
setWordCount(0)
|
||||||
|
setTone(fresh.tone || 'general')
|
||||||
|
setDocText('')
|
||||||
|
}, [saveNow, isBlankDraft])
|
||||||
|
|
||||||
|
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)
|
||||||
|
setTone('general')
|
||||||
|
setDocText('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
setDocText(change.content_text)
|
||||||
|
setEditTick((n) => n + 1)
|
||||||
|
if (currentDoc) {
|
||||||
|
patchSummary(currentDoc.id, { word_count: change.word_count })
|
||||||
|
schedule(change)
|
||||||
|
scheduleCheckpoint()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Changing the tone persists it and re-runs the checkpoint so Petal's advice
|
||||||
|
// re-tunes to the new register. The auto-save (1.5s) lands before the
|
||||||
|
// checkpoint debounce (4s), so the server reads the updated tone.
|
||||||
|
const handleToneChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setTone(value)
|
||||||
|
if (currentDoc) {
|
||||||
|
schedule({ tone: value })
|
||||||
|
scheduleCheckpoint()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentDoc, schedule, scheduleCheckpoint],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Accept applies the replacement in the editor (handled in EditorCore) and
|
||||||
|
// 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],
|
||||||
|
)
|
||||||
|
|
||||||
|
// After restoring a version, swap the restored doc into the editor. Bumping
|
||||||
|
// editorEpoch remounts EditorCore so it picks up the restored content.
|
||||||
|
const handleRestored = useCallback(
|
||||||
|
(doc: Document) => {
|
||||||
|
setCurrentDoc(doc)
|
||||||
|
setTitle(doc.title)
|
||||||
|
setWordCount(doc.word_count)
|
||||||
|
setTone(doc.tone || 'general')
|
||||||
|
setDocText(doc.content_text)
|
||||||
|
patchSummary(doc.id, { title: doc.title, word_count: doc.word_count })
|
||||||
|
setEditorEpoch((n) => n + 1)
|
||||||
|
},
|
||||||
|
[patchSummary],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Play a soft sound when freshly-checked suggestions arrive — one per distinct
|
||||||
|
// new type, lightly staggered so a batch reads as a little melody rather than
|
||||||
|
// a pile-up. We track which ids we've already chimed for, and only chime for
|
||||||
|
// recently-created suggestions so opening a doc with old pending advice stays
|
||||||
|
// silent (the existing set was created in a past session).
|
||||||
|
const chimedRef = useRef<Set<string>>(new Set())
|
||||||
|
useEffect(() => {
|
||||||
|
const fresh = suggestions.filter((s) => !chimedRef.current.has(s.id))
|
||||||
|
fresh.forEach((s) => chimedRef.current.add(s.id))
|
||||||
|
const justMade = fresh.filter(
|
||||||
|
(s) => Date.now() - new Date(s.created_at).getTime() < 12_000,
|
||||||
|
)
|
||||||
|
if (justMade.length === 0) return
|
||||||
|
const types = [...new Set(justMade.map((s) => s.type))].slice(0, 3)
|
||||||
|
const timers = types.map((t, i) =>
|
||||||
|
window.setTimeout(() => playSuggestionSound(t), i * 150),
|
||||||
|
)
|
||||||
|
return () => timers.forEach((id) => window.clearTimeout(id))
|
||||||
|
}, [suggestions])
|
||||||
|
|
||||||
|
// Forget chimed ids when switching documents so the set can't grow unbounded.
|
||||||
|
useEffect(() => {
|
||||||
|
chimedRef.current = new Set()
|
||||||
|
}, [currentDoc?.id])
|
||||||
|
|
||||||
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
|
<PetalFall />
|
||||||
className="flex flex-col items-center gap-3 bg-surface px-12 py-10"
|
<header
|
||||||
style={{ borderRadius: 'var(--radius-card)', boxShadow: 'var(--shadow-soft)' }}
|
onMouseDown={handleChromeDown}
|
||||||
|
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"
|
||||||
|
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
<span className="text-5xl">🌸</span>
|
<button
|
||||||
<h1 className="text-3xl font-extrabold text-plum">Petal</h1>
|
type="button"
|
||||||
<p className="max-w-xs text-muted" style={{ fontFamily: 'var(--font-body)' }}>
|
aria-label="Toggle document list"
|
||||||
A cozy place to write.
|
onClick={() => {
|
||||||
</p>
|
setFocusMode(false)
|
||||||
<span
|
setDrawerOpen((v) => !v)
|
||||||
className="mt-2 inline-flex items-center gap-2 px-4 py-1.5 text-sm font-semibold"
|
|
||||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)' }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
api === 'ok'
|
|
||||||
? 'var(--color-success)'
|
|
||||||
: api === 'down'
|
|
||||||
? 'var(--color-accent)'
|
|
||||||
: 'var(--color-muted)',
|
|
||||||
}}
|
}}
|
||||||
|
className="petal-sidebar-toggle petal-tap-sm -ml-2 mr-1 items-center justify-center text-xl"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', width: 36, height: 36, color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<span className="text-xl">🌸</span>
|
||||||
|
<span className="text-lg font-extrabold text-plum">Petal</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="relative flex min-h-0 flex-1">
|
||||||
|
<div
|
||||||
|
className={`petal-no-print petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}${drawerOpen ? ' petal-drawer-open' : ''}`}
|
||||||
|
>
|
||||||
|
<DocList
|
||||||
|
docs={docs}
|
||||||
|
roster={tagRoster}
|
||||||
|
selectedId={currentDoc?.id ?? null}
|
||||||
|
onSelect={openDoc}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onToggleTag={handleToggleTag}
|
||||||
|
onCreateTag={handleCreateTag}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`petal-no-print petal-scrim${drawerOpen ? ' petal-scrim-show' : ''}`}
|
||||||
|
onClick={() => setDrawerOpen(false)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div className="mb-5 flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => handleTitleChange(e.target.value)}
|
||||||
|
placeholder="Untitled"
|
||||||
|
aria-label="Document title"
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
|
||||||
|
style={{ fontFamily: 'var(--font-ui)' }}
|
||||||
|
/>
|
||||||
|
<div className="petal-no-print shrink-0">
|
||||||
|
<ToneSelect value={tone} onChange={handleToneChange} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHistoryOpen(true)}
|
||||||
|
aria-label="Version history"
|
||||||
|
title="Browse and restore earlier versions"
|
||||||
|
className="petal-no-print inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden>🕘</span>
|
||||||
|
<span>历史</span>
|
||||||
|
<span style={{ color: 'var(--color-muted)' }}>· History</span>
|
||||||
|
</button>
|
||||||
|
<div className="petal-no-print">
|
||||||
|
<ExportMenu docId={currentDoc.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EditorCore
|
||||||
|
key={`${currentDoc.id}:${editorEpoch}`}
|
||||||
|
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 className="petal-no-print" onMouseDown={handleChromeDown}>
|
||||||
|
<StatusBar
|
||||||
|
wordCount={wordCount}
|
||||||
|
text={docText}
|
||||||
|
saveStatus={status}
|
||||||
|
checking={checking}
|
||||||
|
voicing={voicing}
|
||||||
|
llmDown={llmDown}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{historyOpen && currentDoc && (
|
||||||
|
<HistoryPanel
|
||||||
|
docId={currentDoc.id}
|
||||||
|
onClose={() => setHistoryOpen(false)}
|
||||||
|
onRestored={handleRestored}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{updateAvailable && <UpdateBanner />}
|
||||||
|
|
||||||
|
<div className="petal-no-print">
|
||||||
|
<PetalCompanion
|
||||||
|
wordCount={wordCount}
|
||||||
|
saveStatus={status}
|
||||||
|
editTick={editTick}
|
||||||
|
acceptTick={acceptTick}
|
||||||
|
text={docText}
|
||||||
/>
|
/>
|
||||||
{api === 'checking' ? 'Checking backend…' : api === 'ok' ? 'Backend connected' : 'Backend offline'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
312
web/src/api/client.ts
Normal file
312
web/src/api/client.ts
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
// A tag's palette key, mapped to a CSS color on the frontend. Mirrors the
|
||||||
|
// backend TagColor* constants; unknown values render as rose.
|
||||||
|
export type TagColor = 'rose' | 'mint' | 'peach' | 'lavender' | 'sky' | 'honey'
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: TagColor
|
||||||
|
doc_count?: number // present only in the tag-roster listing
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocSummary {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
word_count: number
|
||||||
|
updated_at: string
|
||||||
|
tags: Tag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// One cross-document search hit. `snippet` is plain text with the matched span
|
||||||
|
// wrapped in the … sentinels (see splitSnippet) for highlighting.
|
||||||
|
export interface SearchResult {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
word_count: number
|
||||||
|
updated_at: string
|
||||||
|
snippet: string
|
||||||
|
tags: Tag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Document {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
title: string
|
||||||
|
content: string // Tiptap JSON (stringified)
|
||||||
|
content_text: string // flattened plain text
|
||||||
|
tone: string // target writing tone; steers LLM advice
|
||||||
|
word_count: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
tone?: string
|
||||||
|
word_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// One sense of a word from the offline dictionary.
|
||||||
|
export interface WordMeaning {
|
||||||
|
part_of_speech: string
|
||||||
|
definition: string
|
||||||
|
example?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// The offline lookup for one word: a Chinese gloss, a few definition senses, and
|
||||||
|
// a list of synonyms. Any of these may be empty when the word isn't a headword.
|
||||||
|
export interface WordInfo {
|
||||||
|
word: string
|
||||||
|
gloss: string // Chinese translation; '' when the word isn't in the gloss set
|
||||||
|
definitions: WordMeaning[]
|
||||||
|
synonyms: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lightweight Chinese-only gloss behind the inline hover/select tooltip.
|
||||||
|
export interface Gloss {
|
||||||
|
word: string
|
||||||
|
gloss: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
|
||||||
|
|
||||||
|
// A point-in-time snapshot of a document. List responses omit the heavy
|
||||||
|
// content/content_text fields; they arrive only on getVersion (preview/restore).
|
||||||
|
export interface DocumentVersion {
|
||||||
|
id: string
|
||||||
|
doc_id: string
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
content_text?: string
|
||||||
|
word_count: number
|
||||||
|
kind: 'auto' | 'manual' | 'pre_restore'
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downloadable export formats. PDF is handled client-side via the browser's
|
||||||
|
// print dialog (CJK-safe, no server-side font embedding).
|
||||||
|
export type ExportFormat = 'md' | 'html' | 'txt' | 'docx'
|
||||||
|
|
||||||
|
// 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' }),
|
||||||
|
|
||||||
|
// Version history. listVersions returns metadata only (no bodies); getVersion
|
||||||
|
// loads one full snapshot for preview; snapshotDoc takes an explicit restore
|
||||||
|
// point; restoreVersion copies a snapshot back onto the live doc (capturing a
|
||||||
|
// pre_restore safety copy server-side first) and returns the restored doc.
|
||||||
|
listVersions: (id: string) => req<DocumentVersion[]>(`/docs/${id}/versions`),
|
||||||
|
getVersion: (id: string, vid: string) =>
|
||||||
|
req<DocumentVersion>(`/docs/${id}/versions/${vid}`),
|
||||||
|
snapshotDoc: (id: string) =>
|
||||||
|
req<DocumentVersion>(`/docs/${id}/versions`, { method: 'POST' }),
|
||||||
|
restoreVersion: (id: string, vid: string) =>
|
||||||
|
req<Document>(`/docs/${id}/versions/${vid}/restore`, { method: 'POST' }),
|
||||||
|
|
||||||
|
// Download URL for an exported document (md/html/txt/docx). Used as an <a
|
||||||
|
// href download> target so the browser handles the file save.
|
||||||
|
exportUrl: (id: string, format: ExportFormat) =>
|
||||||
|
`/api/docs/${id}/export?format=${format}`,
|
||||||
|
|
||||||
|
// Offline word lookup (gloss + definition + synonyms) for the right-click popover.
|
||||||
|
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
|
||||||
|
// Lightweight Chinese-only gloss for the inline hover/select tooltip — instant
|
||||||
|
// and offline, so it fires on hover without spinning up the heavier lookup.
|
||||||
|
glossWord: (word: string) => req<Gloss>(`/gloss/${encodeURIComponent(word)}`),
|
||||||
|
// Tone-rewrite: rewrites a selected passage in the given style ('natural',
|
||||||
|
// 'academic', …) and returns the rewritten text for an in-editor preview. Not
|
||||||
|
// persisted — the editor applies it directly on accept.
|
||||||
|
rewriteSelection: (docId: string, text: string, style: string) =>
|
||||||
|
req<{ rewrite: string }>(`/docs/${docId}/rewrite`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ text, style }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Cross-document full-text search (title + body). Returns hits with a
|
||||||
|
// highlighted snippet. Empty query returns []. Encodes the term for the URL.
|
||||||
|
search: (q: string) => req<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`),
|
||||||
|
|
||||||
|
// Tags. listTags returns the roster with per-tag document counts; createTag is
|
||||||
|
// idempotent on name (returns the existing tag if it already exists);
|
||||||
|
// updateTag recolors/renames; deleteTag removes it (assignments cascade).
|
||||||
|
// assignTag/unassignTag link a tag to one document.
|
||||||
|
listTags: () => req<Tag[]>('/tags'),
|
||||||
|
createTag: (name: string, color: TagColor) =>
|
||||||
|
req<Tag>('/tags', { method: 'POST', body: JSON.stringify({ name, color }) }),
|
||||||
|
updateTag: (id: string, patch: { name?: string; color?: TagColor }) =>
|
||||||
|
req<Tag>(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||||
|
deleteTag: (id: string) => req<void>(`/tags/${id}`, { method: 'DELETE' }),
|
||||||
|
assignTag: (docId: string, tagId: string) =>
|
||||||
|
req<void>(`/docs/${docId}/tags`, { method: 'POST', body: JSON.stringify({ tag_id: tagId }) }),
|
||||||
|
unassignTag: (docId: string, tagId: string) =>
|
||||||
|
req<void>(`/docs/${docId}/tags/${tagId}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Current deployed build id — changes whenever a new frontend ships. The
|
||||||
|
// app polls this to offer a refresh. Bypasses any cache so the answer is live.
|
||||||
|
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sentinel characters the server wraps a search match in (\x01 … \x02). Used
|
||||||
|
// by splitSnippet to render the matched span highlighted.
|
||||||
|
export const SNIPPET_HL_START = String.fromCharCode(1)
|
||||||
|
export const SNIPPET_HL_END = String.fromCharCode(2)
|
||||||
|
|
||||||
|
// splitSnippet breaks a server snippet into ordered { text, hit } segments so the
|
||||||
|
// UI can bold the matched span(s) without dangerously setting innerHTML.
|
||||||
|
export function splitSnippet(snippet: string): { text: string; hit: boolean }[] {
|
||||||
|
const out: { text: string; hit: boolean }[] = []
|
||||||
|
let rest = snippet
|
||||||
|
for (;;) {
|
||||||
|
const start = rest.indexOf(SNIPPET_HL_START)
|
||||||
|
if (start < 0) {
|
||||||
|
if (rest) out.push({ text: rest, hit: false })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (start > 0) out.push({ text: rest.slice(0, start), hit: false })
|
||||||
|
const end = rest.indexOf(SNIPPET_HL_END, start + 1)
|
||||||
|
if (end < 0) {
|
||||||
|
// Unterminated (shouldn't happen) — emit the remainder plainly.
|
||||||
|
out.push({ text: rest.slice(start + 1), hit: false })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out.push({ text: rest.slice(start + 1, end), hit: true })
|
||||||
|
rest = rest.slice(end + 1)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagColorVar maps a tag color key to its CSS custom property. Unknown keys fall
|
||||||
|
// back to the rose accent, matching the backend's coercion.
|
||||||
|
export function tagColorVar(color: TagColor | string): string {
|
||||||
|
switch (color) {
|
||||||
|
case 'mint':
|
||||||
|
return 'var(--color-mint)'
|
||||||
|
case 'peach':
|
||||||
|
return 'var(--color-peach)'
|
||||||
|
case 'lavender':
|
||||||
|
return 'var(--color-lavender)'
|
||||||
|
case 'sky':
|
||||||
|
return 'var(--color-sky)'
|
||||||
|
case 'honey':
|
||||||
|
return 'var(--color-honey)'
|
||||||
|
default:
|
||||||
|
return 'var(--color-accent)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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') }
|
||||||
|
}
|
||||||
BIN
web/src/assets/sounds/cheer.wav
Normal file
BIN
web/src/assets/sounds/cheer.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/chime.wav
Normal file
BIN
web/src/assets/sounds/chime.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/droplet.wav
Normal file
BIN
web/src/assets/sounds/droplet.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/pop.wav
Normal file
BIN
web/src/assets/sounds/pop.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/shimmer.wav
Normal file
BIN
web/src/assets/sounds/shimmer.wav
Normal file
Binary file not shown.
174
web/src/audio/sounds.ts
Normal file
174
web/src/audio/sounds.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
// Petal's cute sound palette. The actual tones live as tiny WAV assets in
|
||||||
|
// ./assets (generated by scripts/gen_sounds.py — warm bubble pops, water drops,
|
||||||
|
// and soft bells tuned to match the app's gentle look). Vite fingerprints and
|
||||||
|
// bundles them into dist, so they ride along inside the single Go binary; here
|
||||||
|
// we just fetch + decode them once and play them through a Web Audio bus with a
|
||||||
|
// soft master volume. The whole thing is opt-out-able and the mute choice
|
||||||
|
// persists in localStorage so it survives reloads.
|
||||||
|
|
||||||
|
import popUrl from '../assets/sounds/pop.wav'
|
||||||
|
import dropletUrl from '../assets/sounds/droplet.wav'
|
||||||
|
import chimeUrl from '../assets/sounds/chime.wav'
|
||||||
|
import shimmerUrl from '../assets/sounds/shimmer.wav'
|
||||||
|
import cheerUrl from '../assets/sounds/cheer.wav'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'petal.sound'
|
||||||
|
|
||||||
|
// Master volume — deliberately gentle. These are background delights, not alerts.
|
||||||
|
const MASTER_GAIN = 0.5
|
||||||
|
|
||||||
|
export type SoundName =
|
||||||
|
| 'pop' // a soft bubble pop — the signature "something arrived"
|
||||||
|
| 'droplet' // a single rounded water-drop ping
|
||||||
|
| 'chime' // a gentle two-note bell
|
||||||
|
| 'shimmer' // three quick ascending sparkles
|
||||||
|
| 'cheer' // a happy little major arpeggio (accepts, milestones)
|
||||||
|
|
||||||
|
const SOURCES: Record<SoundName, string> = {
|
||||||
|
pop: popUrl,
|
||||||
|
droplet: dropletUrl,
|
||||||
|
chime: chimeUrl,
|
||||||
|
shimmer: shimmerUrl,
|
||||||
|
cheer: cheerUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx: AudioContext | null = null
|
||||||
|
let master: GainNode | null = null
|
||||||
|
const buffers = new Map<SoundName, AudioBuffer>()
|
||||||
|
let enabled = readEnabled()
|
||||||
|
const listeners = new Set<(on: boolean) => void>()
|
||||||
|
|
||||||
|
function readEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) !== 'off'
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSoundEnabled(): boolean {
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSoundEnabled(on: boolean): void {
|
||||||
|
enabled = on
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off')
|
||||||
|
} catch {
|
||||||
|
/* private mode — choice just won't persist */
|
||||||
|
}
|
||||||
|
listeners.forEach((fn) => fn(on))
|
||||||
|
// Touching the context on enable doubles as a user-gesture unlock + warm-up.
|
||||||
|
if (on) void ensureContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onSoundEnabledChange(fn: (on: boolean) => void): () => void {
|
||||||
|
listeners.add(fn)
|
||||||
|
return () => listeners.delete(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazily create the AudioContext (browsers require a user gesture before audio
|
||||||
|
// can play; the first play() after a click will succeed, earlier ones resume).
|
||||||
|
async function ensureContext(): Promise<AudioContext | null> {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const AC: typeof AudioContext | undefined =
|
||||||
|
window.AudioContext ??
|
||||||
|
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||||
|
if (!AC) return null
|
||||||
|
if (!ctx) {
|
||||||
|
ctx = new AC()
|
||||||
|
master = ctx.createGain()
|
||||||
|
master.gain.value = MASTER_GAIN
|
||||||
|
// A gentle soft-clip so stacked sounds never crackle.
|
||||||
|
const shaper = ctx.createWaveShaper()
|
||||||
|
shaper.curve = softClipCurve()
|
||||||
|
master.connect(shaper)
|
||||||
|
shaper.connect(ctx.destination)
|
||||||
|
}
|
||||||
|
if (ctx.state === 'suspended') {
|
||||||
|
try {
|
||||||
|
await ctx.resume()
|
||||||
|
} catch {
|
||||||
|
/* will retry on the next gesture */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
function softClipCurve(): Float32Array<ArrayBuffer> {
|
||||||
|
const n = 1024
|
||||||
|
const curve = new Float32Array(new ArrayBuffer(n * 4))
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = (i / (n - 1)) * 2 - 1
|
||||||
|
curve[i] = Math.tanh(x * 1.2)
|
||||||
|
}
|
||||||
|
return curve
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch + decode one sound, caching the decoded buffer for instant replay.
|
||||||
|
async function load(name: SoundName, audio: AudioContext): Promise<AudioBuffer | null> {
|
||||||
|
const cached = buffers.get(name)
|
||||||
|
if (cached) return cached
|
||||||
|
try {
|
||||||
|
const res = await fetch(SOURCES[name])
|
||||||
|
const bytes = await res.arrayBuffer()
|
||||||
|
const buf = await audio.decodeAudioData(bytes)
|
||||||
|
buffers.set(name, buf)
|
||||||
|
return buf
|
||||||
|
} catch {
|
||||||
|
return null // asset missing / decode failed — sound just won't play
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm the cache so the first real play is instant.
|
||||||
|
async function prime(): Promise<void> {
|
||||||
|
const audio = await ensureContext()
|
||||||
|
if (!audio) return
|
||||||
|
await Promise.all((Object.keys(SOURCES) as SoundName[]).map((n) => load(n, audio)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire a sound. No-op when muted or audio is unavailable. Never throws — a
|
||||||
|
// delight that fails should fail silently.
|
||||||
|
export function playSound(name: SoundName): void {
|
||||||
|
if (!enabled) return
|
||||||
|
void (async () => {
|
||||||
|
const audio = await ensureContext()
|
||||||
|
if (!audio || !master || audio.state !== 'running') return
|
||||||
|
const buf = await load(name, audio)
|
||||||
|
if (!buf) return
|
||||||
|
try {
|
||||||
|
const src = audio.createBufferSource()
|
||||||
|
src.buffer = buf
|
||||||
|
src.connect(master)
|
||||||
|
src.start()
|
||||||
|
} catch {
|
||||||
|
/* audio glitch — ignore */
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map an editor suggestion type to its sound, so each kind of help has its own
|
||||||
|
// little voice (grammar pops like a bubble, idioms chime, etc.).
|
||||||
|
const SUGGESTION_SOUND: Record<string, SoundName> = {
|
||||||
|
grammar: 'pop',
|
||||||
|
phrasing: 'droplet',
|
||||||
|
idiom: 'chime',
|
||||||
|
clarity: 'shimmer',
|
||||||
|
voice: 'droplet',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playSuggestionSound(type: string): void {
|
||||||
|
playSound(SUGGESTION_SOUND[type] ?? 'pop')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock + warm audio on the first user gesture so the very first sound isn't
|
||||||
|
// dropped by the browser's autoplay policy. Self-removing.
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const unlock = () => {
|
||||||
|
void prime()
|
||||||
|
window.removeEventListener('pointerdown', unlock)
|
||||||
|
window.removeEventListener('keydown', unlock)
|
||||||
|
}
|
||||||
|
window.addEventListener('pointerdown', unlock)
|
||||||
|
window.addEventListener('keydown', unlock)
|
||||||
|
}
|
||||||
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 />
|
||||||
|
}
|
||||||
201
web/src/components/Companion/PetalCompanion.tsx
Normal file
201
web/src/components/Companion/PetalCompanion.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
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
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, text }: Props) {
|
||||||
|
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
|
||||||
|
wordCount,
|
||||||
|
saveStatus,
|
||||||
|
editTick,
|
||||||
|
acceptTick,
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
// An always-asleep mascot (the cat) stays pinned to one pose so its Lottie
|
||||||
|
// never reloads as the engine flips moods underneath it.
|
||||||
|
const renderMood: Mood = companion.alwaysAsleep
|
||||||
|
? 'idle'
|
||||||
|
: mood === 'sleeping' && !hasSleepClip
|
||||||
|
? 'idle'
|
||||||
|
: mood
|
||||||
|
const napping = companion.alwaysAsleep || (mood === 'sleeping' && hasSleepClip)
|
||||||
|
|
||||||
|
// Never swap the cute companion for a bare emoji on an idle nap: fall back to
|
||||||
|
// the idle animation when a mood has no clip of its own, and only reach for
|
||||||
|
// the emoji if the companion ships no animations at all.
|
||||||
|
const animationData = companion.animations[renderMood] ?? companion.animations.idle
|
||||||
|
|
||||||
|
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}
|
||||||
|
onMouseEnter={holdBubble}
|
||||||
|
onMouseLeave={releaseBubble}
|
||||||
|
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: 144,
|
||||||
|
height: 144,
|
||||||
|
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={animationData}
|
||||||
|
className="h-32 w-32"
|
||||||
|
fallback={<span style={{ fontSize: 72, 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'
|
||||||
250
web/src/components/Companion/prose.test.ts
Normal file
250
web/src/components/Companion/prose.test.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { analyzeProse } from './prose'
|
||||||
|
|
||||||
|
// These tests pin the rules-based prose checker's behavior. For an ESL writer a
|
||||||
|
// *wrong* nudge costs trust faster than a missed one earns it, so every rule is
|
||||||
|
// tested in two directions: a true-positive case that must fire, and at least
|
||||||
|
// one near-miss "guard" case that must stay silent. Sentences are padded past
|
||||||
|
// the 8-word floor that analyzeProse needs before it offers advice.
|
||||||
|
|
||||||
|
const rulesFor = (text: string) => analyzeProse(text).map((h) => h.rule)
|
||||||
|
|
||||||
|
// Assert a given rule appears among the hints for this text.
|
||||||
|
function expectRule(text: string, rule: string) {
|
||||||
|
expect(rulesFor(text), `expected "${rule}" for: ${text}`).toContain(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert the text produces no hints at all (false-positive guard).
|
||||||
|
function expectClean(text: string) {
|
||||||
|
expect(analyzeProse(text), `expected no hints for: ${text}`).toEqual([])
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('analyzeProse — floor', () => {
|
||||||
|
it('stays quiet on very short drafts (under 8 English words)', () => {
|
||||||
|
expect(analyzeProse('he go now')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns hints highest-priority first', () => {
|
||||||
|
// This 36-word run-on also contains a lowercase "i"; run-on must lead.
|
||||||
|
const hints = analyzeProse(
|
||||||
|
'yesterday i walked all the way to the market and i bought apples and oranges and i saw my friend there and we talked and laughed and walked back home together in the warm afternoon sun.',
|
||||||
|
)
|
||||||
|
expect(hints[0].rule).toBe('runon')
|
||||||
|
expect(hints.map((h) => h.rule)).toContain('cap-i')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('run-ons', () => {
|
||||||
|
it('flags a long, comma-heavy sentence', () => {
|
||||||
|
expectRule(
|
||||||
|
'I went to the market and I bought some apples and then I saw my friend and we talked for a while and after that we went to the cafe and ordered coffee and cake together.',
|
||||||
|
'runon',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a normal sentence alone', () => {
|
||||||
|
expectClean('The garden was quiet in the morning and the rain fell softly on the leaves.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('comma splices', () => {
|
||||||
|
it('flags two independent clauses joined by a comma', () => {
|
||||||
|
expectRule('I finished the report, I sent it to my boss right away today.', 'splice')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('catches a splice with a verb not on any list', () => {
|
||||||
|
expectRule('The garden was quiet in the morning, she poured a cup of tea slowly.', 'splice')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag an introductory transition (However, …)', () => {
|
||||||
|
expectClean('However, we decided to stay home and rest for the entire weekend.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag an introductory phrase (After dinner, …)', () => {
|
||||||
|
expectClean('After dinner, I went home and read a book until I fell asleep.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag a pronoun list after a comma', () => {
|
||||||
|
expectClean('We invited my brother, you and your sister to the dinner party.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('unclear antecedents', () => {
|
||||||
|
it('flags a vague "This + verb" opener after a prior sentence', () => {
|
||||||
|
expectRule(
|
||||||
|
'We changed the whole schedule last week. This makes everyone confused about the new times.',
|
||||||
|
'antecedent',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves "This" alone when it names its noun', () => {
|
||||||
|
expectClean(
|
||||||
|
'We changed the whole schedule last week. This new schedule confuses everyone about the times.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('oxford comma', () => {
|
||||||
|
it('flags a 3-item noun list missing the serial comma', () => {
|
||||||
|
expectRule('My mother, my father and my sister came to visit us last summer.', 'oxford')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags a verb-phrase list missing the serial comma', () => {
|
||||||
|
expectRule('We cleaned the kitchen, washed the dishes and took out the trash.', 'oxford')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag a list that already has the serial comma', () => {
|
||||||
|
expectClean('I bought apples, bananas, and oranges at the store this morning.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag a compound predicate as a list', () => {
|
||||||
|
expectClean('After dinner, I went home and read a book until I fell asleep.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('a / an', () => {
|
||||||
|
it('flags "a" before a vowel sound', () => {
|
||||||
|
expectRule('She found a umbrella and a old book on the table by the window.', 'article')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags "an" before a consonant sound', () => {
|
||||||
|
expectRule('He gave me an book and an pencil to use during the long exam.', 'article')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects sound exceptions (a university, an hour)', () => {
|
||||||
|
expectClean('He is a university student and it took an hour to arrive here.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('uncountable plurals', () => {
|
||||||
|
it('flags a pluralized mass noun', () => {
|
||||||
|
expectRule('She gave me many informations about the new job opening today.', 'uncountable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the singular mass noun alone', () => {
|
||||||
|
expectClean('He shared some useful advice and helped me with my homework tonight.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('capitalization of proper nouns', () => {
|
||||||
|
it('flags lowercase languages and days', () => {
|
||||||
|
expectRule('I am learning english and french during my free time every week.', 'propercap')
|
||||||
|
expectRule('We will meet on monday to discuss the final project together soon.', 'propercap')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag ambiguous homographs (may, march)', () => {
|
||||||
|
expectClean('They may visit in the spring and march through the old town square.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves already-capitalized proper nouns alone', () => {
|
||||||
|
expectClean('I will study English and Chinese together this coming summer break here.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('subject–verb agreement', () => {
|
||||||
|
it('flags a bare verb after he/she/it', () => {
|
||||||
|
expectRule('My brother is busy but he go to the gym every single morning.', 'sva')
|
||||||
|
expectRule('She have a lovely garden full of roses behind her small house.', 'sva')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags a sentence-initial subject too', () => {
|
||||||
|
expectRule('It make me happy whenever the sun comes out after the rain.', 'sva')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag causative/perception frames (let it go, make it work)', () => {
|
||||||
|
expectClean('Please let it go and try to enjoy the rest of your day.')
|
||||||
|
expectClean('We should make it work before the big meeting tomorrow afternoon.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('plural after a number', () => {
|
||||||
|
it('flags a singular noun after a cardinal number', () => {
|
||||||
|
expectRule('I bought three apple and some bread at the market this morning.', 'plural')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag an adjective between number and noun', () => {
|
||||||
|
expectClean('We adopted two small dogs from the shelter near our apartment today.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag irregular plurals (five people)', () => {
|
||||||
|
expectClean('There were five people waiting patiently outside in the cold morning rain.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag an already-plural noun', () => {
|
||||||
|
expectClean('She has two cats and they sleep all day long on the couch.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('double determiner', () => {
|
||||||
|
it('flags a stacked article + possessive', () => {
|
||||||
|
expectRule('Please put the my book back on the shelf when you finish it.', 'doubledet')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('there is + plural', () => {
|
||||||
|
it('flags "there is" with a plural quantifier', () => {
|
||||||
|
expectRule('There is many reasons to visit the coast during the warm months.', 'thereis')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag "there is some" with a mass noun', () => {
|
||||||
|
expectClean('There is some water left in the bottle if you are thirsty now.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('its / it’s', () => {
|
||||||
|
it("flags it's used as a possessive", () => {
|
||||||
|
expectRule("The cat licked it's own paw while sitting quietly by the window.", 'its')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags its used where "it is" is meant', () => {
|
||||||
|
expectRule('I think its a wonderful idea to travel together next year sometime.', 'its')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves correct possessive "its" alone', () => {
|
||||||
|
expectClean('The dog wagged its tail happily when we came home that evening.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('than / then', () => {
|
||||||
|
it('flags a comparative followed by "then"', () => {
|
||||||
|
expectRule('This cake tastes much better then the one we made last weekend.', 'than')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves sequential "then" alone', () => {
|
||||||
|
expectClean('We ate dinner and then we watched a long movie on the sofa.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('comma after a transition', () => {
|
||||||
|
it('flags a sentence-initial transition with no comma', () => {
|
||||||
|
expectRule('However we decided to stay home and rest for the whole weekend.', 'introcomma')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag a sequence word (Then …)', () => {
|
||||||
|
expectClean('Then we walked to the park and fed the ducks by the pond.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('spacing around punctuation', () => {
|
||||||
|
it('flags a missing space after a comma', () => {
|
||||||
|
expectRule('I like apples,bananas and grapes from the little shop down the street.', 'space-after')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags a space before punctuation', () => {
|
||||||
|
expectRule('She smiled warmly , then walked away without saying a single word today.', 'space-punct')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag thousands separators in numbers', () => {
|
||||||
|
expectClean('The list had 1,000 items and cost us about 2,500 dollars in total.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('capitalization basics', () => {
|
||||||
|
it('flags a lowercase standalone "i"', () => {
|
||||||
|
expectRule('Yesterday i walked to the store and bought milk, eggs, and bread.', 'cap-i')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags a sentence that does not start with a capital', () => {
|
||||||
|
expectRule('The sun set slowly. it was a beautiful evening down by the river.', 'cap-sentence')
|
||||||
|
})
|
||||||
|
})
|
||||||
577
web/src/components/Companion/prose.ts
Normal file
577
web/src/components/Companion/prose.ts
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
// Rules-based prose checker — NO LLM. This is the companion's "smart" side: it
|
||||||
|
// reads the writer's actual text and surfaces one gentle, context-aware grammar
|
||||||
|
// or style note at a time, the way an attentive tutor leaning over her shoulder
|
||||||
|
// would. Every rule here is intentionally conservative: a wrong nudge erodes
|
||||||
|
// trust far faster than a missed one earns it, so we only speak up when a
|
||||||
|
// pattern is a high-confidence, genuinely-common English mistake.
|
||||||
|
//
|
||||||
|
// Each finding becomes a Mandarin-first bubble Line (see tips.ts), often quoting
|
||||||
|
// a short slice of her own sentence so the advice clearly belongs to *this*
|
||||||
|
// paragraph and not a generic tip jar.
|
||||||
|
|
||||||
|
import type { Line } from './tips'
|
||||||
|
|
||||||
|
export interface ProseHint extends Line {
|
||||||
|
// Stable, content-derived id so the same untouched sentence isn't re-flagged
|
||||||
|
// every cadence; the companion remembers recently-shown ids.
|
||||||
|
id: string
|
||||||
|
// Rule family, used to vary advice (don't show the same kind twice in a row).
|
||||||
|
rule: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── small text helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ENGLISH_WORD_RE = /[A-Za-z]+(?:['’-][A-Za-z]+)*/g
|
||||||
|
|
||||||
|
// Split into sentence-ish chunks (text + leading punctuation trimmed). Mirrors
|
||||||
|
// the SENTENCE_RE terminators used elsewhere, including CJK fullwidth forms.
|
||||||
|
function sentences(text: string): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
const re = /[^.!?。!?]+[.!?。!?]*/g
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = re.exec(text))) {
|
||||||
|
const s = m[0].trim()
|
||||||
|
if (s) out.push(s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// First few words of a sentence, as an anchor we can quote back to her.
|
||||||
|
function anchor(s: string, n = 6): string {
|
||||||
|
const words = s.split(/\s+/).slice(0, n).join(' ')
|
||||||
|
return words.length < s.length ? `${words}…` : words
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cheap, stable hash → short id suffix, so ids stay stable across recomputes but
|
||||||
|
// change the moment she edits the offending text.
|
||||||
|
function key(s: string): string {
|
||||||
|
return s.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 48)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── individual rules ────────────────────────────────────────────────────────
|
||||||
|
// Each rule pushes at most a couple of findings; the caller shows one at a time.
|
||||||
|
|
||||||
|
// Run-on / overly long sentences — the single most common readability problem
|
||||||
|
// for ESL writers, who often chain clauses that a period would serve better.
|
||||||
|
function runOns(text: string, out: ProseHint[]) {
|
||||||
|
let found = 0
|
||||||
|
for (const s of sentences(text)) {
|
||||||
|
if (found >= 2) break
|
||||||
|
const words = s.match(ENGLISH_WORD_RE)?.length ?? 0
|
||||||
|
const commas = (s.match(/,/g) ?? []).length
|
||||||
|
const longRun = words >= 32
|
||||||
|
const commaHeavy = words >= 24 && commas >= 2
|
||||||
|
if (longRun || commaHeavy) {
|
||||||
|
found++
|
||||||
|
out.push({
|
||||||
|
id: `runon:${key(s)}`,
|
||||||
|
rule: 'runon',
|
||||||
|
zh: '这句话有点长啦,分成两三句会更清楚 🌸',
|
||||||
|
en: `This one runs long — try splitting it: “${anchor(s)}”`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comma splice — two independent clauses joined by only a comma. We look for a
|
||||||
|
// comma immediately followed by a subject pronoun + a finite verb, which is a
|
||||||
|
// strong, low-false-positive signal of a spliced second clause.
|
||||||
|
// A comma, then a subject pronoun starting a fresh clause, then its verb. We no
|
||||||
|
// longer whitelist verbs (that lost too many real splices) — instead we take any
|
||||||
|
// word after the pronoun and reject the function words that signal it *isn't* a
|
||||||
|
// new clause (a conjunction/preposition list like “she and I”, “it of course”).
|
||||||
|
const SPLICE_RE = /[a-z]\w*,\s+(I|we|they|he|she|it|you)\s+([a-z]+)\b/g
|
||||||
|
const SPLICE_NONVERB = new Set([
|
||||||
|
'and', 'or', 'but', 'nor', 'yet', 'so', 'to', 'too', 'also', 'of', 'in', 'on',
|
||||||
|
'at', 'for', 'with', 'as', 'who', 'whom', 'which', 'that', 'than', 'then',
|
||||||
|
'will', // “…, I will, …” is usually an aside, not a clean splice
|
||||||
|
])
|
||||||
|
|
||||||
|
// First words that signal the lead is an *introductory* element (transition,
|
||||||
|
// subordinate clause, or adverbial phrase), where a following “, subject verb”
|
||||||
|
// is correct punctuation — not a splice. Guards against “However, we left.” and
|
||||||
|
// “After dinner, I went home.”
|
||||||
|
const INTRO_LEAD = new Set([
|
||||||
|
'after', 'before', 'when', 'while', 'although', 'though', 'because', 'since',
|
||||||
|
'if', 'as', 'until', 'unless', 'whenever', 'wherever', 'whereas', 'however',
|
||||||
|
'therefore', 'moreover', 'furthermore', 'nevertheless', 'nonetheless',
|
||||||
|
'meanwhile', 'consequently', 'otherwise', 'besides', 'instead', 'finally',
|
||||||
|
'first', 'firstly', 'second', 'secondly', 'next', 'then', 'later', 'eventually',
|
||||||
|
'afterwards', 'anyway', 'yesterday', 'today', 'tomorrow', 'honestly',
|
||||||
|
'actually', 'suddenly', 'sometimes', 'usually', 'often', 'also', 'still',
|
||||||
|
'now', 'here', 'there', 'well', 'yes', 'no', 'fortunately', 'unfortunately',
|
||||||
|
'surprisingly', 'clearly', 'obviously', 'basically', 'generally', 'initially',
|
||||||
|
'recently', 'currently', 'sadly', 'luckily', 'hopefully', 'frankly',
|
||||||
|
'personally', 'overall', 'soon', 'once', 'during', 'in', 'on', 'at', 'for',
|
||||||
|
'with', 'without', 'by', 'from', 'despite', 'throughout', 'given', 'regarding',
|
||||||
|
])
|
||||||
|
|
||||||
|
// A genuine comma splice has an *independent clause* before the comma. We
|
||||||
|
// approximate that by requiring the lead to be ≥3 words and not begin with an
|
||||||
|
// introductory word — short or transition-led leads are intro phrases, not
|
||||||
|
// spliced clauses. Precision over recall: a wrong nudge costs more than a miss.
|
||||||
|
function commaSplices(text: string, out: ProseHint[]) {
|
||||||
|
let found = 0
|
||||||
|
for (const s of sentences(text)) {
|
||||||
|
if (found >= 2) break
|
||||||
|
SPLICE_RE.lastIndex = 0
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = SPLICE_RE.exec(s)) && found < 2) {
|
||||||
|
const commaIdx = s.indexOf(',', m.index)
|
||||||
|
const lead = s.slice(0, commaIdx).trim()
|
||||||
|
const words = lead.split(/\s+/).filter(Boolean)
|
||||||
|
const firstWord = words[0]?.toLowerCase().replace(/[^a-z]/g, '') ?? ''
|
||||||
|
if (words.length < 3 || INTRO_LEAD.has(firstWord)) continue
|
||||||
|
if (SPLICE_NONVERB.has(m[2].toLowerCase())) continue
|
||||||
|
found++
|
||||||
|
out.push({
|
||||||
|
id: `splice:${key(m[0])}`,
|
||||||
|
rule: 'splice',
|
||||||
|
zh: '这里用逗号连了两句话,可以改成句号,或加个 “and / but”。',
|
||||||
|
en: `Two sentences joined by a comma: “…${m[0].trim()}…” — use a period or add a joining word.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unclear antecedent — a sentence that opens with This/That/These/Those riding
|
||||||
|
// straight into a verb, with no noun naming what it points back to. Only flag
|
||||||
|
// when there's a prior sentence (so an antecedent is actually in question).
|
||||||
|
const ANTECEDENT_RE =
|
||||||
|
/^(This|That|These|Those)\s+(is|are|was|were|will|would|can|could|should|makes?|made|means?|shows?|showed|gives?|gave|causes?|caused|creates?|created|leads?|led|results?|happens?|happened|helps?|helped)\b/
|
||||||
|
|
||||||
|
function antecedents(text: string, out: ProseHint[]) {
|
||||||
|
const list = sentences(text)
|
||||||
|
let found = 0
|
||||||
|
for (let i = 1; i < list.length && found < 2; i++) {
|
||||||
|
const s = list[i]
|
||||||
|
const m = s.match(ANTECEDENT_RE)
|
||||||
|
if (m) {
|
||||||
|
found++
|
||||||
|
out.push({
|
||||||
|
id: `antecedent:${key(s)}`,
|
||||||
|
rule: 'antecedent',
|
||||||
|
zh: `“${m[1]}” 指代不太清楚,最好点明它指的是什么(比如 “${m[1]} idea / change…”)。`,
|
||||||
|
en: `“${m[1]}” here is a little vague — name what it refers to.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oxford comma — a 3+ item list ending “… X and Y” with no comma before the
|
||||||
|
// conjunction. Guarded by a clause-starter stoplist so we don't mistake a
|
||||||
|
// compound clause (“…, but she and I…”) for a list.
|
||||||
|
const OXFORD_RE = /(\w+),\s+([\w'’-]+(?:\s+[\w'’-]+){0,2})\s+(and|or)\s+[\w'’-]+/g
|
||||||
|
const CLAUSE_STARTERS = new Set([
|
||||||
|
'but', 'so', 'because', 'which', 'who', 'that', 'when', 'while', 'if',
|
||||||
|
'although', 'though', 'since', 'as', 'and', 'or', 'nor', 'yet', 'then',
|
||||||
|
'however', 'whereas',
|
||||||
|
// Subject pronouns signal a clause, not a list item (“…, I went home and…”).
|
||||||
|
'i', 'you', 'he', 'she', 'it', 'we', 'they',
|
||||||
|
])
|
||||||
|
|
||||||
|
function oxford(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
let found = 0
|
||||||
|
OXFORD_RE.lastIndex = 0
|
||||||
|
while ((m = OXFORD_RE.exec(text)) && found < 1) {
|
||||||
|
const firstWord = m[2].split(/\s+/)[0]?.toLowerCase() ?? ''
|
||||||
|
if (CLAUSE_STARTERS.has(firstWord)) continue
|
||||||
|
found++
|
||||||
|
out.push({
|
||||||
|
id: `oxford:${key(m[0])}`,
|
||||||
|
rule: 'oxford',
|
||||||
|
zh: '列举三样以上时,在 “and / or” 前也加个逗号会更清楚(牛津逗号)。',
|
||||||
|
en: `In a list, a comma before “${m[3]}” keeps it clear: “a, b, ${m[3]} c”.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a vs. an — chosen by the *sound* of the next word. We work only on lowercase
|
||||||
|
// words (sidestepping proper nouns / acronyms) and keep sound-exception lists.
|
||||||
|
const A_BEFORE_VOWEL_RE = /\ba\s+([aeiou][a-z]+)\b/g
|
||||||
|
// Vowel-spelled but consonant-sounding → “a” is correct, don't flag.
|
||||||
|
const CONSONANT_SOUND_RE = /^(uni|use|usu|util|euro?|eul|ewe|once|one|ubiqu|unanim)/
|
||||||
|
const AN_BEFORE_CONSONANT_RE = /\ban\s+([b-df-hj-np-tv-z][a-z]+)\b/g
|
||||||
|
// Consonant-spelled but vowel-sounding (silent h) → “an” is correct, don't flag.
|
||||||
|
const VOWEL_SOUND_RE = /^(hour|honest|honou?r|heir|homage)/
|
||||||
|
|
||||||
|
function articles(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
let found = 0
|
||||||
|
A_BEFORE_VOWEL_RE.lastIndex = 0
|
||||||
|
while ((m = A_BEFORE_VOWEL_RE.exec(text)) && found < 1) {
|
||||||
|
if (CONSONANT_SOUND_RE.test(m[1])) continue
|
||||||
|
found++
|
||||||
|
out.push({
|
||||||
|
id: `article-an:${key(m[1])}`,
|
||||||
|
rule: 'article',
|
||||||
|
zh: `元音开头的词前用 “an”:“an ${m[1]}”。`,
|
||||||
|
en: `Before a vowel sound, use “an”: “an ${m[1]}”.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
AN_BEFORE_CONSONANT_RE.lastIndex = 0
|
||||||
|
while ((m = AN_BEFORE_CONSONANT_RE.exec(text)) && found < 2) {
|
||||||
|
if (VOWEL_SOUND_RE.test(m[1])) continue
|
||||||
|
found++
|
||||||
|
out.push({
|
||||||
|
id: `article-a:${key(m[1])}`,
|
||||||
|
rule: 'article',
|
||||||
|
zh: `辅音开头的词前用 “a”:“a ${m[1]}”。`,
|
||||||
|
en: `Before a consonant sound, use “a”: “a ${m[1]}”.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doubled word — “the the”, “is is”. Excludes the handful of doublings that can
|
||||||
|
// be legitimate (“the fact that that happened”, “she had had enough”).
|
||||||
|
const DOUBLE_RE = /\b([A-Za-z]+)\s+\1\b/gi
|
||||||
|
const LEGIT_DOUBLES = new Set(['that', 'had'])
|
||||||
|
|
||||||
|
function doubles(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
let found = 0
|
||||||
|
DOUBLE_RE.lastIndex = 0
|
||||||
|
while ((m = DOUBLE_RE.exec(text)) && found < 1) {
|
||||||
|
const w = m[1].toLowerCase()
|
||||||
|
if (LEGIT_DOUBLES.has(w)) continue
|
||||||
|
found++
|
||||||
|
out.push({
|
||||||
|
id: `double:${key(m[0])}`,
|
||||||
|
rule: 'double',
|
||||||
|
zh: `“${m[1]}” 好像写了两遍,检查一下哦。`,
|
||||||
|
en: `“${m[1]} ${m[1]}” — looks like a word got doubled.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lowercase standalone “I”. Conservative: only when bounded by spaces / line
|
||||||
|
// edge, to avoid tangling with “i.e.” and the like.
|
||||||
|
const LOWER_I_RE = /(?:^|\s)i(?=\s|$)/m
|
||||||
|
|
||||||
|
function pronounI(text: string, out: ProseHint[]) {
|
||||||
|
if (LOWER_I_RE.test(text)) {
|
||||||
|
out.push({
|
||||||
|
id: 'cap-i',
|
||||||
|
rule: 'cap-i',
|
||||||
|
zh: '英文里的 “I”(我)任何时候都要大写哦。',
|
||||||
|
en: 'In English, “I” is always written as a capital letter.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentence not starting with a capital. We look after a terminator, and ignore
|
||||||
|
// CJK sentences (she writes Mandarin too — those don't take Latin capitals).
|
||||||
|
const LOWER_START_RE = /[.!?]\s+([a-z])/
|
||||||
|
|
||||||
|
function sentenceCaps(text: string, out: ProseHint[]) {
|
||||||
|
if (LOWER_START_RE.test(text)) {
|
||||||
|
out.push({
|
||||||
|
id: 'cap-sentence',
|
||||||
|
rule: 'cap-sentence',
|
||||||
|
zh: '每个句子的开头,用大写字母开始吧。',
|
||||||
|
en: 'Start each new sentence with a capital letter.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stray space *before* punctuation (a common habit carried from CJK spacing).
|
||||||
|
const SPACE_BEFORE_PUNCT_RE = /[A-Za-z] +([,;:!?]|\.(?!\.))/
|
||||||
|
|
||||||
|
function spaceBeforePunct(text: string, out: ProseHint[]) {
|
||||||
|
if (SPACE_BEFORE_PUNCT_RE.test(text)) {
|
||||||
|
out.push({
|
||||||
|
id: 'space-punct',
|
||||||
|
rule: 'space-punct',
|
||||||
|
zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
|
||||||
|
en: 'No space before punctuation — it tucks right against the word.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chinese-L1 interference rules (Tier 1) ──────────────────────────────────
|
||||||
|
// Mandarin lacks plural inflection, articles, and verb agreement, so these
|
||||||
|
// patterns are the predictable places that grammar "leaks" into her English.
|
||||||
|
// All are closed-list or strong-signal to keep false positives near zero.
|
||||||
|
|
||||||
|
// Mass nouns that Mandarin speakers very commonly pluralize. These words are
|
||||||
|
// essentially never valid with a trailing “s”, so the list is its own guard.
|
||||||
|
const UNCOUNTABLE_PLURAL_RE =
|
||||||
|
/\b(informations|advices|knowledges|equipments|furnitures|homeworks|softwares|hardwares|luggages|baggages|sceneries|machineries)\b/i
|
||||||
|
|
||||||
|
function uncountables(text: string, out: ProseHint[]) {
|
||||||
|
const m = UNCOUNTABLE_PLURAL_RE.exec(text)
|
||||||
|
UNCOUNTABLE_PLURAL_RE.lastIndex = 0
|
||||||
|
if (m) {
|
||||||
|
const singular = m[1].replace(/s$/i, '')
|
||||||
|
out.push({
|
||||||
|
id: `uncountable:${m[1].toLowerCase()}`,
|
||||||
|
rule: 'uncountable',
|
||||||
|
zh: `“${m[1]}” 是不可数名词,不用加 s,写 “${singular}” 就好。`,
|
||||||
|
en: `“${m[1]}” is uncountable — drop the “s”: just “${singular}”.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Languages, nationalities, days, and months are proper nouns in English but
|
||||||
|
// not in Mandarin, so they're routinely left lowercase. We list only the
|
||||||
|
// unambiguous ones — “may/march/august/china/turkey/polish” are skipped because
|
||||||
|
// they're also ordinary lowercase words and would misfire.
|
||||||
|
const PROPER_NOUNS =
|
||||||
|
'monday|tuesday|wednesday|thursday|friday|saturday|sunday|' +
|
||||||
|
'january|february|april|june|july|september|october|november|december|' +
|
||||||
|
'english|chinese|mandarin|cantonese|japanese|korean|french|spanish|german|' +
|
||||||
|
'italian|russian|portuguese|arabic|vietnamese|thai|american|british|canadian|' +
|
||||||
|
'australian|mexican|brazilian|european'
|
||||||
|
// Case-sensitive (lowercase-only) so already-capitalized words aren't flagged.
|
||||||
|
const PROPER_NOUN_RE = new RegExp(`\\b(${PROPER_NOUNS})\\b`)
|
||||||
|
|
||||||
|
function properCaps(text: string, out: ProseHint[]) {
|
||||||
|
const m = PROPER_NOUN_RE.exec(text)
|
||||||
|
if (m) {
|
||||||
|
const fixed = m[1][0].toUpperCase() + m[1].slice(1)
|
||||||
|
out.push({
|
||||||
|
id: `propercap:${m[1]}`,
|
||||||
|
rule: 'propercap',
|
||||||
|
zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
|
||||||
|
en: `Languages, days, and months are capitalized in English: “${fixed}”.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third-person singular verb form, for the agreement rule's suggestion.
|
||||||
|
function third(verb: string): string {
|
||||||
|
const v = verb.toLowerCase()
|
||||||
|
const irregular: Record<string, string> = { have: 'has', do: 'does', go: 'goes' }
|
||||||
|
if (irregular[v]) return irregular[v]
|
||||||
|
if (/[^aeiou]y$/.test(v)) return v.slice(0, -1) + 'ies'
|
||||||
|
if (/(s|x|z|ch|sh)$/.test(v)) return v + 'es'
|
||||||
|
return v + 's'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject–verb agreement after he/she/it: a bare (base-form) verb where the
|
||||||
|
// third-person “-s” form is required. We skip the causative/perception frames
|
||||||
|
// (“let it go”, “make it work”, “help it grow”) where the bare verb is correct.
|
||||||
|
const SVA_BASE =
|
||||||
|
'go|have|do|make|like|want|need|know|think|say|see|feel|get|take|come|give|' +
|
||||||
|
'run|play|work|live|look|seem|become|bring|buy|find|keep|leave|tell|try|call|' +
|
||||||
|
'move|turn|put|mean|show|use|love|hope|wish|believe|understand|remember|enjoy'
|
||||||
|
// The preceding word is optional so a sentence-initial subject (“She have…”)
|
||||||
|
// still matches; when present it lets us skip causative/perception frames.
|
||||||
|
const SVA_RE = new RegExp(`(?:(\\w+)\\s+)?\\b(he|she|it)\\s+(${SVA_BASE})\\b`, 'gi')
|
||||||
|
const CAUSATIVE = new Set([
|
||||||
|
'let', 'lets', 'make', 'makes', 'made', 'help', 'helps', 'helped', 'watch',
|
||||||
|
'watches', 'watched', 'see', 'sees', 'saw', 'hear', 'heard', 'have', 'has',
|
||||||
|
'had', 'let’s', "let's",
|
||||||
|
])
|
||||||
|
|
||||||
|
function subjectVerbAgreement(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
let found = 0
|
||||||
|
SVA_RE.lastIndex = 0
|
||||||
|
while ((m = SVA_RE.exec(text)) && found < 2) {
|
||||||
|
if (m[1] && CAUSATIVE.has(m[1].toLowerCase())) continue
|
||||||
|
found++
|
||||||
|
const fixed = third(m[3])
|
||||||
|
out.push({
|
||||||
|
id: `sva:${m[2].toLowerCase()}:${m[3].toLowerCase()}`,
|
||||||
|
rule: 'sva',
|
||||||
|
zh: `主语是 he/she/it 时,动词要加 -s:“${m[2]} ${fixed}”。`,
|
||||||
|
en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singular noun after a cardinal number (“three apple”). Adjectives between the
|
||||||
|
// number and noun would misfire (“two small dogs”), so we only flag when the
|
||||||
|
// candidate is followed by a clause boundary — a verb, preposition, article, or
|
||||||
|
// punctuation — which means it's the noun itself, not a modifier.
|
||||||
|
const NUMBER_NOUN_RE =
|
||||||
|
/\b(two|three|four|five|six|seven|eight|nine|ten)\s+([a-z]{3,})\b(?=\s*(?:[.,!?;:]|$|\s(?:is|are|was|were|and|or|but|that|which|who|in|on|at|to|for|with|will|can|could|would|should|the|a|an)\b))/g
|
||||||
|
// Irregular plurals / mass nouns that are correct without an “s”.
|
||||||
|
const NON_S_PLURAL = new Set([
|
||||||
|
'people', 'children', 'men', 'women', 'fish', 'deer', 'sheep', 'feet', 'teeth',
|
||||||
|
'mice', 'geese', 'police', 'staff', 'series', 'species', 'aircraft', 'cattle',
|
||||||
|
'hundred', 'thousand', 'million', 'billion', 'dozen', 'percent',
|
||||||
|
])
|
||||||
|
|
||||||
|
function pluralAfterNumber(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
let found = 0
|
||||||
|
NUMBER_NOUN_RE.lastIndex = 0
|
||||||
|
while ((m = NUMBER_NOUN_RE.exec(text)) && found < 1) {
|
||||||
|
const noun = m[2].toLowerCase()
|
||||||
|
if (noun.endsWith('s') || NON_S_PLURAL.has(noun)) continue
|
||||||
|
found++
|
||||||
|
out.push({
|
||||||
|
id: `plural:${key(m[0])}`,
|
||||||
|
rule: 'plural',
|
||||||
|
zh: `“${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`,
|
||||||
|
en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two stacked determiners (“the my book”, “a the”) — Mandarin uses a bare
|
||||||
|
// possessive, so the article often gets stacked on top. One of the two must go.
|
||||||
|
const DOUBLE_DET_RE =
|
||||||
|
/\b(a|an|the)\s+(a|an|the|my|your|his|her|its|our|their)\b/gi
|
||||||
|
|
||||||
|
function doubleDeterminer(text: string, out: ProseHint[]) {
|
||||||
|
const m = DOUBLE_DET_RE.exec(text)
|
||||||
|
DOUBLE_DET_RE.lastIndex = 0
|
||||||
|
if (m) {
|
||||||
|
out.push({
|
||||||
|
id: `doubledet:${key(m[0])}`,
|
||||||
|
rule: 'doubledet',
|
||||||
|
zh: `“${m[1]} ${m[2]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`,
|
||||||
|
en: `“${m[1]} ${m[2]}” stacks two determiners — keep just one.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// “there is” with a plural quantifier → should be “there are”. We trigger only
|
||||||
|
// on clearly-plural cues (skip “some/any”, which are fine with singular mass
|
||||||
|
// nouns: “there is some water”).
|
||||||
|
const THERE_IS_RE =
|
||||||
|
/\bthere(?:\s+is|'s|’s)\s+(many|several|numerous|various|few|two|three|four|five|six|seven|eight|nine|ten)\b/i
|
||||||
|
|
||||||
|
function thereIsPlural(text: string, out: ProseHint[]) {
|
||||||
|
const m = THERE_IS_RE.exec(text)
|
||||||
|
THERE_IS_RE.lastIndex = 0
|
||||||
|
if (m) {
|
||||||
|
out.push({
|
||||||
|
id: `thereis:${m[1].toLowerCase()}`,
|
||||||
|
rule: 'thereis',
|
||||||
|
zh: `后面是复数时用 “there are”:“there are ${m[1]}…”。`,
|
||||||
|
en: `With a plural, use “there are”: “there are ${m[1]}…”.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── confusables (Tier 2) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// its / it's — only the two unambiguous directions: “it's own” (always its own)
|
||||||
|
// and “its a/an” (the possessive can't take an article → it's a/an).
|
||||||
|
const ITS_OWN_RE = /\bit's\s+own\b/i
|
||||||
|
const ITS_ARTICLE_RE = /\bits\s+(a|an)\b/i
|
||||||
|
|
||||||
|
function itsConfusion(text: string, out: ProseHint[]) {
|
||||||
|
if (ITS_OWN_RE.test(text)) {
|
||||||
|
out.push({
|
||||||
|
id: 'its-own',
|
||||||
|
rule: 'its',
|
||||||
|
zh: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
|
||||||
|
en: '“it’s” means “it is” — the possessive is “its”: “its own”.',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const m = ITS_ARTICLE_RE.exec(text)
|
||||||
|
ITS_ARTICLE_RE.lastIndex = 0
|
||||||
|
if (m) {
|
||||||
|
out.push({
|
||||||
|
id: 'its-article',
|
||||||
|
rule: 'its',
|
||||||
|
zh: `这里应该是 “it’s ${m[1]}”(it is),“its” 是“它的”。`,
|
||||||
|
en: `Here it should be “it’s ${m[1]}” (it is); “its” means belonging to it.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparative + “then” where “than” is meant (“better then”, “more then”). We
|
||||||
|
// use an explicit comparative list rather than a generic “-er” pattern, which
|
||||||
|
// would snag “after then”, “however”, “remember”, etc.
|
||||||
|
const COMPARATIVES =
|
||||||
|
'better|more|less|rather|worse|greater|other|older|younger|bigger|smaller|' +
|
||||||
|
'larger|faster|slower|higher|lower|cheaper|stronger|weaker|easier|harder|' +
|
||||||
|
'earlier|later|sooner|longer|shorter|taller|richer|poorer|happier|safer'
|
||||||
|
const THAN_THEN_RE = new RegExp(`\\b(${COMPARATIVES})\\s+then\\b`, 'i')
|
||||||
|
|
||||||
|
function thanThen(text: string, out: ProseHint[]) {
|
||||||
|
const m = THAN_THEN_RE.exec(text)
|
||||||
|
if (m) {
|
||||||
|
out.push({
|
||||||
|
id: `than:${m[1].toLowerCase()}`,
|
||||||
|
rule: 'than',
|
||||||
|
zh: `比较的时候用 “than”,不是 “then”:“${m[1]} than”。`,
|
||||||
|
en: `For comparisons use “than”, not “then”: “${m[1]} than”.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A transition word opening a sentence with no comma after it (“However we…”).
|
||||||
|
// Limited to conjunctive adverbs that strongly want the comma — sequence words
|
||||||
|
// like “Then/First/Finally” are left out (their comma is optional).
|
||||||
|
const INTRO_RE =
|
||||||
|
/^(However|Therefore|Moreover|Furthermore|Nevertheless|Nonetheless|Meanwhile|Consequently|In addition|In conclusion|As a result|On the other hand|For example|For instance)\s+[A-Za-z]/
|
||||||
|
|
||||||
|
function introComma(text: string, out: ProseHint[]) {
|
||||||
|
let found = false
|
||||||
|
for (const s of sentences(text)) {
|
||||||
|
const m = s.match(INTRO_RE)
|
||||||
|
if (m) {
|
||||||
|
out.push({
|
||||||
|
id: `introcomma:${m[1].toLowerCase()}`,
|
||||||
|
rule: 'introcomma',
|
||||||
|
zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`,
|
||||||
|
en: `Put a comma after the opening transition: “${m[1]}, …”.`,
|
||||||
|
})
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing space *after* a comma or sentence period (“apple,banana”, “end.Next”).
|
||||||
|
// The comma case requires letters on both sides so numbers like 1,000 are safe;
|
||||||
|
// the period case requires a real word boundary so “e.g.”/“U.S.” are skipped.
|
||||||
|
const NO_SPACE_COMMA_RE = /[A-Za-z],[A-Za-z]/
|
||||||
|
const NO_SPACE_PERIOD_RE = /[a-z]{2,}\.[A-Z][a-z]/
|
||||||
|
|
||||||
|
function spaceAfterPunct(text: string, out: ProseHint[]) {
|
||||||
|
if (NO_SPACE_COMMA_RE.test(text) || NO_SPACE_PERIOD_RE.test(text)) {
|
||||||
|
out.push({
|
||||||
|
id: 'space-after',
|
||||||
|
rule: 'space-after',
|
||||||
|
zh: '逗号、句号后面要空一格,再接下一个词。',
|
||||||
|
en: 'Add a space after a comma or period before the next word.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── orchestration ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Rules run in priority order — the ones the writer cares most about first, so
|
||||||
|
// that when several fire at once the companion leads with the weightiest note.
|
||||||
|
const RULES: Array<(text: string, out: ProseHint[]) => void> = [
|
||||||
|
runOns,
|
||||||
|
commaSplices,
|
||||||
|
antecedents,
|
||||||
|
oxford,
|
||||||
|
articles,
|
||||||
|
uncountables,
|
||||||
|
properCaps,
|
||||||
|
subjectVerbAgreement,
|
||||||
|
pluralAfterNumber,
|
||||||
|
doubleDeterminer,
|
||||||
|
thereIsPlural,
|
||||||
|
itsConfusion,
|
||||||
|
thanThen,
|
||||||
|
introComma,
|
||||||
|
doubles,
|
||||||
|
pronounI,
|
||||||
|
sentenceCaps,
|
||||||
|
spaceBeforePunct,
|
||||||
|
spaceAfterPunct,
|
||||||
|
]
|
||||||
|
|
||||||
|
// analyzeProse returns context-aware hints, highest-priority first. It bails on
|
||||||
|
// text too short to advise on (mid-thought drafts shouldn't get picked apart).
|
||||||
|
export function analyzeProse(text: string): ProseHint[] {
|
||||||
|
const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0
|
||||||
|
if (englishWords < 8) return []
|
||||||
|
const out: ProseHint[] = []
|
||||||
|
for (const rule of RULES) rule(text, out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
64
web/src/components/Companion/tips.ts
Normal file
64
web/src/components/Companion/tips.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// 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, generic writing/ESL tips — the fallback when the rules-based prose
|
||||||
|
// checker (prose.ts) finds nothing concrete to point at in her current text.
|
||||||
|
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)]
|
||||||
|
}
|
||||||
251
web/src/components/Companion/useCompanion.ts
Normal file
251
web/src/components/Companion/useCompanion.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
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'
|
||||||
|
import { analyzeProse } from './prose'
|
||||||
|
import { playSound } from '../../audio/sounds'
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// The document's plain text, used by the rules-based prose checker to offer
|
||||||
|
// context-aware writing notes instead of only generic tips.
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by
|
||||||
|
// how much there is to read, since she reads both the Mandarin and the English
|
||||||
|
// (and tips now quote a slice of her own sentence, so they run longer).
|
||||||
|
const BUBBLE_MS = 9_000 // baseline for a tip / break
|
||||||
|
const CHEER_MS = 6_000 // a short cheer still needs a beat in two languages
|
||||||
|
const READ_MS_PER_CHAR = 45 // ~22 chars/sec, generous for a bilingual read
|
||||||
|
const MAX_BUBBLE_MS = 20_000 // cap so a long quote can't pin the bubble forever
|
||||||
|
const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
|
||||||
|
const now = () => Date.now()
|
||||||
|
|
||||||
|
// Reading time for a bubble: a base floor by tone, stretched by the combined
|
||||||
|
// length of the Mandarin + English lines so denser advice stays up long enough
|
||||||
|
// to actually finish reading.
|
||||||
|
function readBubbleMs(b: Bubble): number {
|
||||||
|
const base = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS
|
||||||
|
const chars = b.zh.length + b.en.length
|
||||||
|
return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, text }: 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)
|
||||||
|
|
||||||
|
// Latest text for the prose checker, read lazily by the heartbeat (kept in a
|
||||||
|
// ref so per-keystroke changes don't re-arm the interval).
|
||||||
|
const textRef = useRef(text)
|
||||||
|
textRef.current = text
|
||||||
|
// Recently-surfaced hint ids, so the same untouched sentence isn't re-flagged
|
||||||
|
// each cadence. A small ring — old ids age out and may resurface later.
|
||||||
|
const recentHints = useRef<string[]>([])
|
||||||
|
const lastRule = useRef<string>('')
|
||||||
|
|
||||||
|
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
|
||||||
|
// A soft sound to match the bubble's mood — celebrations cheer, gentle
|
||||||
|
// cheers sparkle, break nudges chime, and tips give a little bubble pop.
|
||||||
|
playSound(
|
||||||
|
opts?.celebrate ? 'cheer' : b.tone === 'cheer' ? 'shimmer' : b.tone === 'break' ? 'chime' : 'pop',
|
||||||
|
)
|
||||||
|
setBubble(b)
|
||||||
|
setMood(opts?.celebrate ? 'celebrate' : 'talking')
|
||||||
|
clearTimeout(bubbleTimer.current)
|
||||||
|
clearTimeout(moodTimer.current)
|
||||||
|
const dur = readBubbleMs(b)
|
||||||
|
bubbleTimer.current = setTimeout(() => setBubble(null), dur)
|
||||||
|
moodTimer.current = setTimeout(() => setMood('idle'), dur)
|
||||||
|
}, [bubble])
|
||||||
|
|
||||||
|
// Choose the next spontaneous tip. Prefer a real, context-aware note from the
|
||||||
|
// rules-based prose checker (it's actionable and clearly about *her* writing);
|
||||||
|
// fall back to a generic encouragement-style tip when the text reads clean or
|
||||||
|
// every finding was shown recently. Avoids repeating a finding or the same
|
||||||
|
// rule family back-to-back so the companion never feels like a broken record.
|
||||||
|
const nextTip = useCallback((): Bubble => {
|
||||||
|
const hints = analyzeProse(textRef.current)
|
||||||
|
const fresh = hints.find(
|
||||||
|
(h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current,
|
||||||
|
)
|
||||||
|
const hint = fresh ?? hints.find((h) => !recentHints.current.includes(h.id))
|
||||||
|
if (hint) {
|
||||||
|
lastRule.current = hint.rule
|
||||||
|
recentHints.current = [hint.id, ...recentHints.current].slice(0, 8)
|
||||||
|
return { zh: hint.zh, en: hint.en, tone: 'tip' }
|
||||||
|
}
|
||||||
|
return { ...pick(TIPS), tone: 'tip' }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
clearTimeout(bubbleTimer.current)
|
||||||
|
clearTimeout(moodTimer.current)
|
||||||
|
setBubble(null)
|
||||||
|
setMood('idle')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Pause the auto-dismiss while she hovers a bubble — so a longer note never
|
||||||
|
// vanishes mid-read. Releasing leaves it up for a short, fixed grace so it
|
||||||
|
// doesn't linger forever once she looks away.
|
||||||
|
const holdBubble = useCallback(() => {
|
||||||
|
clearTimeout(bubbleTimer.current)
|
||||||
|
clearTimeout(moodTimer.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const releaseBubble = useCallback(() => {
|
||||||
|
clearTimeout(bubbleTimer.current)
|
||||||
|
clearTimeout(moodTimer.current)
|
||||||
|
bubbleTimer.current = setTimeout(() => setBubble(null), HOVER_GRACE_MS)
|
||||||
|
moodTimer.current = setTimeout(() => setMood('idle'), HOVER_GRACE_MS)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 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 — context-aware when her text gives us
|
||||||
|
// something concrete to gently point at, generic warmth otherwise.
|
||||||
|
if (t - lastTip.current > TIP_MIN_GAP) {
|
||||||
|
lastTip.current = t
|
||||||
|
say(nextTip(), { proactive: true })
|
||||||
|
}
|
||||||
|
}, 10_000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [bubble, say, nextTip])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
clearTimeout(bubbleTimer.current)
|
||||||
|
clearTimeout(moodTimer.current)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { mood, bubble, dismiss, holdBubble, releaseBubble }
|
||||||
|
}
|
||||||
97
web/src/components/DocList/DocList.tsx
Normal file
97
web/src/components/DocList/DocList.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
||||||
|
import { DocListItem } from './DocListItem'
|
||||||
|
import { SearchBox } from './SearchBox'
|
||||||
|
import { TagChip } from './TagChip'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
docs: DocSummary[]
|
||||||
|
roster: Tag[]
|
||||||
|
selectedId: string | null
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
onCreate: () => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
onToggleTag: (docId: string, tag: Tag) => void
|
||||||
|
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocList is the sidebar: a cross-document search box, a tag filter bar, the New
|
||||||
|
// button, and the document browser (each row showing its tag chips).
|
||||||
|
export function DocList({
|
||||||
|
docs,
|
||||||
|
roster,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
onToggleTag,
|
||||||
|
onCreateTag,
|
||||||
|
}: Props) {
|
||||||
|
// Active tag filter (null = show all). Cleared automatically if the tag
|
||||||
|
// disappears from the roster.
|
||||||
|
const [filterId, setFilterId] = useState<string | null>(null)
|
||||||
|
const activeFilter = filterId && roster.some((t) => t.id === filterId) ? filterId : null
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => (activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs),
|
||||||
|
[docs, activeFilter],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only surface tags that are actually in use, so the filter bar stays tidy.
|
||||||
|
const usedTags = useMemo(() => roster.filter((t) => (t.doc_count ?? 0) > 0), [roster])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="flex h-full w-[280px] flex-col gap-2 p-3"
|
||||||
|
style={{ borderRight: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
<SearchBox onSelect={onSelect} />
|
||||||
|
|
||||||
|
{usedTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 pb-0.5">
|
||||||
|
{usedTags.map((t) => (
|
||||||
|
<TagChip
|
||||||
|
key={t.id}
|
||||||
|
tag={t}
|
||||||
|
count={t.doc_count}
|
||||||
|
active={activeFilter === t.id}
|
||||||
|
onClick={() => setFilterId((cur) => (cur === t.id ? null : t.id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCreate}
|
||||||
|
className="petal-tap flex items-center justify-center gap-2 text-sm font-bold text-white"
|
||||||
|
style={{ minHeight: 44, 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">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{activeFilter ? 'No documents with this tag.' : 'No documents yet.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filtered.map((doc) => (
|
||||||
|
<DocListItem
|
||||||
|
key={doc.id}
|
||||||
|
doc={doc}
|
||||||
|
active={doc.id === selectedId}
|
||||||
|
roster={roster}
|
||||||
|
onSelect={() => onSelect(doc.id)}
|
||||||
|
onDelete={() => onDelete(doc.id)}
|
||||||
|
onToggleTag={onToggleTag}
|
||||||
|
onCreateTag={onCreateTag}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
web/src/components/DocList/DocListItem.tsx
Normal file
101
web/src/components/DocList/DocListItem.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
||||||
|
import { TagChip } from './TagChip'
|
||||||
|
import { TagPicker } from './TagPicker'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
doc: DocSummary
|
||||||
|
active: boolean
|
||||||
|
roster: Tag[]
|
||||||
|
onSelect: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onToggleTag: (docId: string, tag: Tag) => void
|
||||||
|
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row in the sidebar: title + word count, its tag chips, a tag affordance and
|
||||||
|
// a delete affordance on hover, and a rose wash when it's the open document.
|
||||||
|
export function DocListItem({
|
||||||
|
doc,
|
||||||
|
active,
|
||||||
|
roster,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
onToggleTag,
|
||||||
|
onCreateTag,
|
||||||
|
}: Props) {
|
||||||
|
const [picking, setPicking] = useState(false)
|
||||||
|
const assignedIds = new Set(doc.tags.map((t) => t.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onSelect}
|
||||||
|
className="group relative flex cursor-pointer flex-col gap-1 px-3 py-2.5"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
background: active ? 'var(--color-surface)' : 'transparent',
|
||||||
|
boxShadow: active ? 'var(--shadow-soft)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Tag + delete affordances: always reachable on touch (no hover), fade in
|
||||||
|
on hover for the pointer experience. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Tag document"
|
||||||
|
title="Tags"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setPicking((v) => !v)
|
||||||
|
}}
|
||||||
|
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-sm"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
🏷️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Delete document"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDelete()
|
||||||
|
}}
|
||||||
|
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-base"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{picking && (
|
||||||
|
<TagPicker
|
||||||
|
roster={roster}
|
||||||
|
assignedIds={assignedIds}
|
||||||
|
onToggle={(t) => onToggleTag(doc.id, t)}
|
||||||
|
onCreate={(name, color) => onCreateTag(doc.id, name, color)}
|
||||||
|
onClose={() => setPicking(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{doc.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{doc.tags.map((t) => (
|
||||||
|
<TagChip key={t.id} tag={t} onRemove={() => onToggleTag(doc.id, t)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
web/src/components/DocList/SearchBox.tsx
Normal file
145
web/src/components/DocList/SearchBox.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, splitSnippet, type SearchResult } from '../../api/client'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Called when a result is chosen — opens that document.
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 220
|
||||||
|
|
||||||
|
// SearchBox is the cross-document finder at the top of the sidebar. Typing
|
||||||
|
// debounces a full-text query; results drop in below with a highlighted snippet.
|
||||||
|
// Clearing (× or empty) returns the sidebar to the normal document list.
|
||||||
|
export function SearchBox({ onSelect }: Props) {
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [results, setResults] = useState<SearchResult[] | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const runRef = useRef(0)
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
const term = q.trim()
|
||||||
|
if (!term) {
|
||||||
|
setResults(null)
|
||||||
|
setBusy(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
const run = ++runRef.current
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const hits = await api.search(term)
|
||||||
|
if (run === runRef.current) setResults(hits)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('search failed', err)
|
||||||
|
if (run === runRef.current) setResults([])
|
||||||
|
} finally {
|
||||||
|
if (run === runRef.current) setBusy(false)
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS)
|
||||||
|
return () => clearTimeout(debounceRef.current)
|
||||||
|
}, [q])
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
setQ('')
|
||||||
|
setResults(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="relative">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm"
|
||||||
|
style={{ color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
🔍
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="搜索 · Search"
|
||||||
|
aria-label="Search documents"
|
||||||
|
className="petal-tap w-full bg-transparent pl-9 pr-8 text-sm focus:outline-none"
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{q && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Clear search"
|
||||||
|
onClick={clear}
|
||||||
|
className="absolute right-2 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results !== null && (
|
||||||
|
<div className="petal-search-results flex flex-col gap-0.5">
|
||||||
|
{busy && results.length === 0 ? (
|
||||||
|
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
查找中… · Searching…
|
||||||
|
</p>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
没有找到 · No matches
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
results.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(r.id)}
|
||||||
|
className="petal-tap flex flex-col items-start gap-0.5 px-3 py-2 text-left"
|
||||||
|
style={{ borderRadius: 'var(--radius-card)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-full truncate text-sm font-bold"
|
||||||
|
style={{ color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
{r.title || 'Untitled'}
|
||||||
|
</span>
|
||||||
|
{r.snippet && (
|
||||||
|
<span
|
||||||
|
className="line-clamp-2 text-xs"
|
||||||
|
style={{ color: 'var(--color-muted)', lineHeight: 1.4 }}
|
||||||
|
>
|
||||||
|
{splitSnippet(r.snippet).map((seg, i) =>
|
||||||
|
seg.hit ? (
|
||||||
|
<mark
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
background: 'color-mix(in srgb, var(--color-accent) 35%, white)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
borderRadius: 3,
|
||||||
|
padding: '0 1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{seg.text}
|
||||||
|
</mark>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{seg.text}</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
web/src/components/DocList/TagChip.tsx
Normal file
75
web/src/components/DocList/TagChip.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { tagColorVar, type Tag } from '../../api/client'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tag: Tag
|
||||||
|
// When set, renders an interactive chip (filter toggle); `active` fills it.
|
||||||
|
onClick?: () => void
|
||||||
|
active?: boolean
|
||||||
|
// When set, shows a small × to remove the tag (used on document rows).
|
||||||
|
onRemove?: () => void
|
||||||
|
// Optional trailing count (used in the filter bar).
|
||||||
|
count?: number
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagChip is the pill used everywhere a tag appears: on document rows, in the
|
||||||
|
// filter bar, and in the assign popover. The palette color tints a soft wash; a
|
||||||
|
// filled variant marks an active filter. Keeps tap targets comfortable on touch.
|
||||||
|
export function TagChip({ tag, onClick, active, onRemove, count, size = 'sm' }: Props) {
|
||||||
|
const color = tagColorVar(tag.color)
|
||||||
|
const interactive = !!onClick
|
||||||
|
const pad = size === 'md' ? '0.3rem 0.7rem' : '0.12rem 0.5rem'
|
||||||
|
const font = size === 'md' ? '0.8rem' : '0.7rem'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
onClick={
|
||||||
|
onClick
|
||||||
|
? (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={`petal-tag-chip inline-flex items-center gap-1 whitespace-nowrap font-bold${interactive ? ' cursor-pointer' : ''}`}
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
padding: pad,
|
||||||
|
fontSize: font,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
background: active ? color : 'color-mix(in srgb, ' + color + ' 22%, white)',
|
||||||
|
color: active ? 'white' : 'var(--color-plum)',
|
||||||
|
border: `1px solid ${active ? color : 'color-mix(in srgb, ' + color + ' 45%, white)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block shrink-0"
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
background: active ? 'white' : color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{tag.name}
|
||||||
|
{typeof count === 'number' && (
|
||||||
|
<span style={{ opacity: 0.7, fontWeight: 700 }}>{count}</span>
|
||||||
|
)}
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove ${tag.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemove()
|
||||||
|
}}
|
||||||
|
className="ml-0.5 inline-flex items-center justify-center"
|
||||||
|
style={{ color: 'inherit', opacity: 0.65, fontSize: '0.85em', lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
web/src/components/DocList/TagPicker.tsx
Normal file
139
web/src/components/DocList/TagPicker.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { tagColorVar, type Tag, type TagColor } from '../../api/client'
|
||||||
|
|
||||||
|
const COLORS: TagColor[] = ['rose', 'mint', 'peach', 'lavender', 'sky', 'honey']
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
roster: Tag[]
|
||||||
|
assignedIds: Set<string>
|
||||||
|
onToggle: (tag: Tag) => void
|
||||||
|
onCreate: (name: string, color: TagColor) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagPicker is the small popover for managing one document's tags: tap an
|
||||||
|
// existing tag to attach/detach it, or type a new name (with a color swatch) to
|
||||||
|
// create-and-attach. Closes on outside click or Escape.
|
||||||
|
export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }: Props) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [color, setColor] = useState<TagColor>('rose')
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDown = (e: PointerEvent) => {
|
||||||
|
if (!ref.current?.contains(e.target as Node)) onClose()
|
||||||
|
}
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
// Defer so the opening click doesn't immediately close it.
|
||||||
|
const id = setTimeout(() => document.addEventListener('pointerdown', onDown), 0)
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
clearTimeout(id)
|
||||||
|
document.removeEventListener('pointerdown', onDown)
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const n = name.trim()
|
||||||
|
if (!n) return
|
||||||
|
onCreate(n, color)
|
||||||
|
setName('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="petal-tag-picker absolute z-20 flex w-60 flex-col gap-2 p-3"
|
||||||
|
style={{
|
||||||
|
top: 'calc(100% + 4px)',
|
||||||
|
right: 0,
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
标签 · Tags
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{roster.length > 0 && (
|
||||||
|
<div className="flex max-h-40 flex-wrap gap-1.5 overflow-y-auto">
|
||||||
|
{roster.map((t) => {
|
||||||
|
const on = assignedIds.has(t.id)
|
||||||
|
const c = tagColorVar(t.color)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(t)}
|
||||||
|
className="petal-tap inline-flex items-center gap-1 whitespace-nowrap text-xs font-bold"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
padding: '0.25rem 0.6rem',
|
||||||
|
background: on ? c : 'color-mix(in srgb, ' + c + ' 18%, white)',
|
||||||
|
color: on ? 'white' : 'var(--color-plum)',
|
||||||
|
border: `1px solid ${on ? c : 'color-mix(in srgb, ' + c + ' 40%, white)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{on && <span aria-hidden>✓</span>}
|
||||||
|
{t.name}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5 pt-1" style={{ borderTop: '1px solid var(--color-border)' }}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Color ${c}`}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className="petal-tap-sm"
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
background: tagColorVar(c),
|
||||||
|
border: color === c ? '2px solid var(--color-plum)' : '2px solid transparent',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') submit()
|
||||||
|
}}
|
||||||
|
placeholder="新标签 · New tag"
|
||||||
|
aria-label="New tag name"
|
||||||
|
className="petal-tap min-w-0 flex-1 bg-transparent px-2.5 text-sm focus:outline-none"
|
||||||
|
style={{
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 'var(--radius-input)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
className="petal-tap-sm shrink-0 px-3 text-sm font-bold text-white"
|
||||||
|
style={{ height: 34, borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
726
web/src/components/Editor/EditorCore.tsx
Normal file
726
web/src/components/Editor/EditorCore.tsx
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
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 { WordCard } from './WordCard'
|
||||||
|
import { GlossTip } from './GlossTip'
|
||||||
|
import { SelectionBubble } from './SelectionBubble'
|
||||||
|
import { RewritePreview, type RewriteStatus } from './RewritePreview'
|
||||||
|
import { api, type Suggestion, type WordInfo } 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// The open right-click word popover (definition + synonyms), or null. `info` is
|
||||||
|
// null while the offline lookup is in flight (`loading`); the card shows a
|
||||||
|
// looking-up state until it resolves.
|
||||||
|
interface WordInfoState {
|
||||||
|
word: string
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
loading: boolean
|
||||||
|
info: WordInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
|
||||||
|
// spray up-and-out from a point; each reads its direction from --dx/--dy.
|
||||||
|
const CONFETTI_DOTS = [
|
||||||
|
{ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// The inline hover gloss: the word under the resting pointer, its Chinese
|
||||||
|
// translation, the PM range it covers (to dedupe re-fetches), and where to anchor.
|
||||||
|
interface GlossState {
|
||||||
|
word: string
|
||||||
|
gloss: string
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-empty text selection that the rewrite bubble hovers over.
|
||||||
|
interface SelectionState {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
text: string
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// An in-flight or completed tone-rewrite preview, pinned over the selection it
|
||||||
|
// came from. `from`/`to` are the PM range the accepted rewrite replaces.
|
||||||
|
interface RewriteState {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
original: string
|
||||||
|
style: string
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
status: RewriteStatus
|
||||||
|
rewrite: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
// The open right-click word popover (definition + synonyms), or null.
|
||||||
|
const [wordInfo, setWordInfo] = useState<WordInfoState | null>(null)
|
||||||
|
// Token to discard a word lookup whose popover has since closed/changed.
|
||||||
|
const wordReqRef = useRef(0)
|
||||||
|
// Transient confetti burst played at the last accept location.
|
||||||
|
const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
|
||||||
|
const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
// 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)
|
||||||
|
// Inline hover gloss (rest the pointer on a word → its Chinese meaning).
|
||||||
|
const [gloss, setGloss] = useState<GlossState | null>(null)
|
||||||
|
const glossTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
const glossReqRef = useRef(0)
|
||||||
|
// The rewrite affordances: a bubble over the current selection, and the
|
||||||
|
// preview that replaces it once a style is chosen.
|
||||||
|
const [selection, setSelection] = useState<SelectionState | null>(null)
|
||||||
|
const [rewrite, setRewrite] = useState<RewriteState | null>(null)
|
||||||
|
const rewriteReqRef = useRef(0)
|
||||||
|
|
||||||
|
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 popover anchors.
|
||||||
|
setMisspell(null)
|
||||||
|
setWordInfo(null)
|
||||||
|
setGloss(null)
|
||||||
|
setSelection(null)
|
||||||
|
// A stale rewrite preview points at a range that just moved — drop it.
|
||||||
|
rewriteReqRef.current++
|
||||||
|
setRewrite(null)
|
||||||
|
onChange({
|
||||||
|
content: JSON.stringify(editor.getJSON()),
|
||||||
|
content_text: editor.getText(),
|
||||||
|
word_count: editor.storage.characterCount.words(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSelectionUpdate: ({ editor }) => {
|
||||||
|
// A selection supersedes the hover gloss; an empty one clears the bubble.
|
||||||
|
setGloss(null)
|
||||||
|
const { from, to, empty } = editor.state.selection
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (empty || !wrapper) {
|
||||||
|
setSelection(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = editor.state.doc.textBetween(from, to, ' ').trim()
|
||||||
|
if (!text) {
|
||||||
|
setSelection(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = editor.view.coordsAtPos(from)
|
||||||
|
const wrapRect = wrapper.getBoundingClientRect()
|
||||||
|
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 360))
|
||||||
|
const top = start.top - wrapRect.top
|
||||||
|
setSelection({ from, to, text, top, left })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
setWordInfo(null)
|
||||||
|
setGloss(null)
|
||||||
|
setSelection(null)
|
||||||
|
rewriteReqRef.current++
|
||||||
|
setRewrite(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
|
||||||
|
// A suggestion card and the word popover shouldn't stack.
|
||||||
|
setWordInfo(null)
|
||||||
|
setHover((prev) => {
|
||||||
|
// Moving to a different highlight resets any Ask Petal pin.
|
||||||
|
if (prev && prev.suggestion.id !== suggestion.id) setPinned(false)
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// Tapping an AI-suggestion highlight also opens its card here — on touch there's
|
||||||
|
// no hover, so the tap is the only way in (mouse users still get hover).
|
||||||
|
const handleSpellClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
|
||||||
|
if (suggestionEl) {
|
||||||
|
const id = suggestionEl.getAttribute('data-suggestion-id')
|
||||||
|
if (id) openCardFor(id, suggestionEl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
setWordInfo(null)
|
||||||
|
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||||
|
},
|
||||||
|
[editor, spellChecker, closeCard, openCardFor],
|
||||||
|
)
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
// Right-click a word to look it up: resolve the exact word span under the
|
||||||
|
// pointer, anchor a popover beneath it, and kick off the offline lookup. The
|
||||||
|
// card opens immediately in a loading state and fills in when the (local)
|
||||||
|
// lookup returns. Right-clicking off any word falls through to the native menu.
|
||||||
|
const handleContextMenu = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!editor) return
|
||||||
|
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||||
|
if (!coords) return
|
||||||
|
const range = wordAt(editor.state.doc, coords.pos)
|
||||||
|
if (!range) return
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper) return
|
||||||
|
e.preventDefault()
|
||||||
|
// Anchor under the word itself (not the click point) so the card lines up
|
||||||
|
// with the text the way the spelling popover does.
|
||||||
|
const start = editor.view.coordsAtPos(range.from)
|
||||||
|
const end = editor.view.coordsAtPos(range.to)
|
||||||
|
const wrapRect = wrapper.getBoundingClientRect()
|
||||||
|
const cardWidth = 300
|
||||||
|
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - cardWidth))
|
||||||
|
const top = end.bottom - wrapRect.top + 6
|
||||||
|
// Opening a word lookup supersedes any suggestion/spelling card.
|
||||||
|
closeCard()
|
||||||
|
setMisspell(null)
|
||||||
|
const token = ++wordReqRef.current
|
||||||
|
setWordInfo({ word: range.word, from: range.from, to: range.to, top, left, loading: true, info: null })
|
||||||
|
api
|
||||||
|
.lookupWord(range.word)
|
||||||
|
.then((info) => {
|
||||||
|
if (token === wordReqRef.current) {
|
||||||
|
setWordInfo((w) => (w ? { ...w, loading: false, info } : null))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('word lookup failed', err)
|
||||||
|
if (token === wordReqRef.current) {
|
||||||
|
setWordInfo((w) => (w ? { ...w, loading: false, info: null } : null))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[editor, closeCard],
|
||||||
|
)
|
||||||
|
|
||||||
|
const replaceWord = useCallback(
|
||||||
|
(synonym: string) => {
|
||||||
|
if (editor && wordInfo) {
|
||||||
|
editor.chain().focus().insertContentAt({ from: wordInfo.from, to: wordInfo.to }, synonym).run()
|
||||||
|
}
|
||||||
|
setWordInfo(null)
|
||||||
|
},
|
||||||
|
[editor, wordInfo],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hover gloss: rest the pointer on an English word and, after a short delay,
|
||||||
|
// show its Chinese meaning from the offline gloss dataset. Suppressed while a
|
||||||
|
// selection, preview, or another popover is active, or over a word that has its
|
||||||
|
// own affordance (a suggestion highlight / a misspelling).
|
||||||
|
const handleMouseMove = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!editor) return
|
||||||
|
if (selection || rewrite || misspell || wordInfo || pinned) return
|
||||||
|
if (!editor.state.selection.empty) return
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
const clear = () => {
|
||||||
|
clearTimeout(glossTimer.current)
|
||||||
|
glossReqRef.current++
|
||||||
|
setGloss(null)
|
||||||
|
}
|
||||||
|
if (t.closest('.petal-suggestion') || t.closest('.petal-misspelling')) {
|
||||||
|
clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||||
|
if (!coords) {
|
||||||
|
clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const range = wordAt(editor.state.doc, coords.pos)
|
||||||
|
if (!range) {
|
||||||
|
clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Already showing this exact word — leave it be (no flicker on micro-moves).
|
||||||
|
if (gloss && gloss.from === range.from && gloss.to === range.to) return
|
||||||
|
clearTimeout(glossTimer.current)
|
||||||
|
const token = ++glossReqRef.current
|
||||||
|
glossTimer.current = setTimeout(() => {
|
||||||
|
api
|
||||||
|
.glossWord(range.word)
|
||||||
|
.then((g) => {
|
||||||
|
if (token !== glossReqRef.current) return
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!g.gloss || !wrapper) {
|
||||||
|
setGloss(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = editor.view.coordsAtPos(range.from)
|
||||||
|
const end = editor.view.coordsAtPos(range.to)
|
||||||
|
const wrapRect = wrapper.getBoundingClientRect()
|
||||||
|
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 280))
|
||||||
|
const top = end.bottom - wrapRect.top + 6
|
||||||
|
setGloss({ word: range.word, gloss: g.gloss, from: range.from, to: range.to, top, left })
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (token === glossReqRef.current) setGloss(null)
|
||||||
|
})
|
||||||
|
}, 350)
|
||||||
|
},
|
||||||
|
[editor, selection, rewrite, misspell, wordInfo, pinned, gloss],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Leaving the editor surface drops any pending/shown gloss.
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
clearTimeout(glossTimer.current)
|
||||||
|
glossReqRef.current++
|
||||||
|
setGloss(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// runRewrite fires the LLM rewrite for a captured range + style and tracks it
|
||||||
|
// through loading → ready/error. A request token discards a response whose
|
||||||
|
// preview has since been cancelled or superseded.
|
||||||
|
const runRewrite = useCallback(
|
||||||
|
(from: number, to: number, original: string, style: string, top: number, left: number) => {
|
||||||
|
const token = ++rewriteReqRef.current
|
||||||
|
setRewrite({ from, to, original, style, top, left, status: 'loading', rewrite: '' })
|
||||||
|
api
|
||||||
|
.rewriteSelection(docId, original, style)
|
||||||
|
.then((res) => {
|
||||||
|
if (token === rewriteReqRef.current) {
|
||||||
|
setRewrite((r) => (r ? { ...r, status: 'ready', rewrite: res.rewrite } : null))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('rewrite failed', err)
|
||||||
|
if (token === rewriteReqRef.current) {
|
||||||
|
setRewrite((r) => (r ? { ...r, status: 'error' } : null))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[docId],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Picking a style in the selection bubble: capture the selection's range +
|
||||||
|
// text, anchor a preview below it, and kick off the rewrite.
|
||||||
|
const handleRewrite = useCallback(
|
||||||
|
(style: string) => {
|
||||||
|
if (!editor || !selection) return
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper) return
|
||||||
|
const start = editor.view.coordsAtPos(selection.from)
|
||||||
|
const end = editor.view.coordsAtPos(selection.to)
|
||||||
|
const wrapRect = wrapper.getBoundingClientRect()
|
||||||
|
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 340))
|
||||||
|
const top = end.bottom - wrapRect.top + 6
|
||||||
|
setSelection(null)
|
||||||
|
setGloss(null)
|
||||||
|
runRewrite(selection.from, selection.to, selection.text, style, top, left)
|
||||||
|
},
|
||||||
|
[editor, selection, runRewrite],
|
||||||
|
)
|
||||||
|
|
||||||
|
const acceptRewrite = useCallback(() => {
|
||||||
|
if (editor && rewrite && rewrite.rewrite) {
|
||||||
|
editor.chain().focus().insertContentAt({ from: rewrite.from, to: rewrite.to }, rewrite.rewrite).run()
|
||||||
|
}
|
||||||
|
rewriteReqRef.current++
|
||||||
|
setRewrite(null)
|
||||||
|
}, [editor, rewrite])
|
||||||
|
|
||||||
|
const cancelRewrite = useCallback(() => {
|
||||||
|
rewriteReqRef.current++
|
||||||
|
setRewrite(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const retryRewrite = useCallback(() => {
|
||||||
|
if (rewrite) runRewrite(rewrite.from, rewrite.to, rewrite.original, rewrite.style, rewrite.top, rewrite.left)
|
||||||
|
}, [rewrite, runRewrite])
|
||||||
|
|
||||||
|
// A pointer-down outside the rewrite preview dismisses it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rewrite) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if ((e.target as HTMLElement).closest('.petal-rewrite-card')) return
|
||||||
|
rewriteReqRef.current++
|
||||||
|
setRewrite(null)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
|
}, [rewrite])
|
||||||
|
|
||||||
|
// A pointer-down outside the word popover (and not on another word, which would
|
||||||
|
// reopen it via the context menu) closes it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wordInfo) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if ((e.target as HTMLElement).closest('.petal-word-card')) return
|
||||||
|
setWordInfo(null)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
|
}, [wordInfo])
|
||||||
|
|
||||||
|
// A pointer-down outside the popover (and not on another misspelling, which
|
||||||
|
// would reopen it) closes the spelling card.
|
||||||
|
useEffect(() => {
|
||||||
|
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)
|
||||||
|
clearTimeout(glossTimer.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])
|
||||||
|
|
||||||
|
// Touch has no hover, so the card is opened by a tap and can't close itself on
|
||||||
|
// mouse-leave. Whenever a card is open, a pointer-down outside both the card and
|
||||||
|
// any highlight dismisses it. Excluding `.petal-suggestion` lets a tap on
|
||||||
|
// another highlight reopen for that one (via the click handler) without flicker.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hover) return
|
||||||
|
const onDown = (e: PointerEvent) => {
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (!t.closest('.petal-suggestion-card') && !t.closest('.petal-suggestion')) closeCard()
|
||||||
|
}
|
||||||
|
document.addEventListener('pointerdown', onDown)
|
||||||
|
return () => document.removeEventListener('pointerdown', onDown)
|
||||||
|
}, [hover, 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}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onClick={handleSpellClick}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
>
|
||||||
|
<EditorContent editor={editor} className="h-full" />
|
||||||
|
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
||||||
|
{gloss && <GlossTip gloss={gloss.gloss} style={{ top: gloss.top, left: gloss.left }} />}
|
||||||
|
{selection && !rewrite && (
|
||||||
|
<SelectionBubble
|
||||||
|
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
|
||||||
|
onRewrite={handleRewrite}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{rewrite && (
|
||||||
|
<RewritePreview
|
||||||
|
style={rewrite.style}
|
||||||
|
status={rewrite.status}
|
||||||
|
original={rewrite.original}
|
||||||
|
rewrite={rewrite.rewrite}
|
||||||
|
cardStyle={{ top: rewrite.top, left: rewrite.left }}
|
||||||
|
onAccept={acceptRewrite}
|
||||||
|
onCancel={cancelRewrite}
|
||||||
|
onRetry={retryRewrite}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{wordInfo && (
|
||||||
|
<WordCard
|
||||||
|
word={wordInfo.word}
|
||||||
|
info={wordInfo.info}
|
||||||
|
loading={wordInfo.loading}
|
||||||
|
style={{ top: wordInfo.top, left: wordInfo.left }}
|
||||||
|
onReplace={replaceWord}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{misspell && (
|
||||||
|
<MisspellCard
|
||||||
|
word={misspell.word}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
web/src/components/Editor/GlossTip.tsx
Normal file
32
web/src/components/Editor/GlossTip.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// GlossTip is the inline hover gloss: rest the pointer on an English word and a
|
||||||
|
// small bubble shows its Chinese meaning, pulled instantly from the offline
|
||||||
|
// gloss dataset. It's a pure reading aid — pointer-events are off so it never
|
||||||
|
// steals hover or blocks a click, and it sits just under the word. Bilingual
|
||||||
|
// chrome elsewhere puts zh first; here the gloss IS the content (the word is
|
||||||
|
// already on screen), so the bubble shows only the 中文.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
gloss: string
|
||||||
|
style: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlossTip({ gloss, style }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="petal-gloss-tip pointer-events-none absolute z-20 px-2.5 py-1.5 text-sm"
|
||||||
|
role="tooltip"
|
||||||
|
style={{
|
||||||
|
maxWidth: 280,
|
||||||
|
background: 'var(--color-plum)',
|
||||||
|
color: 'var(--color-surface)',
|
||||||
|
borderRadius: 'var(--radius-input)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
lineHeight: 1.35,
|
||||||
|
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gloss}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
web/src/components/Editor/RewritePreview.tsx
Normal file
136
web/src/components/Editor/RewritePreview.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { REWRITE_STYLES } from './SelectionBubble'
|
||||||
|
|
||||||
|
// RewritePreview shows the result of a tone-rewrite before it touches the
|
||||||
|
// document: the writer's original passage, the model's rewrite beneath it, and
|
||||||
|
// accept/cancel. It mirrors the SuggestionCard's warm, bilingual chrome. While
|
||||||
|
// the model runs it shows a breathing dot; on failure it offers a gentle retry.
|
||||||
|
|
||||||
|
export type RewriteStatus = 'loading' | 'ready' | 'error'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
style: string // the chosen rewrite style key (for the header label)
|
||||||
|
status: RewriteStatus
|
||||||
|
original: string
|
||||||
|
rewrite: string
|
||||||
|
cardStyle: React.CSSProperties
|
||||||
|
onAccept: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
onRetry: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||||
|
|
||||||
|
export function RewritePreview({
|
||||||
|
style,
|
||||||
|
status,
|
||||||
|
original,
|
||||||
|
rewrite,
|
||||||
|
cardStyle,
|
||||||
|
onAccept,
|
||||||
|
onCancel,
|
||||||
|
onRetry,
|
||||||
|
}: Props) {
|
||||||
|
const meta = REWRITE_STYLES.find((s) => s.value === style) ?? REWRITE_STYLES[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="petal-rewrite-card absolute z-30 p-3.5 text-sm"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Rewrite preview"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: 340,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
fontFamily: CJK,
|
||||||
|
...cardStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||||
|
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{meta.emoji}</span>
|
||||||
|
{meta.zh} · {meta.en}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
改写 · Rewrite
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Original passage, dimmed — what's being replaced. */}
|
||||||
|
<p
|
||||||
|
className="mt-3 leading-snug"
|
||||||
|
style={{ color: 'var(--color-muted)', textDecoration: 'line-through', textDecorationColor: 'var(--color-border)' }}
|
||||||
|
>
|
||||||
|
{original}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{status === 'loading' && (
|
||||||
|
<div className="mt-3 inline-flex items-center gap-1.5" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
<span
|
||||||
|
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
改写中… · Rewriting…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
改写失败,请再试一次 · Couldn’t rewrite — try again
|
||||||
|
</p>
|
||||||
|
<div className="mt-2.5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
取消 · Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="rounded-full px-3 py-1 text-xs font-bold"
|
||||||
|
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
重试 · Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'ready' && (
|
||||||
|
<>
|
||||||
|
<p className="mt-2 leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{rewrite}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
取消 · Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAccept}
|
||||||
|
className="rounded-full px-3 py-1 text-xs font-bold"
|
||||||
|
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
用这个 · Use this
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
web/src/components/Editor/SelectionBubble.tsx
Normal file
85
web/src/components/Editor/SelectionBubble.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// SelectionBubble floats above a text selection and offers to rewrite it: a
|
||||||
|
// prominent "✨ 更自然 Say it naturally" action plus the tone vocabulary (学术,
|
||||||
|
// 轻松, …) mirrored from the document-tone picker. Picking one hands the style up
|
||||||
|
// to EditorCore, which calls the LLM and shows a preview. Buttons use
|
||||||
|
// onMouseDown→preventDefault so clicking them doesn't collapse the selection
|
||||||
|
// before the handler captures its range.
|
||||||
|
|
||||||
|
export interface RewriteStyle {
|
||||||
|
value: string
|
||||||
|
emoji: string
|
||||||
|
zh: string
|
||||||
|
en: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'natural' is the default "say it more naturally" rewrite; the rest mirror the
|
||||||
|
// llm styleGuidance keys (and the ToneSelect labels) so the two stay in step.
|
||||||
|
export const REWRITE_STYLES: RewriteStyle[] = [
|
||||||
|
{ value: 'natural', emoji: '✨', zh: '更自然', en: 'Natural' },
|
||||||
|
{ value: 'academic', emoji: '🎓', zh: '学术', en: 'Academic' },
|
||||||
|
{ value: 'professional', emoji: '💼', zh: '专业', en: 'Professional' },
|
||||||
|
{ value: 'casual', emoji: '☕', zh: '轻松', en: 'Casual' },
|
||||||
|
{ value: 'humorous', emoji: '😄', zh: '幽默', en: 'Humorous' },
|
||||||
|
{ value: 'creative', emoji: '🎨', zh: '创意', en: 'Creative' },
|
||||||
|
{ value: 'persuasive', emoji: '📣', zh: '说服', en: 'Persuasive' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
style: React.CSSProperties
|
||||||
|
onRewrite: (style: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||||
|
|
||||||
|
export function SelectionBubble({ style, onRewrite }: Props) {
|
||||||
|
const [natural, ...tones] = REWRITE_STYLES
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="petal-selection-bubble absolute z-30 flex max-w-[360px] flex-wrap items-center gap-1.5 p-2"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Rewrite the selection"
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
fontFamily: CJK,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRewrite(natural.value)}
|
||||||
|
className="inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||||
|
title="Rewrite the selection to sound more natural"
|
||||||
|
>
|
||||||
|
<span aria-hidden>{natural.emoji}</span>
|
||||||
|
<span>{natural.zh}</span>
|
||||||
|
<span className="font-semibold" style={{ color: 'var(--color-plum)', opacity: 0.7 }}>
|
||||||
|
{natural.en}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="mx-0.5 h-5 w-px shrink-0" style={{ background: 'var(--color-border)' }} />
|
||||||
|
|
||||||
|
{tones.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRewrite(t.value)}
|
||||||
|
className="inline-flex h-8 items-center gap-1 whitespace-nowrap px-2.5 text-sm font-semibold"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-lavender)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||||
|
title={`Rewrite in a ${t.en.toLowerCase()} tone`}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{t.emoji}</span>
|
||||||
|
<span>{t.zh}</span>
|
||||||
|
</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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
119
web/src/components/Editor/ToneSelect.tsx
Normal file
119
web/src/components/Editor/ToneSelect.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// ToneSelect lets the writer set the document's target tone, which steers the
|
||||||
|
// grammar-checkpoint LLM toward the right register (an academic essay vs a casual
|
||||||
|
// journal). A small custom dropdown (not a native <select>) so it can carry the
|
||||||
|
// bilingual zh·en labels and emoji that match Petal's chrome — the writer uses
|
||||||
|
// Mandarin and English. The `value` strings mirror the backend's tone keys.
|
||||||
|
|
||||||
|
export interface ToneOption {
|
||||||
|
value: string
|
||||||
|
emoji: string
|
||||||
|
zh: string
|
||||||
|
en: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep these `value`s in sync with llm.toneGuidance on the server. 'general'
|
||||||
|
// means no steering (Petal's default friendly ESL advice).
|
||||||
|
export const TONES: ToneOption[] = [
|
||||||
|
{ value: 'general', emoji: '🌸', zh: '通用', en: 'General' },
|
||||||
|
{ value: 'academic', emoji: '🎓', zh: '学术', en: 'Academic' },
|
||||||
|
{ value: 'professional', emoji: '💼', zh: '专业', en: 'Professional' },
|
||||||
|
{ value: 'casual', emoji: '☕', zh: '轻松', en: 'Casual' },
|
||||||
|
{ value: 'humorous', emoji: '😄', zh: '幽默', en: 'Humorous' },
|
||||||
|
{ value: 'creative', emoji: '🎨', zh: '创意', en: 'Creative' },
|
||||||
|
{ value: 'persuasive', emoji: '📣', zh: '说服', en: 'Persuasive' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToneSelect({ value, onChange }: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const current = TONES.find((t) => t.value === value) ?? TONES[0]
|
||||||
|
|
||||||
|
// Click outside closes the menu.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (!ref.current?.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Document tone"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
}}
|
||||||
|
title="Set the tone — Petal tailors its advice to match"
|
||||||
|
>
|
||||||
|
<span aria-hidden>{current.emoji}</span>
|
||||||
|
<span>{current.zh}</span>
|
||||||
|
<span style={{ color: 'var(--color-muted)' }}>· {current.en}</span>
|
||||||
|
<span aria-hidden style={{ color: 'var(--color-muted)' }}>
|
||||||
|
⌄
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
className="petal-word-card absolute right-0 z-30 mt-1.5 p-1.5"
|
||||||
|
style={{
|
||||||
|
width: 200,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TONES.map((t) => {
|
||||||
|
const active = t.value === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(t.value)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold"
|
||||||
|
style={{
|
||||||
|
background: active ? 'var(--color-surface-alt)' : 'transparent',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.background = active ? 'var(--color-surface-alt)' : 'transparent')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{t.emoji}</span>
|
||||||
|
<span>{t.zh}</span>
|
||||||
|
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{t.en}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
web/src/components/Editor/WordCard.tsx
Normal file
130
web/src/components/Editor/WordCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import type { WordInfo } from '../../api/client'
|
||||||
|
|
||||||
|
// WordCard is the right-click popover for any word: its dictionary definition(s)
|
||||||
|
// on top and tappable synonym pills below. Clicking a synonym replaces the word
|
||||||
|
// in place. Both datasets are offline, so this opens instantly and fills in as
|
||||||
|
// the (local) lookup returns. Labels are bilingual (zh-first, en subtitle) to
|
||||||
|
// match the rest of Petal's chrome — the writer uses Mandarin and English.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
word: string
|
||||||
|
info: WordInfo | null
|
||||||
|
loading: boolean
|
||||||
|
style: React.CSSProperties
|
||||||
|
onReplace: (synonym: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WordCard({ word, info, loading, style, onReplace }: Props) {
|
||||||
|
const definitions = info?.definitions ?? []
|
||||||
|
const synonyms = info?.synonyms ?? []
|
||||||
|
const gloss = info?.gloss ?? ''
|
||||||
|
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={`Definition and synonyms for ${word}`}
|
||||||
|
className="petal-word-card absolute z-20 p-3.5 text-sm"
|
||||||
|
style={{
|
||||||
|
width: 300,
|
||||||
|
maxHeight: 340,
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||||
|
style={{ background: 'var(--color-lavender)', color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
词语 · Word
|
||||||
|
</span>
|
||||||
|
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{word}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */}
|
||||||
|
{gloss && (
|
||||||
|
<p
|
||||||
|
className="mt-2.5 leading-snug"
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gloss}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="mt-3 inline-flex items-center gap-1.5" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
<span
|
||||||
|
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
查找中… · Looking up…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{definitions.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<p className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
释义 · Definition
|
||||||
|
</p>
|
||||||
|
<ol className="space-y-1.5">
|
||||||
|
{definitions.map((m, i) => (
|
||||||
|
<li key={i} className="leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{m.part_of_speech && (
|
||||||
|
<span className="mr-1 italic" style={{ color: 'var(--color-accent-hover)' }}>
|
||||||
|
{m.part_of_speech}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{m.definition}
|
||||||
|
{m.example && (
|
||||||
|
<span className="mt-0.5 block italic" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
“{m.example}”
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{synonyms.length > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="mb-1.5 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
近义词 · Synonyms <span className="font-normal">(点击替换 · tap to swap)</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{synonyms.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onReplace(s)}
|
||||||
|
className="rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{empty && (
|
||||||
|
<p className="mt-3 leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
没有找到这个词 · Nothing found for this word
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
web/src/components/Export/ExportMenu.tsx
Normal file
120
web/src/components/Export/ExportMenu.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, type ExportFormat } from '../../api/client'
|
||||||
|
|
||||||
|
// ExportMenu is the "get your writing out of Petal" dropdown. File formats are
|
||||||
|
// plain <a download> links to the server's export endpoint (which sets the
|
||||||
|
// Content-Disposition filename, CJK and all). "Print / Save as PDF" calls the
|
||||||
|
// browser print dialog against the print stylesheet, so PDF stays CJK-safe with
|
||||||
|
// no server-side font embedding. Bilingual zh·en labels match Petal's chrome.
|
||||||
|
|
||||||
|
interface FormatOption {
|
||||||
|
format: ExportFormat
|
||||||
|
emoji: string
|
||||||
|
zh: string
|
||||||
|
en: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMATS: FormatOption[] = [
|
||||||
|
{ format: 'docx', emoji: '📄', zh: 'Word 文档', en: 'Word (.docx)' },
|
||||||
|
{ format: 'md', emoji: '📝', zh: 'Markdown', en: 'Markdown (.md)' },
|
||||||
|
{ format: 'html', emoji: '🌐', zh: '网页', en: 'Web page (.html)' },
|
||||||
|
{ format: 'txt', emoji: '🧾', zh: '纯文本', en: 'Plain text (.txt)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
docId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportMenu({ docId }: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (!ref.current?.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Export document"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
}}
|
||||||
|
title="Save or print your writing"
|
||||||
|
>
|
||||||
|
<span aria-hidden>⬇</span>
|
||||||
|
<span>导出</span>
|
||||||
|
<span style={{ color: 'var(--color-muted)' }}>· Export</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="petal-word-card absolute right-0 z-30 mt-1.5 p-1.5"
|
||||||
|
style={{
|
||||||
|
width: 220,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FORMATS.map((f) => (
|
||||||
|
<a
|
||||||
|
key={f.format}
|
||||||
|
role="menuitem"
|
||||||
|
href={api.exportUrl(docId, f.format)}
|
||||||
|
download
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold no-underline"
|
||||||
|
style={{ color: 'var(--color-plum)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{f.emoji}</span>
|
||||||
|
<span>{f.zh}</span>
|
||||||
|
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{f.en}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="my-1 h-px" style={{ background: 'var(--color-border)' }} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
// Defer so the menu unmounts before the print dialog snapshots.
|
||||||
|
setTimeout(() => window.print(), 50)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold"
|
||||||
|
style={{ background: 'transparent', color: 'var(--color-plum)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
<span aria-hidden>🖨️</span>
|
||||||
|
<span>打印 / PDF</span>
|
||||||
|
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
Print / PDF
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
230
web/src/components/History/HistoryPanel.tsx
Normal file
230
web/src/components/History/HistoryPanel.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { api, type Document, type DocumentVersion } from '../../api/client'
|
||||||
|
|
||||||
|
// HistoryPanel is the "time machine" drawer: every snapshot Petal kept of this
|
||||||
|
// document, newest first, with a one-click preview and restore. It's the safety
|
||||||
|
// net that makes trusting Petal with real writing reasonable — a bad edit or a
|
||||||
|
// regretted rewrite is always recoverable, and restoring is itself undoable
|
||||||
|
// (the server keeps a pre_restore copy). Bilingual, zh-first, to match chrome.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
docId: string
|
||||||
|
onClose: () => void
|
||||||
|
// Called after a successful restore with the freshly-restored document so the
|
||||||
|
// editor can reload it.
|
||||||
|
onRestored: (doc: Document) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindLabel maps a snapshot kind to its bilingual badge + accent color.
|
||||||
|
const KIND: Record<DocumentVersion['kind'], { zh: string; en: string; color: string }> = {
|
||||||
|
manual: { zh: '保存点', en: 'Saved point', color: 'var(--color-accent)' },
|
||||||
|
auto: { zh: '自动', en: 'Auto', color: 'var(--color-muted)' },
|
||||||
|
pre_restore: { zh: '恢复前', en: 'Before restore', color: 'var(--color-lavender)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// relativeTime renders a UTC timestamp as a gentle "x minutes ago" string.
|
||||||
|
function relativeTime(iso: string): string {
|
||||||
|
const then = new Date(iso).getTime()
|
||||||
|
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
|
||||||
|
if (secs < 60) return 'just now · 刚刚'
|
||||||
|
const mins = Math.round(secs / 60)
|
||||||
|
if (mins < 60) return `${mins} min ago · ${mins} 分钟前`
|
||||||
|
const hrs = Math.round(mins / 60)
|
||||||
|
if (hrs < 24) return `${hrs} hr ago · ${hrs} 小时前`
|
||||||
|
const days = Math.round(hrs / 24)
|
||||||
|
return `${days} day${days > 1 ? 's' : ''} ago · ${days} 天前`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||||
|
const [versions, setVersions] = useState<DocumentVersion[] | null>(null)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [selected, setSelected] = useState<DocumentVersion | null>(null)
|
||||||
|
const [preview, setPreview] = useState<DocumentVersion | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(false)
|
||||||
|
try {
|
||||||
|
setVersions(await api.listVersions(docId))
|
||||||
|
} catch {
|
||||||
|
setError(true)
|
||||||
|
}
|
||||||
|
}, [docId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
// Escape closes the drawer.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const choose = useCallback(
|
||||||
|
async (v: DocumentVersion) => {
|
||||||
|
setSelected(v)
|
||||||
|
setPreview(null)
|
||||||
|
try {
|
||||||
|
setPreview(await api.getVersion(docId, v.id))
|
||||||
|
} catch {
|
||||||
|
setPreview(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[docId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const restore = useCallback(async () => {
|
||||||
|
if (!selected) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const doc = await api.restoreVersion(docId, selected.id)
|
||||||
|
onRestored(doc)
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}, [docId, selected, onRestored, onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="petal-no-print fixed inset-0 z-40 flex justify-end">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ background: 'rgba(61, 46, 57, 0.18)' }}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
className="relative flex h-full w-full max-w-[380px] flex-col"
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
borderLeft: '1px solid var(--color-border)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
className="flex items-center justify-between px-5 py-4"
|
||||||
|
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-extrabold text-plum">历史 · History</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
Every saved moment — nothing is ever lost 🌸
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close history"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full text-lg"
|
||||||
|
style={{ color: 'var(--color-muted)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
||||||
|
{error ? (
|
||||||
|
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
Couldn’t load history just now.
|
||||||
|
<button onClick={load} className="ml-1 font-bold text-plum underline">
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : versions === null ? (
|
||||||
|
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
) : versions.length === 0 ? (
|
||||||
|
<div className="px-2 py-8 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
No snapshots yet. Keep writing — Petal saves restore points as you go.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{versions.map((v) => {
|
||||||
|
const k = KIND[v.kind]
|
||||||
|
const active = selected?.id === v.id
|
||||||
|
return (
|
||||||
|
<li key={v.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => choose(v)}
|
||||||
|
className="flex w-full flex-col gap-1 rounded-2xl px-3 py-2.5 text-left"
|
||||||
|
style={{
|
||||||
|
background: active ? 'var(--color-surface-alt)' : 'transparent',
|
||||||
|
border: `1px solid ${active ? 'var(--color-border)' : 'transparent'}`,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.background = 'var(--color-surface-alt)')
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.background = active
|
||||||
|
? 'var(--color-surface-alt)'
|
||||||
|
: 'transparent')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-sm font-bold text-plum">{v.title || 'Untitled'}</span>
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
|
||||||
|
style={{ background: k.color, color: '#fff' }}
|
||||||
|
>
|
||||||
|
{k.zh} · {k.en}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
<span>{relativeTime(v.created_at)}</span>
|
||||||
|
<span>{v.word_count} words</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<div
|
||||||
|
className="shrink-0 px-4 py-3"
|
||||||
|
style={{ borderTop: '1px solid var(--color-border)', background: 'var(--color-surface-alt)' }}
|
||||||
|
>
|
||||||
|
<div className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
预览 · Preview
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mb-3 max-h-32 overflow-y-auto whitespace-pre-wrap rounded-xl px-3 py-2 text-sm"
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
fontFamily: 'var(--font-body)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preview ? preview.content_text || '(empty)' : 'Loading…'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={restore}
|
||||||
|
disabled={busy}
|
||||||
|
className="w-full rounded-full py-2.5 text-sm font-extrabold text-white disabled:opacity-60"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
|
>
|
||||||
|
{busy ? 'Restoring…' : '恢复这个版本 · Restore this version'}
|
||||||
|
</button>
|
||||||
|
<div className="mt-1.5 text-center text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
Your current draft is saved first, so this is undoable.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
web/src/components/StatusBar/SoundToggle.tsx
Normal file
37
web/src/components/StatusBar/SoundToggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { isSoundEnabled, onSoundEnabledChange, playSound, setSoundEnabled } from '../../audio/sounds'
|
||||||
|
|
||||||
|
// A tiny mute toggle for Petal's cute sounds, tucked at the right of the status
|
||||||
|
// bar. Bilingual tooltip (she reads Mandarin first), and a soft confirming pop
|
||||||
|
// when sounds are turned back on so the choice is audible.
|
||||||
|
export function SoundToggle() {
|
||||||
|
const [on, setOn] = useState(isSoundEnabled)
|
||||||
|
|
||||||
|
// Stay in sync if the setting is flipped elsewhere.
|
||||||
|
useEffect(() => onSoundEnabledChange(setOn), [])
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
const next = !on
|
||||||
|
setSoundEnabled(next)
|
||||||
|
setOn(next)
|
||||||
|
if (next) playSound('pop') // a little hello when re-enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-pressed={on}
|
||||||
|
title={on ? '声音开 · Sounds on' : '声音关 · Sounds off'}
|
||||||
|
aria-label={on ? 'Mute Petal sounds' : 'Unmute Petal sounds'}
|
||||||
|
className="rounded-full px-1.5 py-0.5 transition-colors"
|
||||||
|
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.color = on ? 'var(--color-accent)' : 'var(--color-muted)')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{on ? '🔔' : '🔕'}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
web/src/components/StatusBar/StatsPanel.tsx
Normal file
81
web/src/components/StatusBar/StatsPanel.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { computeStats, gradeBand } from './stats'
|
||||||
|
|
||||||
|
// StatsPanel is the popover that opens above the word count: a small grid of
|
||||||
|
// writing statistics computed from the live document. Bilingual zh·en labels to
|
||||||
|
// match Petal's chrome. Reading level shows a friendly band, not just a number.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string
|
||||||
|
wordCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
zh: string
|
||||||
|
en: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsPanel({ text, wordCount }: Props) {
|
||||||
|
const rows = useMemo<Row[]>(() => {
|
||||||
|
const s = computeStats(text, wordCount)
|
||||||
|
const band = gradeBand(s.gradeLevel)
|
||||||
|
const fmt = (n: number, d = 0) =>
|
||||||
|
n.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d })
|
||||||
|
return [
|
||||||
|
{ zh: '字数', en: 'Words', value: fmt(s.words) },
|
||||||
|
{ zh: '字符', en: 'Characters', value: fmt(s.characters) },
|
||||||
|
{ zh: '句子', en: 'Sentences', value: fmt(s.sentences) },
|
||||||
|
{ zh: '段落', en: 'Paragraphs', value: fmt(s.paragraphs) },
|
||||||
|
{ zh: '页数', en: 'Pages', value: `~${fmt(Math.max(s.pages, s.words > 0 ? 0.1 : 0), 1)}` },
|
||||||
|
{ zh: '阅读时间', en: 'Reading time', value: readingTime(s.readingTimeMin) },
|
||||||
|
{ zh: '平均词长', en: 'Avg word length', value: `${fmt(s.avgWordLength, 1)}` },
|
||||||
|
{ zh: '词汇丰富度', en: 'Word variety', value: `${fmt(s.variety * 100)}%` },
|
||||||
|
{
|
||||||
|
zh: '阅读难度',
|
||||||
|
en: 'Reading level',
|
||||||
|
value: s.words > 0 ? `${band.en} · ${fmt(s.gradeLevel, 1)}` : '—',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [text, wordCount])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Writing statistics"
|
||||||
|
className="petal-word-card absolute bottom-7 left-0 z-30 p-3.5"
|
||||||
|
style={{
|
||||||
|
width: 268,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
写作统计 · Writing stats
|
||||||
|
</p>
|
||||||
|
<dl className="space-y-1.5">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.en} className="flex items-baseline justify-between gap-3 text-sm">
|
||||||
|
<dt style={{ color: 'var(--color-muted)' }}>
|
||||||
|
<span className="font-semibold" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{r.zh}
|
||||||
|
</span>{' '}
|
||||||
|
{r.en}
|
||||||
|
</dt>
|
||||||
|
<dd className="font-bold tabular-nums">{r.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readingTime renders minutes as a friendly "< 1 min" / "N min" string.
|
||||||
|
function readingTime(min: number): string {
|
||||||
|
if (min <= 0) return '0 min'
|
||||||
|
if (min < 1) return '< 1 min'
|
||||||
|
return `${Math.round(min)} min`
|
||||||
|
}
|
||||||
129
web/src/components/StatusBar/StatusBar.tsx
Normal file
129
web/src/components/StatusBar/StatusBar.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||||
|
import { StatsPanel } from './StatsPanel'
|
||||||
|
import { SoundToggle } from './SoundToggle'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
wordCount: number
|
||||||
|
// Live plain text of the document, for the expanded stats panel.
|
||||||
|
text: string
|
||||||
|
saveStatus: SaveStatus
|
||||||
|
// True while a grammar checkpoint is in flight — shows the breathing rose dot.
|
||||||
|
checking: boolean
|
||||||
|
// True while a whole-document voice pass runs — shows a breathing honey dot.
|
||||||
|
voicing: boolean
|
||||||
|
// True when Petal can't reach its LLM helper — shows a gentle, reassuring note
|
||||||
|
// (the writing still saves locally, so this is awareness, not an error).
|
||||||
|
llmDown: 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, text, saveStatus, checking, voicing, llmDown }: Props) {
|
||||||
|
const label = SAVE_LABEL[saveStatus]
|
||||||
|
// The expanded stats panel toggles open when the word count is clicked.
|
||||||
|
const [statsOpen, setStatsOpen] = useState(false)
|
||||||
|
const statsRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!statsOpen) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (!statsRef.current?.contains(e.target as Node)) setStatsOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
|
}, [statsOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
className="flex h-9 shrink-0 items-center gap-3 px-6 text-xs"
|
||||||
|
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
<div className="relative" ref={statsRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatsOpen((o) => !o)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={statsOpen}
|
||||||
|
className="rounded-full px-1.5 py-0.5 font-semibold transition-colors"
|
||||||
|
style={{ color: statsOpen ? 'var(--color-accent-hover)' : 'inherit' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.color = statsOpen ? 'var(--color-accent-hover)' : 'inherit')
|
||||||
|
}
|
||||||
|
title="Writing stats"
|
||||||
|
>
|
||||||
|
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
||||||
|
</button>
|
||||||
|
{statsOpen && <StatsPanel text={text} wordCount={wordCount} />}
|
||||||
|
</div>
|
||||||
|
{checking && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{llmDown && !checking && !voicing && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
title="Petal can't reach its writing helper right now — your text is still saved."
|
||||||
|
style={{ color: 'var(--color-honey)' }}
|
||||||
|
>
|
||||||
|
<span aria-hidden>🌙</span>
|
||||||
|
<span>小助手在休息</span>
|
||||||
|
<span style={{ opacity: 0.75 }}>· Petal's helper is resting · 文字已保存</span>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto">
|
||||||
|
<SoundToggle />
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
web/src/components/StatusBar/stats.ts
Normal file
92
web/src/components/StatusBar/stats.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Writing statistics computed from the document's plain text. These power the
|
||||||
|
// expanded stats panel that opens when the writer clicks the word count. The
|
||||||
|
// reading-level formulas are English-centric (Flesch); a document mixing in
|
||||||
|
// Mandarin still gets sensible counts, with the level treated as approximate.
|
||||||
|
|
||||||
|
export interface WritingStats {
|
||||||
|
words: number
|
||||||
|
characters: number
|
||||||
|
charactersNoSpaces: number
|
||||||
|
sentences: number
|
||||||
|
paragraphs: number
|
||||||
|
pages: number
|
||||||
|
avgWordLength: number // letters per English word
|
||||||
|
uniqueWords: number
|
||||||
|
variety: number // type-token ratio, 0–1 (unique ÷ total English words)
|
||||||
|
readingTimeMin: number
|
||||||
|
gradeLevel: number // Flesch–Kincaid grade
|
||||||
|
}
|
||||||
|
|
||||||
|
// English word tokens (letters with internal apostrophes/hyphens) — used for the
|
||||||
|
// letter-length, syllable, and variety measures, which only make sense for
|
||||||
|
// alphabetic words.
|
||||||
|
const ENGLISH_WORD_RE = /[A-Za-z]+(?:['’-][A-Za-z]+)*/g
|
||||||
|
// Sentence terminators, including the CJK fullwidth forms.
|
||||||
|
const SENTENCE_RE = /[.!?。!?]+/g
|
||||||
|
// Words per page (a rough double-spaced manuscript page) and reading speed.
|
||||||
|
const WORDS_PER_PAGE = 250
|
||||||
|
const WORDS_PER_MINUTE = 200
|
||||||
|
|
||||||
|
// countSyllables is the common vowel-group heuristic: count vowel runs, drop a
|
||||||
|
// trailing silent "e"/"es"/"ed", and floor at one. Not perfect, but plenty
|
||||||
|
// accurate for an at-a-glance reading level.
|
||||||
|
function countSyllables(word: string): number {
|
||||||
|
const w = word.toLowerCase().replace(/[^a-z]/g, '')
|
||||||
|
if (w.length === 0) return 0
|
||||||
|
if (w.length <= 3) return 1
|
||||||
|
const trimmed = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '').replace(/^y/, '')
|
||||||
|
const groups = trimmed.match(/[aeiouy]{1,2}/g)
|
||||||
|
return groups ? groups.length : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeStats(text: string, wordCount: number): WritingStats {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
const characters = [...text].length
|
||||||
|
const charactersNoSpaces = text.replace(/\s/g, '').length
|
||||||
|
|
||||||
|
const sentenceMatches = trimmed.match(SENTENCE_RE)
|
||||||
|
const sentences = sentenceMatches ? sentenceMatches.length : trimmed ? 1 : 0
|
||||||
|
|
||||||
|
const paragraphBlocks = trimmed.split(/\n{2,}/).filter((p) => p.trim().length > 0)
|
||||||
|
const paragraphs = paragraphBlocks.length
|
||||||
|
|
||||||
|
const englishWords = trimmed.match(ENGLISH_WORD_RE) ?? []
|
||||||
|
const letters = englishWords.reduce((sum, w) => sum + w.length, 0)
|
||||||
|
const avgWordLength = englishWords.length > 0 ? letters / englishWords.length : 0
|
||||||
|
|
||||||
|
const unique = new Set(englishWords.map((w) => w.toLowerCase()))
|
||||||
|
const uniqueWords = unique.size
|
||||||
|
const variety = englishWords.length > 0 ? uniqueWords / englishWords.length : 0
|
||||||
|
|
||||||
|
const syllables = englishWords.reduce((sum, w) => sum + countSyllables(w), 0)
|
||||||
|
// Flesch–Kincaid grade level; needs at least one sentence and word to be real.
|
||||||
|
let gradeLevel = 0
|
||||||
|
if (englishWords.length > 0 && sentences > 0) {
|
||||||
|
gradeLevel =
|
||||||
|
0.39 * (englishWords.length / sentences) + 11.8 * (syllables / englishWords.length) - 15.59
|
||||||
|
if (gradeLevel < 0) gradeLevel = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
words: wordCount,
|
||||||
|
characters,
|
||||||
|
charactersNoSpaces,
|
||||||
|
sentences,
|
||||||
|
paragraphs,
|
||||||
|
pages: wordCount / WORDS_PER_PAGE,
|
||||||
|
avgWordLength,
|
||||||
|
uniqueWords,
|
||||||
|
variety,
|
||||||
|
readingTimeMin: wordCount / WORDS_PER_MINUTE,
|
||||||
|
gradeLevel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gradeBand turns a Flesch–Kincaid grade into a friendly bilingual descriptor —
|
||||||
|
// far more useful to an ESL writer than a bare number.
|
||||||
|
export function gradeBand(grade: number): { zh: string; en: string } {
|
||||||
|
if (grade <= 5) return { zh: '简单', en: 'Easy' }
|
||||||
|
if (grade <= 8) return { zh: '标准', en: 'Standard' }
|
||||||
|
if (grade <= 12) return { zh: '偏难', en: 'Fairly hard' }
|
||||||
|
return { zh: '较难', en: 'Advanced' }
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
web/src/components/UpdateBanner/UpdateBanner.tsx
Normal file
64
web/src/components/UpdateBanner/UpdateBanner.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// UpdateBanner gently floats down from the top when a newer build has been
|
||||||
|
// deployed, inviting a refresh. Mandarin-first copy (north star Note #17), soft
|
||||||
|
// rose styling, and a clear primary action. Dismissable — if she dismisses it,
|
||||||
|
// the next deploy (or reload) will surface a fresh one.
|
||||||
|
export function UpdateBanner() {
|
||||||
|
const [dismissed, setDismissed] = useState(false)
|
||||||
|
if (dismissed) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-center px-4">
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="petal-update pointer-events-auto flex items-center gap-3 py-2.5 pl-4 pr-2.5"
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
maxWidth: 'min(92vw, 460px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden style={{ fontSize: 20, lineHeight: 1 }}>
|
||||||
|
🌸
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p
|
||||||
|
className="truncate text-sm font-bold leading-snug"
|
||||||
|
style={{ color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
有新版本啦
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
A new version is ready — refresh to update.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="shrink-0 rounded-full px-3.5 py-1.5 text-sm font-bold text-white transition-colors"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
|
>
|
||||||
|
刷新 · Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
aria-label="稍后再说 · Dismiss"
|
||||||
|
title="稍后再说 · Dismiss"
|
||||||
|
className="shrink-0 rounded-full px-1.5 text-lg leading-none transition-colors"
|
||||||
|
style={{ color: 'var(--color-muted)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-plum)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-muted)')}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
267
web/src/effects/PetalFall.tsx
Normal file
267
web/src/effects/PetalFall.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
// PetalFall is the cozy ambient layer: a full-window WebGL canvas that drifts a
|
||||||
|
// handful of soft flower petals down the page. It sits behind everything,
|
||||||
|
// ignores pointer events, and stays sparse so it reads as "occasional petals on
|
||||||
|
// a breeze" rather than confetti. Honors prefers-reduced-motion by rendering
|
||||||
|
// nothing. No external libraries — a single tiny shader program animates every
|
||||||
|
// petal entirely on the GPU, so the CPU just spins a clock.
|
||||||
|
|
||||||
|
// Each petal is a quad whose corners we expand in the vertex shader. We pack one
|
||||||
|
// quad as 6 vertices; per-vertex we send the corner offset (which corner) plus
|
||||||
|
// the petal's static randomness (lane, speed, size, sway, tint, spin).
|
||||||
|
const PETAL_COUNT = 26
|
||||||
|
|
||||||
|
// Soft Petal-palette tints (rose / blush / lavender), as RGB 0..1.
|
||||||
|
const TINTS: [number, number, number][] = [
|
||||||
|
[0.91, 0.63, 0.75], // accent rose #E8A0BF
|
||||||
|
[1.0, 0.94, 0.96], // near-white blush
|
||||||
|
[0.96, 0.72, 0.63], // peach #F4B8A0
|
||||||
|
[0.77, 0.71, 0.91], // lavender #C5B4E8
|
||||||
|
[0.85, 0.55, 0.69], // deeper rose
|
||||||
|
]
|
||||||
|
|
||||||
|
const VERT = `
|
||||||
|
precision mediump float;
|
||||||
|
attribute vec2 a_corner; // quad corner in [-0.5,0.5]
|
||||||
|
attribute vec4 a_rand; // x: lane(0..1), y: speed, z: size, w: phase
|
||||||
|
attribute vec4 a_rand2; // x: swayAmp, y: swayFreq, z: spin, w: tintIdx
|
||||||
|
uniform float u_time;
|
||||||
|
uniform vec2 u_res;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
varying float v_tint;
|
||||||
|
varying float v_alpha;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
v_uv = a_corner + 0.5;
|
||||||
|
v_tint = a_rand2.w;
|
||||||
|
|
||||||
|
float lane = a_rand.x;
|
||||||
|
float speed = a_rand.y;
|
||||||
|
float size = a_rand.z;
|
||||||
|
float phase = a_rand.w;
|
||||||
|
|
||||||
|
// Vertical fall: loop 0..1 over time, offset per petal so they don't sync.
|
||||||
|
float fall = fract(u_time * speed + phase);
|
||||||
|
// Map to clip space: start above the top (1.15) fall to below bottom (-1.15).
|
||||||
|
float y = 1.15 - fall * 2.3;
|
||||||
|
|
||||||
|
// Horizontal: a base lane plus a gentle sway as it descends.
|
||||||
|
float sway = a_rand2.x * sin(u_time * a_rand2.y + phase * 6.2831);
|
||||||
|
float x = (lane * 2.0 - 1.0) + sway;
|
||||||
|
|
||||||
|
// Spin the quad over time.
|
||||||
|
float ang = u_time * a_rand2.z + phase * 6.2831;
|
||||||
|
float c = cos(ang), s = sin(ang);
|
||||||
|
vec2 corner = a_corner * size;
|
||||||
|
// Keep petals visually square despite aspect ratio.
|
||||||
|
corner.x *= u_res.y / u_res.x;
|
||||||
|
vec2 rot = vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
|
||||||
|
|
||||||
|
// Fade in near the top and out near the bottom so they don't pop.
|
||||||
|
v_alpha = smoothstep(0.0, 0.12, fall) * (1.0 - smoothstep(0.86, 1.0, fall));
|
||||||
|
|
||||||
|
gl_Position = vec4(x + rot.x, y + rot.y, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const FRAG = `
|
||||||
|
precision mediump float;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
varying float v_tint;
|
||||||
|
varying float v_alpha;
|
||||||
|
uniform vec3 u_tints[5];
|
||||||
|
|
||||||
|
// A soft petal/teardrop alpha mask centered in the quad.
|
||||||
|
float petalMask(vec2 uv) {
|
||||||
|
vec2 p = uv - vec2(0.5);
|
||||||
|
// Teardrop: pinch the top, round the bottom.
|
||||||
|
float r = length(vec2(p.x * 1.65, p.y * 1.15 + 0.12));
|
||||||
|
float body = smoothstep(0.5, 0.16, r);
|
||||||
|
// A subtle crease down the middle for a petal feel.
|
||||||
|
float crease = 1.0 - 0.18 * exp(-pow(p.x * 7.0, 2.0));
|
||||||
|
return body * crease;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
float m = petalMask(v_uv);
|
||||||
|
if (m < 0.01) discard;
|
||||||
|
int idx = int(v_tint + 0.5);
|
||||||
|
vec3 col = u_tints[0];
|
||||||
|
if (idx == 1) col = u_tints[1];
|
||||||
|
else if (idx == 2) col = u_tints[2];
|
||||||
|
else if (idx == 3) col = u_tints[3];
|
||||||
|
else if (idx == 4) col = u_tints[4];
|
||||||
|
gl_FragColor = vec4(col, m * v_alpha * 0.55);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null {
|
||||||
|
const sh = gl.createShader(type)
|
||||||
|
if (!sh) return null
|
||||||
|
gl.shaderSource(sh, src)
|
||||||
|
gl.compileShader(sh)
|
||||||
|
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
||||||
|
console.error('petal shader compile failed', gl.getShaderInfoLog(sh))
|
||||||
|
gl.deleteShader(sh)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return sh
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic-ish pseudo random (we can't use Math.random at module scope for
|
||||||
|
// SSR safety, but here in an effect it's fine; kept simple).
|
||||||
|
function rand(): number {
|
||||||
|
return Math.random()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PetalFall() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reduce =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||||
|
if (reduce) return // respect users who don't want ambient motion
|
||||||
|
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false, antialias: true })
|
||||||
|
if (!gl) return // no WebGL — silently skip the effect
|
||||||
|
|
||||||
|
const vs = compile(gl, gl.VERTEX_SHADER, VERT)
|
||||||
|
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)
|
||||||
|
if (!vs || !fs) return
|
||||||
|
const prog = gl.createProgram()!
|
||||||
|
gl.attachShader(prog, vs)
|
||||||
|
gl.attachShader(prog, fs)
|
||||||
|
gl.linkProgram(prog)
|
||||||
|
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
||||||
|
console.error('petal program link failed', gl.getProgramInfoLog(prog))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gl.useProgram(prog)
|
||||||
|
|
||||||
|
// Build interleaved vertex data: 6 verts per petal, each with corner(2),
|
||||||
|
// rand(4), rand2(4) = 10 floats.
|
||||||
|
const FLOATS_PER_VERT = 10
|
||||||
|
const corners = [
|
||||||
|
[-0.5, -0.5],
|
||||||
|
[0.5, -0.5],
|
||||||
|
[-0.5, 0.5],
|
||||||
|
[-0.5, 0.5],
|
||||||
|
[0.5, -0.5],
|
||||||
|
[0.5, 0.5],
|
||||||
|
]
|
||||||
|
const data = new Float32Array(PETAL_COUNT * 6 * FLOATS_PER_VERT)
|
||||||
|
let o = 0
|
||||||
|
for (let i = 0; i < PETAL_COUNT; i++) {
|
||||||
|
const lane = rand()
|
||||||
|
const speed = 0.018 + rand() * 0.03 // slow, gentle fall
|
||||||
|
const size = 0.05 + rand() * 0.06
|
||||||
|
const phase = rand()
|
||||||
|
const swayAmp = 0.04 + rand() * 0.09
|
||||||
|
const swayFreq = 0.4 + rand() * 0.7
|
||||||
|
const spin = (rand() - 0.5) * 1.2
|
||||||
|
const tint = Math.floor(rand() * TINTS.length)
|
||||||
|
for (let c = 0; c < 6; c++) {
|
||||||
|
data[o++] = corners[c][0]
|
||||||
|
data[o++] = corners[c][1]
|
||||||
|
data[o++] = lane
|
||||||
|
data[o++] = speed
|
||||||
|
data[o++] = size
|
||||||
|
data[o++] = phase
|
||||||
|
data[o++] = swayAmp
|
||||||
|
data[o++] = swayFreq
|
||||||
|
data[o++] = spin
|
||||||
|
data[o++] = tint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = gl.createBuffer()
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
|
||||||
|
|
||||||
|
const stride = FLOATS_PER_VERT * 4
|
||||||
|
const aCorner = gl.getAttribLocation(prog, 'a_corner')
|
||||||
|
const aRand = gl.getAttribLocation(prog, 'a_rand')
|
||||||
|
const aRand2 = gl.getAttribLocation(prog, 'a_rand2')
|
||||||
|
gl.enableVertexAttribArray(aCorner)
|
||||||
|
gl.vertexAttribPointer(aCorner, 2, gl.FLOAT, false, stride, 0)
|
||||||
|
gl.enableVertexAttribArray(aRand)
|
||||||
|
gl.vertexAttribPointer(aRand, 4, gl.FLOAT, false, stride, 2 * 4)
|
||||||
|
gl.enableVertexAttribArray(aRand2)
|
||||||
|
gl.vertexAttribPointer(aRand2, 4, gl.FLOAT, false, stride, 6 * 4)
|
||||||
|
|
||||||
|
const uTime = gl.getUniformLocation(prog, 'u_time')
|
||||||
|
const uRes = gl.getUniformLocation(prog, 'u_res')
|
||||||
|
const uTints = gl.getUniformLocation(prog, 'u_tints')
|
||||||
|
gl.uniform3fv(uTints, TINTS.flat())
|
||||||
|
|
||||||
|
gl.enable(gl.BLEND)
|
||||||
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||||
|
gl.disable(gl.DEPTH_TEST)
|
||||||
|
|
||||||
|
// Non-null aliases so the render closures keep the narrowed types.
|
||||||
|
const view = canvas
|
||||||
|
const glc = gl
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||||
|
function resize() {
|
||||||
|
const w = Math.floor(window.innerWidth * dpr)
|
||||||
|
const h = Math.floor(window.innerHeight * dpr)
|
||||||
|
if (view.width !== w || view.height !== h) {
|
||||||
|
view.width = w
|
||||||
|
view.height = h
|
||||||
|
}
|
||||||
|
glc.viewport(0, 0, view.width, view.height)
|
||||||
|
glc.uniform2f(uRes, view.width, view.height)
|
||||||
|
}
|
||||||
|
resize()
|
||||||
|
window.addEventListener('resize', resize)
|
||||||
|
|
||||||
|
let raf = 0
|
||||||
|
let running = true
|
||||||
|
const start = performance.now()
|
||||||
|
function frame() {
|
||||||
|
if (!running) return
|
||||||
|
const t = (performance.now() - start) / 1000
|
||||||
|
glc.clearColor(0, 0, 0, 0)
|
||||||
|
glc.clear(glc.COLOR_BUFFER_BIT)
|
||||||
|
glc.uniform1f(uTime, t)
|
||||||
|
glc.drawArrays(glc.TRIANGLES, 0, PETAL_COUNT * 6)
|
||||||
|
raf = requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
// Pause when the tab is hidden to save the battery.
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
running = false
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
} else if (!running) {
|
||||||
|
running = true
|
||||||
|
raf = requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
|
raf = requestAnimationFrame(frame)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
running = false
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
window.removeEventListener('resize', resize)
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
|
gl.deleteBuffer(buf)
|
||||||
|
gl.deleteProgram(prog)
|
||||||
|
gl.deleteShader(vs)
|
||||||
|
gl.deleteShader(fs)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
aria-hidden
|
||||||
|
className="petal-fall pointer-events-none fixed inset-0"
|
||||||
|
style={{ zIndex: 20 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
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 }
|
||||||
|
}
|
||||||
108
web/src/hooks/useCheckpoint.ts
Normal file
108
web/src/hooks/useCheckpoint.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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)
|
||||||
|
// True when the last LLM pass couldn't reach the model (server 502 or network
|
||||||
|
// error). Drives a gentle, reassuring "helper is resting" note — the writing
|
||||||
|
// itself still saves fine, so this is awareness, not an error. Cleared on the
|
||||||
|
// next success or doc switch.
|
||||||
|
const [llmDown, setLlmDown] = 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)
|
||||||
|
setLlmDown(false)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('checkpoint failed', err)
|
||||||
|
if (run === runRef.current) setLlmDown(true)
|
||||||
|
} 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)
|
||||||
|
setLlmDown(false)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('voice pass failed', err)
|
||||||
|
if (run === runRef.current) setLlmDown(true)
|
||||||
|
} 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)
|
||||||
|
setLlmDown(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, llmDown, 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 }
|
||||||
|
}
|
||||||
63
web/src/hooks/useTags.ts
Normal file
63
web/src/hooks/useTags.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { api, type Tag, type TagColor } from '../api/client'
|
||||||
|
|
||||||
|
// useTags owns the user's tag roster (the full set the writer has created, with
|
||||||
|
// per-tag document counts). Document↔tag assignments live in App alongside the
|
||||||
|
// document list; this hook is just the roster plus create/recolor/delete and a
|
||||||
|
// refresh used to keep counts current after an assignment changes.
|
||||||
|
export function useTags() {
|
||||||
|
const [tags, setTags] = useState<Tag[]>([])
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setTags(await api.listTags())
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to load tags', err)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
// Create (or reuse) a tag, returning it. The roster is refreshed so the new
|
||||||
|
// tag appears with a zero count.
|
||||||
|
const createTag = useCallback(
|
||||||
|
async (name: string, color: TagColor): Promise<Tag | null> => {
|
||||||
|
try {
|
||||||
|
const tag = await api.createTag(name, color)
|
||||||
|
await refresh()
|
||||||
|
return tag
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to create tag', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[refresh],
|
||||||
|
)
|
||||||
|
|
||||||
|
const recolorTag = useCallback(
|
||||||
|
async (id: string, color: TagColor) => {
|
||||||
|
// Optimistic recolor; refresh reconciles.
|
||||||
|
setTags((prev) => prev.map((t) => (t.id === id ? { ...t, color } : t)))
|
||||||
|
try {
|
||||||
|
await api.updateTag(id, { color })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to recolor tag', err)
|
||||||
|
void refresh()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[refresh],
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteTag = useCallback(async (id: string) => {
|
||||||
|
setTags((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
try {
|
||||||
|
await api.deleteTag(id)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to delete tag', err)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { tags, refresh, createTag, recolorTag, deleteTag }
|
||||||
|
}
|
||||||
52
web/src/hooks/useVersionWatch.ts
Normal file
52
web/src/hooks/useVersionWatch.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
// How often to ask the server whether a newer frontend has shipped. Gentle —
|
||||||
|
// a deploy is rare, and the check is a tiny no-store GET.
|
||||||
|
const POLL_MS = 90_000
|
||||||
|
|
||||||
|
// useVersionWatch records the build id the app loaded with, then quietly polls
|
||||||
|
// /api/version. When the server reports a different id, a new version has been
|
||||||
|
// deployed and `updateAvailable` flips true (and stays true) so the UI can
|
||||||
|
// offer a refresh. Network blips are ignored — it only ever reacts to a real,
|
||||||
|
// confirmed change. Returns false until the baseline is established.
|
||||||
|
export function useVersionWatch(): boolean {
|
||||||
|
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||||
|
// The version we're currently running. Null until the first successful fetch.
|
||||||
|
const baseline = useRef<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
try {
|
||||||
|
const { version } = await api.version()
|
||||||
|
if (!active || !version) return
|
||||||
|
if (baseline.current === null) {
|
||||||
|
baseline.current = version // first read: this is "us"
|
||||||
|
} else if (version !== baseline.current) {
|
||||||
|
setUpdateAvailable(true)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* offline / server bounce — try again next tick, never alarm the user */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check()
|
||||||
|
const id = setInterval(check, POLL_MS)
|
||||||
|
// Re-check the moment she returns to the tab, so a deploy that happened while
|
||||||
|
// she was away surfaces right away instead of up to POLL_MS later.
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState === 'visible') check()
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', onVisible)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
clearInterval(id)
|
||||||
|
document.removeEventListener('visibilitychange', onVisible)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return updateAvailable
|
||||||
|
}
|
||||||
@@ -53,3 +53,383 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Word lookup popover ----------------------------------------------------
|
||||||
|
Right-click a word for its dictionary definition and synonyms. Same soft card
|
||||||
|
treatment as the spelling popover; synonym pills swap the word on click. */
|
||||||
|
.petal-word-card {
|
||||||
|
animation: petal-suggestion-in 200ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- ESL superpowers (Phase 9) ----------------------------------------------
|
||||||
|
Inline Chinese gloss on hover (a small dark tooltip under the word), and the
|
||||||
|
selection rewrite bubble + its preview card. All share the soft pop-in. The
|
||||||
|
gloss tip fades in a touch faster — it's a fleeting reading aid, not a panel. */
|
||||||
|
.petal-gloss-tip {
|
||||||
|
animation: petal-suggestion-in 140ms ease both;
|
||||||
|
}
|
||||||
|
.petal-selection-bubble,
|
||||||
|
.petal-rewrite-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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "New version available" banner — drifts down from the top like a petal. */
|
||||||
|
.petal-update {
|
||||||
|
animation: petal-drop 360ms cubic-bezier(0.2, 0.8, 0.3, 1.2) both;
|
||||||
|
}
|
||||||
|
@keyframes petal-drop {
|
||||||
|
from { opacity: 0; transform: translateY(-14px) scale(0.96); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Organization & search (Phase 10) --------------------------------------
|
||||||
|
Tag chips, the search results list, and per-row affordances. The row actions
|
||||||
|
(tag / delete) fade in on hover for pointer users but are always visible on
|
||||||
|
touch (no hover) — see the coarse-pointer block below. */
|
||||||
|
.petal-tag-chip {
|
||||||
|
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
.petal-search-results {
|
||||||
|
animation: petal-suggestion-in 160ms ease both;
|
||||||
|
}
|
||||||
|
.petal-tag-picker {
|
||||||
|
animation: petal-suggestion-in 160ms ease both;
|
||||||
|
}
|
||||||
|
.petal-row-action {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.group:hover .petal-row-action,
|
||||||
|
.petal-row-action:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.petal-row-action:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
/* Clamp a search snippet to two lines. */
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Touch & tablet polish --------------------------------------------------
|
||||||
|
On coarse pointers (tablets, phones) there's no hover, so make tap targets
|
||||||
|
comfortable and reveal affordances that would otherwise be hover-only. */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.petal-tap {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.petal-tap-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
/* Row actions can't rely on hover — keep them visible and roomy. */
|
||||||
|
.petal-row-action {
|
||||||
|
opacity: 1;
|
||||||
|
height: 36px;
|
||||||
|
width: 36px;
|
||||||
|
}
|
||||||
|
/* Bigger, easier-to-hit suggestion/word/tag pills. */
|
||||||
|
.petal-tag-chip {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Responsive sidebar (narrow screens) ------------------------------------
|
||||||
|
Below the tablet breakpoint the sidebar becomes an overlay drawer toggled by a
|
||||||
|
hamburger in the header, instead of a permanent column. A scrim sits behind it.
|
||||||
|
On wide screens the toggle + scrim are hidden and the sidebar is in-flow. */
|
||||||
|
.petal-sidebar-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.petal-scrim {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.petal-sidebar-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.petal-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 48px; /* below the 12-height header */
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 30;
|
||||||
|
background: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
/* On mobile the sidebar is hidden by default; the drawer-open class slides it
|
||||||
|
in. (Distraction-free's petal-sidebar-hidden still wins to keep it closed.) */
|
||||||
|
.petal-sidebar:not(.petal-drawer-open) {
|
||||||
|
width: 0;
|
||||||
|
transform: translateX(-24px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.petal-scrim.petal-scrim-show {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 48px 0 0 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: rgba(61, 46, 57, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print / Save-as-PDF: strip every bit of app chrome and editing decoration so
|
||||||
|
only the title and the writing itself reach the page. This is Petal's PDF
|
||||||
|
path — it uses the browser's own fonts, so CJK renders correctly with no
|
||||||
|
server-side font embedding. */
|
||||||
|
/* --- Ambient falling petals -------------------------------------------------
|
||||||
|
The WebGL petal layer is a fixed, non-interactive overlay that drifts soft
|
||||||
|
blossoms over the page. Kept gentle (low opacity) so it never competes with
|
||||||
|
the writing. It hides itself in print and is skipped entirely for users who
|
||||||
|
prefer reduced motion (the component renders nothing in that case). */
|
||||||
|
.petal-fall {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Honor reduced-motion globally: still the looping ambient animations and drop
|
||||||
|
the petal layer. One-shot entrance/feedback animations are left intact. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.petal-fall {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.petal-bob,
|
||||||
|
.petal-bob-slow,
|
||||||
|
.petal-zzz,
|
||||||
|
.petal-checkpoint-dot {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.petal-fall,
|
||||||
|
.petal-no-print,
|
||||||
|
.petal-sidebar,
|
||||||
|
.petal-suggestion-card,
|
||||||
|
.petal-misspell-card,
|
||||||
|
.petal-gloss-tip,
|
||||||
|
.petal-selection-bubble,
|
||||||
|
.petal-rewrite-card,
|
||||||
|
.petal-confetti,
|
||||||
|
.petal-companion {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
html, body, #root {
|
||||||
|
height: auto !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
/* Drop the in-editor highlight underlines/tints — they're guidance, not ink. */
|
||||||
|
.petal-suggestion,
|
||||||
|
.petal-misspelling {
|
||||||
|
background: none !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
/* Let the writing use the full page width. */
|
||||||
|
.max-w-\[720px\] { max-width: none !important; }
|
||||||
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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