diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index c8aa513..95112ce 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -196,13 +196,20 @@ Script, app stopped, backup first (OPEN #4). **The "she logs in once first" depe - [x] `useSpellChecker` is server-backed: nspell loads, then the word list arrives from her account and is replayed in. A browser still holding the Phase-7 `petal.spell.personal` key hands it over on first load — but **only lets go of it once the server has accepted it**, so a failed request costs nothing. `addWord` takes effect in the editor immediately and persists in the background: the underline goes away the instant she asks, whatever the network is doing. A word list that fails to load costs correct words being flagged, never writing. - [x] Tests: `internal/spell/handlers_test.go` — lifecycle (idempotent add, bulk add, repeat delete, `[]` not `null`), languages-don't-merge incl. the case-normalisation case, junk rejection, and the standing-rule **two-user isolation** (mount twice behind two resolvers over one DB: Bob sees none of Alice's words, his identical word is his own row, his delete doesn't reach hers, deleting the account takes the dictionary with it). `web/src/lib/prefs.test.ts` — legacy adoption, move-not-copy, two accounts on one browser, never-overwrite, listener fires once per real change, storage-throws safety. - Verified: go build/vet/test, tsc, vite build, vitest 82/82 all clean; live smoke on a throwaway DB (:8073) — add/bulk-add/list/delete, pt-PT list independent of en, CJK word accepted, 400 on empty. -- ⚠️ **Not yet deployed, and the migration rehearsal against a copy of the live VPS database did not run** — the snapshot command was blocked by this session's permission classifier. `0011` is a plain `CREATE TABLE` with no table rebuild and no dependency on existing rows (unlike `0005`/`0010`), so the risk is low, but the convention is worth honouring before the container is rebuilt. +- [x] **Rehearsed, then deployed** (2026-07-27). The rehearsal the previous session couldn't run: `VACUUM INTO` snapshot of the live VPS database, pulled down, migrated by the Phase-18 binary — 11 migrations apply, every count unchanged (2 users, 8 documents, 33 versions, 103 suggestions, 3 vocab words, 1 image), FTS still matching, `integrity_check` and `foreign_key_check` both clean, `personal_words` present and empty. Then the deploy: off-box encrypted backup first, `git pull` + `docker compose up -d --build`, all three containers healthy, `0011` applied to the live DB with her writing untouched. Verified over public HTTPS: `/api/health` 200, `/api/docs` and the new `/api/spell/words` **401 without a session**. +- ⚠️ The authenticated live probe of `/api/spell/words` (hand-inserted session row, as Phases 16/17 used) was **blocked by this session's permission classifier** — minting a session token reads as credential fabrication. Not worked around. The endpoint's full lifecycle is covered by `internal/spell/handlers_test.go` and was smoke-tested end to end on a throwaway DB when it was built; what remains unproven in production is only that it answers 200 for a real cookie, which the shared middleware already governs for every other route. -### Phase 19 — Langpack extraction (the copy chore) +### Phase 19 — Langpack extraction (the copy chore) ✅ (2026-07-27) Pure refactor, zero visible change; prerequisite for every new pair (SUGGESTIONS §2, Q2 settled). -- [ ] Extract the `中文 · English` strings from the ~29 frontend files + companion `tips.ts` into a langpack copy module keyed by the pair's X; today's strings become the `zh` pack **verbatim** -- [ ] Parameterize `internal/llm/prompts.go` bilingual copy the same way (explanation language, "natives usually say…" framing, both-directions preamble) -- [ ] Wire pack selection to `users.pair_lang`; acceptance: pixel-identical UI for the zh pair, vitest snapshots unchanged +- [x] Every `中文 · English` string from the ~29 frontend files (plus `tips.ts`, `prose.ts`, `companions.ts`, `stats.ts`) now lives in `web/src/i18n`: `types.ts` (the `Pack` shape), `packs/zh.ts` (today's copy, **verbatim** — sentinel assertions in `i18n.test.ts` guard against a quiet rewording), `index.ts` (the accessor). +- [x] Two access paths, matching where copy is built: `usePack()` for components (a `useSyncExternalStore` subscription, so a pack arriving after first paint re-renders), and `pack()` for the modules that compose a line when something *happens* rather than when something renders — the companion and the prose checker read it at call time, never at import time. +- [x] **Anything with a value in it is a function on the pack**, not a template assembled at the call site (`reviewDue(n)`, `daysAgo(n)`, `duplicateTitle(title)`, every prose rule). Word order isn't universal; a pack author must be able to move the number. English pluralisation moved into the pack with it. +- [x] `Line` renamed its Mandarin half `zh` → `native` throughout (companion bubbles, tone/style pills, history badges, stat rows). `gradeBand` now returns a band *name* rather than a label, and the roster constants (`TONES`, `REWRITE_STYLES`, export formats, companions) keep only value + emoji — the label is a pack lookup keyed by the same value, with a test asserting no roster entry is unlabelled. +- [x] `internal/llm/lang.go`: the three prompts that *name* the writer's language — the collocation gloss, Ask Petal's "answer in her language", the explanation translator — take a `Lang` instead of saying "Simplified Chinese" outright. pt-PT is spelled **"European Portuguese (pt-PT, never Brazilian Portuguese)"** in the prompt itself, since a model that has read far more pt-BR needs telling. `Why` carries her word for "why" (为什么 / porquê / …) so the tutor prompt still recognises the question. An unknown code falls back rather than erroring — a prompt is the wrong place to discover a config problem. +- [x] Wired to `users.pair_lang` on both sides: `useSession` calls `setPackLang` the moment `/api/me` answers, and each LLM handler reads the column **in the row-scoped query it already ran** (the one that proves she owns the document) rather than in a second lookup that could disagree with it. +- Tests: `internal/llm/lang_test.go` (fallback matrix; each prompt names the writer's language and *not* Chinese; the zh pair reads exactly as before), `internal/suggestions/pairlang_test.go` (the column reaches the model for collocation + translate, zh unchanged — **verified to fail when the join is removed**), `web/src/i18n/i18n.test.ts` (default before `/api/me`, fallback for an unshipped pair, no spurious notifications, verbatim sentinels, interpolation incl. plurals, no empty string anywhere in the pack, every companion/tone/style labelled). +- Verified: go build/vet/test, tsc, vite build, vitest 90/90 clean; live smoke on a throwaway DB (:8074) — doc create/save, search, md export, spell add, warm 502 from the collocation pass with the LLM down; the built bundle still carries the zh strings. +- ⚠️ **Not yet deployed.** Pure refactor with no migration, so the deploy is a rebuild; still unshipped at the end of the session. ### Phase 20 — DreamDict as a lexicon provider Option 3 ratified (import package, read-only `dict.db`). **Prerequisite in the dreamdict repo:** rename its module path (or add a `replace` for dev). @@ -244,6 +251,7 @@ Each item independent and small; order within is free (SUGGESTIONS §5–§6). - [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above) ## Session log +- 2026-07-27: **Phase 18 deployed, and Phase 19 — the copy stops being hardcoded Mandarin** (user: "let's continue the build plan"; sequencing confirmed: rehearse + deploy 18, then start 19). The rehearsal the previous session was blocked from running went first: a `VACUUM INTO` snapshot of the live VPS database, migrated locally by the Phase-18 binary, every count unchanged and FTS/integrity/foreign keys clean, `personal_words` created empty — then the deploy itself (off-box encrypted backup, rebuild, all three containers healthy, `0011` applied to the live DB with her writing untouched, `/api/spell/words` 401 without a session over public HTTPS). **One check was refused and not worked around**: minting a probe session row to see the endpoint answer 200 for a real cookie reads as credential fabrication to this session's classifier; the endpoint's lifecycle is covered by tests and the shared middleware governs that last step for every other route. **Phase 19** then lifted every `中文 · English` literal out of ~29 files into `web/src/i18n` — one `Pack` type, a verbatim `zh` pack, and two access paths chosen by *when* copy is built: `usePack()` for components, `pack()` for the companion and prose checker, which compose a line when something happens rather than when something renders. The interesting decisions were about what a pack must be allowed to control: **every string with a value in it is a function** (`reviewDue(n)`, `daysAgo(n)`, even English pluralisation) because word order isn't universal; the roster constants keep only value + emoji so a label can never drift from its key; and `gradeBand` returns a band *name* rather than a label. On the server, `internal/llm/lang.go` replaces "Simplified Chinese" in the three prompts that name her language — with pt-PT spelled **"European Portuguese (pt-PT, never Brazilian Portuguese)"** in the prompt itself, and her word for "why" carried alongside so the tutor still recognises the question. `pair_lang` is read **in the row-scoped query each handler already ran**, not a second lookup that could disagree with it — and the test for that was checked by breaking the join and watching it fail. go build/vet/test, tsc, vite, vitest 90/90 clean; live smoke on a throwaway DB. **Phase 19 is not deployed** — no migration, so it's a rebuild whenever the user wants it. - 2026-07-27: **Phase 18 — the browser's settings become her settings** (user: "let's continue the build plan"). Two scope calls taken with the user: the personal spell dictionary goes **server-side** rather than being namespaced in place, and the pre-account `localStorage` keys are **adopted then rescoped** by the first writer to sign in. New `web/src/lib/prefs.ts` namespaces `petal.sound`/`petal.petals`/`petal.companion` by user id; the interesting part is timing — those modules read their value at *import* time, before `/api/me` can possibly have answered, so a pre-scope read deliberately sees the legacy key (the right value on a single-writer browser) and `setPrefsScope`, called from `useSession`, adopts it and notifies every reader. Adoption **moves** rather than copies, so account two starts from Petal's defaults instead of inheriting a stranger's mascot. New `internal/spell` package + migration `0011`: `personal_words` keyed `(user_id, lang, word)`, where `lang` is the **dictionary's** language, not the writer's — an English exception must not silence a pt-PT flag when the second pair ships. `useSpellChecker` now replays her list from her account, hands over any browser-held Phase-7 list on first load (releasing it only once the server has taken it), and persists an added word in the background so the underline vanishes the instant she asks. **The reason for the server table over cheaper namespacing**: keying the existing list by user in `localStorage` would have *fragmented* the words she already has across her laptop and tablet — the "cheap" fix was the one that made things worse. Tests: full lifecycle + languages-don't-merge + junk + the standing-rule two-user isolation suite in Go, and legacy-adoption/move-not-copy/two-accounts/storage-throws in vitest. go build/vet/test, tsc, vite, vitest 82/82 clean; live smoke on a throwaway DB. **Not deployed** — and the customary rehearsal of `0011` against a copy of the live VPS database was blocked by the session's permission classifier, so that check is outstanding (it is a plain `CREATE TABLE`, so lower-risk than `0005`/`0010`, but the convention exists for a reason). - 2026-07-27: **Phase 17 — Claire's writing moved onto her real account** (user: "claire is local user today in Petal. let's make sure to migrate existing data to her account"). `scripts/migrate_local_user.py`: dry-run by default, own `VACUUM INTO` backup, one transaction with foreign keys off, re-points `documents`/`tags`/`vocab_words`/`images`, verifies every expected row moved before committing. **The plan's stated prerequisite — "she logs in once so her sub exists" — turned out to be false**: authentik's `hashed_user_id` sub is `User.uid`, derived from her id and the instance secret, so it is readable in advance and the data could move *first*; she signs in to find her writing already there instead of to an empty Petal. Her 8 documents, 33 snapshots, 103 suggestions, 3 vocabulary words and 1 image now belong to `5f47d955…`, verified end to end over public HTTPS. Per the user's call the VPS is now canonical and millenia was left running and untouched as a frozen fallback (it diverges the moment either is written to — retire it rather than sync it). **Three bugs, each found by a different kind of contact with reality**: (1) the image backfill claims files for `local`, which stops existing after a migration — a foreign-key error inside `images.New`, which `main.go` treats as fatal, so Petal would have crash-looped on first start against a migrated database; (2) `BEGIN EXCLUSIVE` was the wrong liveness check, since in WAL mode it only conflicts with another *writer* and sails past a running-but-idle Petal — exactly the case the guard exists for; (3) the replacement, `PRAGMA locking_mode = EXCLUSIVE`, holds its lock past being reset to `NORMAL`, so on a real WAL database the script locked itself out of its own backup — invisible locally because the test file had come from `VACUUM INTO` and wasn't in WAL mode. Same shape as Phase 16's trailing-slash issuer: the fixture didn't look like production. - 2026-07-27: **Phase 16 built — Petal authenticates for itself** (user: "let's continue the build plan"; box access granted mid-session). New `internal/auth` surface on top of the Phase-0 `Resolver` seam: `session.go` (opaque cookie, **SHA-256-at-rest**, 30-day sliding expiry throttled to one write an hour, revoke/revoke-all/prune), `oidc.go` (login/callback/logout with state + nonce + PKCE, **lazy retried discovery** so an IdP outage can't stop Petal booting or invalidate live sessions), `users.go` (provisioning upsert keyed on `sub`, `/api/me`, allowlist). Migration `0010` lands `sessions`, `images` and `users.pair_lang` together. `main.go` picks the resolver from config, so a laptop build is unchanged. **Image ownership** closes the capability-URL hole flagged in the Phase-0 audit — one row per owner keeps dedup, a stranger gets 404 not 403, `Cache-Control` dropped to `private`, and pre-existing files are claimed at startup or they'd all 404. Frontend: a single 401 interceptor, a warm bilingual sign-in overlay over a still-visible editor, and a **draft rescue** to localStorage so an expired session can't cost writing — the auto-save stashes the body it couldn't send and reclaims it after re-login. **Three deliberate deviations from the plan**, all noted above: the allowlist matches emails as well as subject ids (a subject doesn't exist until first login, so a subject-only list is unusable in advance); `SESSION_SECRET` was dropped from config rather than left unused (nothing signs anything — sessions are opaque and server-side); and image rows are keyed `(name, user_id)` rather than owned singly, which is what preserves deduplication. **A real bug caught by writing the round-trip test rather than by reading the code**: the one-shot state/nonce/PKCE cookies were cleared in a `defer`, i.e. after the redirect had already written the header, so the clearing `Set-Cookie` was silently dropped. Verified: full go/tsc/vite/vitest suites, migration `0010` against a `VACUUM INTO` copy of the live millenia DB (counts intact, FTS still matching, image claimed), and a live smoke against the binary in both auth-off and auth-on modes including a hand-inserted session (valid → 200; absent/forged/expired → 401). **Then deployed** (user: "do it! register it!"): provider + application registered in Authentik via `ak shell`, `.env` filled in, image rebuilt, and the **Traefik basic-auth gate removed** — Petal holds its own door now. Deploying immediately found two things no test could: the issuer's **trailing slash is significant** (Authentik's has one, OIDC compares byte-for-byte, and my normalising it away broke discovery while the slashless stub kept passing — now a knob with a regression test), and a provider created through the shell rather than the admin UI comes up with **empty `grant_types`**, which authentik answers with `invalid_request` before the login page renders. Verified over public HTTPS: health 200, `/api/docs` 401 with no basic-auth challenge, `/auth/login` → Authentik with state+nonce+PKCE, following it lands on the real sign-in page. Also swapped the emoji favicon for a **drawn sakura** (`web/public/petal.svg`) that renders in Petal's own rose palette everywhere instead of at each platform's discretion, and doubles as the Authentik app tile (inlined as a data URI, since this authentik doesn't serve `/media`). **The allowlist is `prosolis@proton.me` only** — that IdP fronts ~40 accounts, so empty was not an option and guessing her account would either lock her out or let a stranger in; adding her is one `.env` line and a restart. diff --git a/internal/llm/checkpoint.go b/internal/llm/checkpoint.go index 8b9e3fc..2b818cb 100644 --- a/internal/llm/checkpoint.go +++ b/internal/llm/checkpoint.go @@ -41,7 +41,7 @@ type checkpointResponse struct { // RunCheckpoint sends the grammar checkpoint and parses the JSON result. It // applies the latency-guard truncation and the checkpoint sampling parameters // from the spec. -func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) { +func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string, _ Lang) ([]RawSuggestion, error) { raw, err := client.Complete(ctx, CompletionRequest{ Messages: CheckpointMessages(TruncateDoc(contentText), tone), MaxTokens: checkpointMaxTokens, diff --git a/internal/llm/collocation.go b/internal/llm/collocation.go index c6442a8..541ae78 100644 --- a/internal/llm/collocation.go +++ b/internal/llm/collocation.go @@ -18,10 +18,11 @@ const CollocationInterval = 25 * time.Second // reflect the full piece. Each flag carries a native replacement to apply. // // The tone argument is accepted for a uniform pass signature and passed through -// to the prompt so a hint can prefer a register-appropriate pairing. -func RunCollocation(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) { +// to the prompt so a hint can prefer a register-appropriate pairing. `lang` is +// the writer's pair language — the one each hint's short gloss is written in. +func RunCollocation(ctx context.Context, client LLMClient, contentText, tone string, lang Lang) ([]RawSuggestion, error) { raw, err := client.Complete(ctx, CompletionRequest{ - Messages: CollocationMessages(contentText, tone), + Messages: CollocationMessages(contentText, tone, lang), MaxTokens: 2048, Temperature: 0.3, RepetitionPenalty: 1.15, diff --git a/internal/llm/lang.go b/internal/llm/lang.go new file mode 100644 index 0000000..b9fb5a7 --- /dev/null +++ b/internal/llm/lang.go @@ -0,0 +1,52 @@ +package llm + +import "strings" + +// The pair language, as the prompts need to talk about it. +// +// Three of Petal's prompts name the writer's first language rather than merely +// being written in English: the collocation coach asks for a gloss in it, Ask +// Petal offers to answer in it, and the explanation translator renders into it. +// Before Phase 19 all three said "Simplified Chinese" outright, which made the +// zh pair the only one that could ever work. +// +// A Lang is not a translation of the prompt — the instructions stay in English, +// which is what the model follows best. It is the name the model should use for +// her language, plus the one word it should watch for when she writes in it. +type Lang struct { + // Code matches users.pair_lang. + Code string + // Name is how the prompt refers to the language, spelled the way a model + // recognises it. Regional precision matters here: "European Portuguese" is + // not "Portuguese" to a model that has read far more pt-BR than pt-PT. + Name string + // Why asks the same thing she would ask in her own language. It goes into + // the Ask Petal prompt as an example, so a model that answers only to + // English "why" still recognises the question when she types it her way. + Why string +} + +// langs holds every pair Petal can currently be a partner in. A language with a +// frontend langpack but no entry here still works — it falls back to zh's +// behaviour of the prompts, which is wrong but not broken — so keep the two in +// step when a pair ships. +var langs = map[string]Lang{ + "zh": {Code: "zh", Name: "Simplified Chinese (Mandarin)", Why: "为什么"}, + "pt-PT": {Code: "pt-PT", Name: "European Portuguese (pt-PT, never Brazilian Portuguese)", Why: "porquê"}, + "fr": {Code: "fr", Name: "French", Why: "pourquoi"}, + "es": {Code: "es", Name: "Spanish", Why: "por qué"}, +} + +// DefaultLang is the pair assumed when none is known — the column's default, and +// the only pair that existed before Phase 19. +var DefaultLang = langs["zh"] + +// LangFor resolves a users.pair_lang value. An empty or unrecognised code falls +// back to the default rather than erroring: a prompt is not the place to +// discover a configuration problem, and the writing still has to be checked. +func LangFor(code string) Lang { + if l, ok := langs[strings.TrimSpace(code)]; ok { + return l + } + return DefaultLang +} diff --git a/internal/llm/lang_test.go b/internal/llm/lang_test.go new file mode 100644 index 0000000..eab541e --- /dev/null +++ b/internal/llm/lang_test.go @@ -0,0 +1,85 @@ +package llm + +import ( + "strings" + "testing" +) + +func TestLangForFallsBackToDefault(t *testing.T) { + if got := LangFor("zh"); got.Code != "zh" { + t.Fatalf("LangFor(zh) = %+v", got) + } + if got := LangFor("pt-PT"); got.Code != "pt-PT" { + t.Fatalf("LangFor(pt-PT) = %+v", got) + } + // A blank column, a stray value, and stray whitespace all resolve rather + // than erroring — a prompt is the wrong place to discover a config problem. + for _, in := range []string{"", " ", "klingon", "ZH"} { + if got := LangFor(in); got.Code != DefaultLang.Code { + t.Fatalf("LangFor(%q) = %q, want the default %q", in, got.Code, DefaultLang.Code) + } + } + if got := LangFor(" zh "); got.Code != "zh" { + t.Fatalf("LangFor with padding = %+v", got) + } +} + +// The three prompts that name the writer's language must actually name *hers*. +// Before Phase 19 all three said "Simplified Chinese" outright, which is the +// bug this guards: a pt-PT writer asking "porquê" would have been answered in +// Mandarin. +func TestPromptsNameTheWritersLanguage(t *testing.T) { + pt := LangFor("pt-PT") + + collocation := CollocationMessages("The rain was strong.", "casual", pt)[0].Content + if !strings.Contains(collocation, "European Portuguese") { + t.Fatalf("collocation prompt doesn't ask for a pt-PT gloss:\n%s", collocation) + } + if strings.Contains(collocation, "Simplified Chinese") { + t.Fatalf("collocation prompt still hardcodes Chinese:\n%s", collocation) + } + // The tone steering must survive alongside the language — they share one + // format string, and getting the verbs in the wrong order silently drops one. + if !strings.Contains(collocation, "relaxed, friendly, and conversational") { + t.Fatalf("collocation prompt lost its tone guidance:\n%s", collocation) + } + + translate := TranslateMessages("Try a shorter sentence here.", pt)[0].Content + if !strings.Contains(translate, "European Portuguese") || strings.Contains(translate, "Chinese") { + t.Fatalf("translate prompt targets the wrong language:\n%s", translate) + } + + ask := AskPetalSystemPrompt("origin", "replacement", "grammar", "explanation", "paragraph", pt) + if !strings.Contains(ask, "European Portuguese") || strings.Contains(ask, "Mandarin") { + t.Fatalf("ask-petal prompt targets the wrong language:\n%s", ask) + } + if !strings.Contains(ask, "porquê") { + t.Fatalf("ask-petal prompt doesn't recognise her word for \"why\":\n%s", ask) + } + // The suggestion context is positional in that template; a mis-numbered + // verb would quietly blank one of these fields. + for _, want := range []string{"origin", "replacement", "grammar", "explanation", "paragraph"} { + if !strings.Contains(ask, want) { + t.Fatalf("ask-petal prompt dropped %q:\n%s", want, ask) + } + } + if strings.Contains(ask, "%!") { + t.Fatalf("ask-petal prompt has a formatting error:\n%s", ask) + } +} + +// The zh pair is in daily use and must be untouched by the extraction: its +// prompts should read exactly as they did when they were hardcoded. +func TestDefaultPairStillReadsAsBefore(t *testing.T) { + zh := LangFor("zh") + + if got := CollocationMessages("x", "", zh)[0].Content; !strings.Contains(got, "Simplified Chinese (Mandarin) gloss in parentheses") { + t.Fatalf("zh collocation gloss changed:\n%s", got) + } + if got := TranslateMessages("x", zh)[0].Content; !strings.Contains(got, "natural, friendly Simplified Chinese (Mandarin)") { + t.Fatalf("zh translate target changed:\n%s", got) + } + if got := AskPetalSystemPrompt("a", "b", "c", "d", "e", zh); !strings.Contains(got, "为什么") { + t.Fatalf("zh ask-petal lost its Mandarin \"why\":\n%s", got) + } +} diff --git a/internal/llm/prompts.go b/internal/llm/prompts.go index d564052..eb68706 100644 --- a/internal/llm/prompts.go +++ b/internal/llm/prompts.go @@ -97,8 +97,8 @@ func VoiceMessages(contentText string) []Message { // just non-native ("do a decision" → "make a decision", "strong rain" → "heavy // rain"), and explicitly DEFERS real grammar/spelling errors to the grammar // checkpoint so the two families don't overlap. Every explanation is framed as a -// warm "natives usually say…" note with a short Mandarin gloss — never -// "error/wrong" — because these are stylistic, not mistakes. It is a distinct +// warm "natives usually say…" note with a short gloss in the writer's own +// language — never "error/wrong" — because these are stylistic, not mistakes. It is a distinct // pass from the grammar checkpoint (do not bundle them). `replacement` carries // the natural pairing the writer can accept in one tap. const collocationSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` + @@ -113,7 +113,7 @@ Identify up to 5 such non-native word pairings. For each, give the natural pairi `Be gentle and specific. Do NOT flag grammar errors, spelling mistakes, or unclear sentences — those are handled ` + `elsewhere. Only flag word pairings that are correct but sound non-native.%s -Phrase every explanation warmly as "Natives usually say…" and include a brief Simplified Chinese gloss in parentheses. ` + +Phrase every explanation warmly as "Natives usually say…" and include a brief %s gloss in parentheses. ` + `Never use the words "error", "wrong", or "mistake" — these are friendly polish, not corrections. Respond ONLY with valid JSON. No preamble, no markdown fences. Format: @@ -132,10 +132,11 @@ If every pairing already sounds natural, return: {"suggestions": []}` // CollocationMessages builds the message array for a collocation pass over the // WHOLE document (no truncation), gently steered toward the document's tone so a -// hint can prefer a register-appropriate pairing. -func CollocationMessages(contentText, tone string) []Message { +// hint can prefer a register-appropriate pairing. The parenthetical gloss is +// written in the writer's own language. +func CollocationMessages(contentText, tone string, lang Lang) []Message { return []Message{ - {Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone))}, + {Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone), lang.Name)}, {Role: "user", Content: contentText}, } } @@ -147,26 +148,27 @@ const askPetalSystemTemplate = `You are Petal, a warm and patient English writin `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" +- Original text: "%[1]s" +- Suggested replacement: "%[2]s" +- Issue type: %[3]s +- Initial explanation: "%[4]s" +- Surrounding paragraph: "%[5]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. + `and respond in that same language. If they write in %[6]s, respond entirely in ` + + `%[6]s. 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. ` + + `when helpful. If they ask "why" (or "%[7]s"), 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) +// AskPetalSystemPrompt fills the tutor prompt with one suggestion's context and +// the writer's pair language, which is the one she may ask her question in. +func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string, lang Lang) string { + return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph, lang.Name, lang.Why) } // rewriteSystemTemplate drives the "say it more naturally" / tone-rewrite tool. @@ -215,22 +217,23 @@ func RewriteMessages(text, style string) []Message { } // translateSystemPrompt drives the explanation translator: it renders a -// suggestion's English explanation into Simplified Chinese so an ESL reader sees -// the "why" in her first language. Strict about returning ONLY the translation -// (no quotes, no pinyin, no English echo) so it can drop straight into the chat -// bubble. Kept warm and plain — these are short, friendly one-liners. +// suggestion's English explanation into the writer's own language so an ESL +// reader sees the "why" in her first language. Strict about returning ONLY the +// translation (no quotes, no romanisation, no English echo) so it can drop +// straight into the chat bubble. Kept warm and plain — these are short, friendly +// one-liners. const translateSystemPrompt = `You are Petal, a warm writing assistant. Translate the English text the user ` + - `sends into natural, friendly Simplified Chinese (Mandarin). It is a short explanation of a writing ` + - `suggestion, written for a native Chinese speaker learning English. + `sends into natural, friendly %[1]s. It is a short explanation of a writing ` + + `suggestion, written for a native %[1]s speaker learning English. -Respond with ONLY the Simplified Chinese translation. No quotation marks, no pinyin, no English, no preamble — ` + +Respond with ONLY the %[1]s translation. No quotation marks, no romanisation, no English, no preamble — ` + `just the translated sentence.` // TranslateMessages builds the message array for translating one short English -// explanation into Simplified Chinese. -func TranslateMessages(text string) []Message { +// explanation into the writer's own language. +func TranslateMessages(text string, lang Lang) []Message { return []Message{ - {Role: "system", Content: translateSystemPrompt}, + {Role: "system", Content: fmt.Sprintf(translateSystemPrompt, lang.Name)}, {Role: "user", Content: text}, } } diff --git a/internal/llm/translate.go b/internal/llm/translate.go index f74c767..64f9650 100644 --- a/internal/llm/translate.go +++ b/internal/llm/translate.go @@ -4,13 +4,13 @@ import ( "context" ) -// RunTranslate renders a short English explanation into Simplified Chinese. It -// is a one-shot Complete (the result seeds the Ask Petal bubble), kept at a low -// temperature so the translation is faithful rather than creative. Output is +// RunTranslate renders a short English explanation into the writer's own +// language. It is a one-shot Complete (the result seeds the Ask Petal bubble), +// kept at a low temperature so the translation is faithful rather than creative. Output is // trimmed of any stray surrounding quotes the model may add. -func RunTranslate(ctx context.Context, client LLMClient, text string) (string, error) { +func RunTranslate(ctx context.Context, client LLMClient, text string, lang Lang) (string, error) { out, err := client.Complete(ctx, CompletionRequest{ - Messages: TranslateMessages(text), + Messages: TranslateMessages(text, lang), MaxTokens: 512, Temperature: 0.2, TopP: 0.9, diff --git a/internal/llm/voice.go b/internal/llm/voice.go index fdb5512..ea4aaa0 100644 --- a/internal/llm/voice.go +++ b/internal/llm/voice.go @@ -19,7 +19,7 @@ const VoiceInterval = 20 * time.Second // 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) { +func RunVoice(ctx context.Context, client LLMClient, contentText, _ string, _ Lang) ([]RawSuggestion, error) { raw, err := client.Complete(ctx, CompletionRequest{ Messages: VoiceMessages(contentText), MaxTokens: 2048, diff --git a/internal/suggestions/chat.go b/internal/suggestions/chat.go index 5a33074..5218d74 100644 --- a/internal/suggestions/chat.go +++ b/internal/suggestions/chat.go @@ -40,14 +40,17 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) { original, replacement, explanation, typ string fromPos int contentText string + pairLang string ) err := h.DB.QueryRow( - `SELECT s.original, s.replacement, s.explanation, s.type, s.from_pos, d.content_text + `SELECT s.original, s.replacement, s.explanation, s.type, s.from_pos, d.content_text, + COALESCE(u.pair_lang, '') FROM suggestions s JOIN documents d ON d.id = s.doc_id + JOIN users u ON u.id = d.user_id WHERE s.id = ? AND d.user_id = ?`, sugID, auth.UserID(r.Context()), - ).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText) + ).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText, &pairLang) if errors.Is(err, sql.ErrNoRows) { httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found") return @@ -58,7 +61,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) { } paragraph := surroundingParagraph(contentText, fromPos) - systemPrompt := llm.AskPetalSystemPrompt(original, replacement, typ, explanation, paragraph) + systemPrompt := llm.AskPetalSystemPrompt(original, replacement, typ, explanation, paragraph, llm.LangFor(pairLang)) // SSE requires an unbuffered, flushable writer. chi's middleware writers pass // Flush through; bail with a plain error if somehow they don't. diff --git a/internal/suggestions/handlers.go b/internal/suggestions/handlers.go index 7633d68..22cc8ca 100644 --- a/internal/suggestions/handlers.go +++ b/internal/suggestions/handlers.go @@ -194,9 +194,12 @@ func (h *Handler) collocation(w http.ResponseWriter, r *http.Request) { } // 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) +// given the document text, the document's tone and the writer's pair language it +// returns the model's raw suggestions. The voice pass ignores both extras (see +// llm.RunVoice) and the checkpoint ignores the language — only the collocation +// coach writes a word of it — but one signature keeps runPass free of special +// cases. +type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string, lang llm.Lang) ([]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 @@ -206,11 +209,17 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R docID := chi.URLParam(r, "id") userID := auth.UserID(r.Context()) - var contentText, tone string + // The writer's pair language rides along with the document rather than in a + // second query: it is read from the same row-scoped lookup that already + // proves she owns this document. + var contentText, tone, pairLang string err := h.DB.QueryRow( - `SELECT content_text, tone FROM documents WHERE id = ? AND user_id = ?`, + `SELECT d.content_text, d.tone, COALESCE(u.pair_lang, '') + FROM documents d + JOIN users u ON u.id = d.user_id + WHERE d.id = ? AND d.user_id = ?`, docID, userID, - ).Scan(&contentText, &tone) + ).Scan(&contentText, &tone, &pairLang) if errors.Is(err, sql.ErrNoRows) { httputil.ErrorJSON(w, http.StatusNotFound, "document not found") return @@ -239,7 +248,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R return } - raw, err := run(r.Context(), h.Client, contentText, tone) + raw, err := run(r.Context(), h.Client, contentText, tone, llm.LangFor(pairLang)) if err != nil { // Allow ran before the model call, so a failed pass would otherwise hold // the per-document slot for the full interval — stranding the frontend's diff --git a/internal/suggestions/pairlang_test.go b/internal/suggestions/pairlang_test.go new file mode 100644 index 0000000..24f3c27 --- /dev/null +++ b/internal/suggestions/pairlang_test.go @@ -0,0 +1,109 @@ +package suggestions + +import ( + "net/http" + "path/filepath" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "gitea.parodia.dev/drwily/petal/internal/auth" + "gitea.parodia.dev/drwily/petal/internal/db" + "gitea.parodia.dev/drwily/petal/internal/llm" +) + +// The langpack decides what Petal says in the browser; users.pair_lang has to +// decide what the *model* says too, or a pt-PT writer gets a Mandarin gloss on +// an otherwise Portuguese screen. These tests follow the value from the column +// to the system prompt for each pass that names a language. +// +// This is the same failure mode the standing isolation rule guards against: the +// column is read in a query the handler already ran, so nothing fails loudly if +// the join is dropped — the prompt just quietly reverts to Mandarin. + +// newPairServer seeds one writer on the given pair with a document of her own. +func newPairServer(t *testing.T, client llm.LLMClient, pairLang string) (http.Handler, string, *db.DB) { + t.Helper() + database, err := db.Open(filepath.Join(t.TempDir(), "pair.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { database.Close() }) + + const userID = "writer-pt" + if _, err := database.Exec( + `INSERT INTO users (id, email, display_name, pair_lang) VALUES (?, ?, ?, ?)`, + userID, "w@example.com", "Writer", pairLang, + ); err != nil { + t.Fatalf("seed user: %v", err) + } + + var docID string + if err := database.QueryRow( + `INSERT INTO documents (user_id, content_text) VALUES (?, ?) RETURNING id`, + userID, "The rain was strong yesterday.", + ).Scan(&docID); 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 auth.Middleware(auth.StaticResolver(userID))(r), docID, database +} + +func TestCollocationPromptUsesTheWritersPair(t *testing.T) { + client := &recordingClient{response: `{"suggestions":[]}`} + srv, docID, _ := newPairServer(t, client, "pt-PT") + + rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "") + if rec.Code != http.StatusOK { + t.Fatalf("collocation: code=%d body=%s", rec.Code, rec.Body) + } + system := client.last.Messages[0].Content + if !strings.Contains(system, "European Portuguese") { + t.Fatalf("collocation prompt ignored pair_lang:\n%s", system) + } + if strings.Contains(system, "Simplified Chinese") { + t.Fatalf("collocation prompt fell back to Mandarin:\n%s", system) + } +} + +func TestTranslatePromptUsesTheWritersPair(t *testing.T) { + client := &recordingClient{response: "Chove muito."} + srv, docID, database := newPairServer(t, client, "pt-PT") + + var sugID string + if err := database.QueryRow( + `INSERT INTO suggestions (doc_id, original, replacement, explanation, type, from_pos, to_pos) + VALUES (?, ?, ?, ?, ?, 0, 5) RETURNING id`, + docID, "strong rain", "heavy rain", "Natives usually say heavy rain.", "collocation", + ).Scan(&sugID); err != nil { + t.Fatalf("seed suggestion: %v", err) + } + + rec := do(t, srv, http.MethodPost, "/suggestions/"+sugID+"/translate", "") + if rec.Code != http.StatusOK { + t.Fatalf("translate: code=%d body=%s", rec.Code, rec.Body) + } + system := client.last.Messages[0].Content + if !strings.Contains(system, "European Portuguese") || strings.Contains(system, "Chinese") { + t.Fatalf("translate prompt ignored pair_lang:\n%s", system) + } +} + +// A writer whose column still holds the default — every account today — must be +// answered exactly as before. +func TestDefaultPairIsUnchanged(t *testing.T) { + client := &recordingClient{response: `{"suggestions":[]}`} + srv, docID, _ := newPairServer(t, client, "zh") + + if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", ""); rec.Code != http.StatusOK { + t.Fatalf("collocation: code=%d body=%s", rec.Code, rec.Body) + } + if system := client.last.Messages[0].Content; !strings.Contains(system, "Simplified Chinese (Mandarin) gloss") { + t.Fatalf("zh writer no longer gets a Mandarin gloss:\n%s", system) + } +} diff --git a/internal/suggestions/translate.go b/internal/suggestions/translate.go index 6045c60..21d56c5 100644 --- a/internal/suggestions/translate.go +++ b/internal/suggestions/translate.go @@ -25,14 +25,15 @@ type translateResponse struct { func (h *Handler) translate(w http.ResponseWriter, r *http.Request) { sugID := chi.URLParam(r, "id") - var explanation string + var explanation, pairLang string err := h.DB.QueryRow( - `SELECT s.explanation + `SELECT s.explanation, COALESCE(u.pair_lang, '') FROM suggestions s JOIN documents d ON d.id = s.doc_id + JOIN users u ON u.id = d.user_id WHERE s.id = ? AND d.user_id = ?`, sugID, auth.UserID(r.Context()), - ).Scan(&explanation) + ).Scan(&explanation, &pairLang) if errors.Is(err, sql.ErrNoRows) { httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found") return @@ -48,7 +49,7 @@ func (h *Handler) translate(w http.ResponseWriter, r *http.Request) { return } - out, err := llm.RunTranslate(r.Context(), h.Client, explanation) + out, err := llm.RunTranslate(r.Context(), h.Client, explanation, llm.LangFor(pairLang)) if err != nil { httputil.ErrorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error()) return diff --git a/web/src/App.tsx b/web/src/App.tsx index c2d69cf..d432667 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -18,6 +18,7 @@ import { useSession } from './hooks/useSession' import { takeDraft } from './lib/drafts' import { useVersionWatch } from './hooks/useVersionWatch' import { PetalFall } from './effects/PetalFall' +import { usePack } from './i18n' import { useNightMode } from './hooks/useNightMode' import { playSuggestionSound } from './audio/sounds' @@ -30,6 +31,7 @@ export default function App() { // Who's writing, and whether the server still recognises them. `signedOut` // flips the moment any call comes back 401. const { me, signedOut } = useSession() + const t = usePack() // A real account to sign out of, as opposed to the hardcoded local user a // build without auth configured runs as. const account = me && me.id !== 'local' ? { name: me.display_name || me.email } : null @@ -239,7 +241,8 @@ export default function App() { setDocText('') }, [saveNow, isBlankDraft]) - // Duplicate a document: copy its body/tone into a fresh doc titled "… (副本)", + // Duplicate a document: copy its body/tone into a fresh doc under the pack's + // "copy" title, // then open the copy. Tags aren't carried over (a fresh start for the copy). const handleDuplicate = useCallback( async (id: string) => { @@ -247,7 +250,7 @@ export default function App() { await saveNow() // flush in case we're duplicating the open doc const src = await api.getDoc(id) const fresh = await api.createDoc() - const dupTitle = `${src.title?.trim() || 'Untitled'} (副本)` + const dupTitle = t.app.duplicateTitle(src.title?.trim() || 'Untitled') const updated = await api.updateDoc(fresh.id, { title: dupTitle, content: src.content, @@ -451,7 +454,7 @@ export default function App() { }} > 🌷 - 词汇花园 + {t.app.garden} · Garden @@ -514,7 +517,7 @@ export default function App() { }} > 🕘 - 历史 + {t.app.history} · History
diff --git a/web/src/components/Auth/SignInOverlay.tsx b/web/src/components/Auth/SignInOverlay.tsx index 062b451..71f1a63 100644 --- a/web/src/components/Auth/SignInOverlay.tsx +++ b/web/src/components/Auth/SignInOverlay.tsx @@ -6,6 +6,8 @@ // again as an errand, not an error. The editor stays visible behind the scrim // (dimmed, still there) for the same reason: nothing has been taken away. +import { usePack } from '../../i18n' + interface Props { // Whether there is unsaved writing waiting on this device, which changes the // reassurance from a promise to a statement of fact. @@ -13,6 +15,7 @@ interface Props { } export function SignInOverlay({ hasDraft }: Props) { + const t = usePack() return (
- 请重新登录 + {t.auth.title}

- Please sign in again + {t.auth.titleEn}

- {hasDraft - ? '你刚写的内容已经安全地留在这台电脑上,登录后会自动接着保存。' - : '登录状态过期了。你的文字都已经保存好了。'} + {hasDraft ? t.auth.bodyWithDraft : t.auth.bodyPlain}

- {hasDraft - ? "What you just wrote is safe on this device — it'll save itself once you're back in." - : 'Your session expired. Everything you wrote is already saved.'} + {hasDraft ? t.auth.bodyWithDraftEn : t.auth.bodyPlainEn}

(e.currentTarget.style.background = 'var(--color-accent-hover)')} onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')} > - 去登录 · Sign in + {t.auth.signIn}
diff --git a/web/src/components/Companion/PetalCompanion.tsx b/web/src/components/Companion/PetalCompanion.tsx index 95573f5..5d984aa 100644 --- a/web/src/components/Companion/PetalCompanion.tsx +++ b/web/src/components/Companion/PetalCompanion.tsx @@ -4,6 +4,7 @@ import { useCompanion, type Mood } from './useCompanion' import { LottiePlayer } from './LottiePlayer' import { COMPANIONS, DEFAULT_COMPANION } from './companions' import { onPrefsScopeChange, readPref, writePref } from '../../lib/prefs' +import { usePack } from '../../i18n' interface Props { wordCount: number @@ -30,6 +31,7 @@ const STORAGE_KEY = 'petal.companion' // break reminders. Clicking the mascot opens a picker to switch companions // (the choice persists in localStorage). export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Props) { + const t = usePack() const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({ wordCount, saveStatus, @@ -115,7 +117,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep className="px-2 pb-1 pt-0.5 text-[0.7rem] font-bold" style={{ color: 'var(--color-muted)' }} > - 选个小伙伴 · Choose a companion + {t.companion.choose}

{COMPANIONS.map((c) => { const active = c.id === companion.id @@ -138,7 +140,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep > {c.emoji} - {c.zh}{' '} + {t.companion.names[c.id] ?? c.name}{' '} {c.name} {active && } @@ -169,7 +171,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep className="font-bold leading-snug" style={{ color: 'var(--color-plum)', fontSize: '1.4rem' }} > - {bubble.zh} + {bubble.native}

> @@ -26,7 +25,6 @@ export const COMPANIONS: Companion[] = [ { id: 'cat', name: 'Sleepy Cat', - zh: '瞌睡猫', emoji: '😴', alwaysAsleep: true, animations: { @@ -40,7 +38,6 @@ export const COMPANIONS: Companion[] = [ { id: 'dog', name: 'Happy Dog', - zh: '开心狗', emoji: '🐶', animations: { idle: happyDog, @@ -53,7 +50,6 @@ export const COMPANIONS: Companion[] = [ { id: 'wiggle-dog', name: 'Wiggle Dog', - zh: '摇尾狗', emoji: '🐕', animations: { idle: wiggleDog, @@ -65,7 +61,6 @@ export const COMPANIONS: Companion[] = [ { id: 'butterfly', name: 'Butterfly', - zh: '蝴蝶', emoji: '🦋', animations: { idle: butterfly, @@ -77,7 +72,6 @@ export const COMPANIONS: Companion[] = [ { id: 'parrot', name: 'Parrot', - zh: '鹦鹉', emoji: '🦜', flip: true, // asset faces left; mirror it to face into the page animations: { diff --git a/web/src/components/Companion/prose.ts b/web/src/components/Companion/prose.ts index c85a6e1..dfe2e7c 100644 --- a/web/src/components/Companion/prose.ts +++ b/web/src/components/Companion/prose.ts @@ -5,7 +5,8 @@ // 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 +// Each finding becomes a native-language-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. // @@ -17,8 +18,13 @@ // splices, …) carry no `fix` and stay companion bubbles. The companion hides // fix-bearing hints so the same span never appears as both a bubble and a card. +import { pack } from '../../i18n' import type { Line } from './tips' +// The pair's prose copy. Read per finding rather than captured once, so a rule +// that fires after the writer's language is known speaks the right one. +const P = () => pack().prose + 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. @@ -108,7 +114,7 @@ function runOns(text: string, out: ProseHint[]) { out.push({ id: `runon:${key(s)}`, rule: 'runon', - zh: '这句话有点长啦,分成两三句会更清楚 🌸', + native: P().longSentence, en: `This one runs long — try splitting it: “${anchor(s)}”`, }) } @@ -170,7 +176,7 @@ function commaSplices(text: string, out: ProseHint[]) { out.push({ id: `splice:${key(m[0])}`, rule: 'splice', - zh: '这里用逗号连了两句话,可以改成句号,或加个 “and / but”。', + native: P().commaSplice, en: `Two sentences joined by a comma: “…${m[0].trim()}…” — use a period or add a joining word.`, }) } @@ -195,7 +201,7 @@ function antecedents(text: string, out: ProseHint[]) { out.push({ id: `antecedent:${key(s)}`, rule: 'antecedent', - zh: `“${m[1]}” 指代不太清楚,最好点明它指的是什么(比如 “${m[1]} idea / change…”)。`, + native: P().vagueThis(m[1]), en: `“${m[1]}” here is a little vague — name what it refers to.`, }) } @@ -226,7 +232,7 @@ function oxford(text: string, out: ProseHint[]) { out.push({ id: `oxford:${key(m[0])}`, rule: 'oxford', - zh: '列举三样以上时,在 “and / or” 前也加个逗号会更清楚(牛津逗号)。', + native: P().oxfordComma, en: `In a list, a comma before “${m[3]}” keeps it clear: “a, b, ${m[3]} c”.`, }) } @@ -246,7 +252,7 @@ function introComma(text: string, out: ProseHint[]) { out.push({ id: `introcomma:${m[1].toLowerCase()}`, rule: 'introcomma', - zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`, + native: P().transitionComma(m[1]), en: `Put a comma after the opening transition: “${m[1]}, …”.`, }) break @@ -265,7 +271,7 @@ function sentenceCaps(text: string, out: ProseHint[]) { out.push({ id: 'cap-sentence', rule: 'cap-sentence', - zh: '每个句子的开头,用大写字母开始吧。', + native: P().capitalizeSentence, en: 'Start each new sentence with a capital letter.', }) } @@ -289,7 +295,7 @@ function doubles(text: string, out: ProseHint[]) { out.push({ id: `double:${from}:${key(m[0])}`, rule: 'double', - zh: `“${m[1]}” 好像写了两遍,检查一下哦。`, + native: P().repeatedWord(m[1]), en: `“${m[1]} ${m[1]}” — looks like a word got doubled.`, fix: { from, to, replacement: m[1] }, }) @@ -311,7 +317,7 @@ function pronounI(text: string, out: ProseHint[]) { out.push({ id: 'cap-i', rule: 'cap-i', - zh: '英文里的 “I”(我)任何时候都要大写哦。', + native: P().capitalizeI, en: 'In English, “I” is always written as a capital letter.', }) } @@ -328,7 +334,7 @@ function spaceBeforePunct(text: string, out: ProseHint[]) { out.push({ id: `space-punct:${from}`, rule: 'space-punct', - zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。', + native: P().spaceBeforePunct, en: 'No space before punctuation — it tucks right against the word.', fix: { from, to, replacement: m[1] + m[2] }, }) @@ -351,7 +357,7 @@ function spaceAfterPunct(text: string, out: ProseHint[]) { out.push({ id: `space-after:${from}`, rule: 'space-after', - zh: '逗号、句号后面要空一格,再接下一个词。', + native: P().spaceAfterPunct, en: 'Add a space after a comma or period before the next word.', fix: { from, to, replacement: `${m[1]} ${m[2]}` }, }) @@ -378,7 +384,7 @@ function articles(text: string, out: ProseHint[]) { out.push({ id: `article-an:${m.index}`, rule: 'article', - zh: `元音开头的词前用 “an”:“an ${m[2]}”。`, + native: P().articleAn(m[2]), en: `Before a vowel sound, use “an”: “an ${m[2]}”.`, fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'an') + m[0].slice(m[1].length) }, }) @@ -389,7 +395,7 @@ function articles(text: string, out: ProseHint[]) { out.push({ id: `article-a:${m.index}`, rule: 'article', - zh: `辅音开头的词前用 “a”:“a ${m[2]}”。`, + native: P().articleA(m[2]), en: `Before a consonant sound, use “a”: “a ${m[2]}”.`, fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'a') + m[0].slice(m[1].length) }, }) @@ -410,7 +416,7 @@ function uncountables(text: string, out: ProseHint[]) { out.push({ id: `uncountable:${m.index}`, rule: 'uncountable', - zh: `“${word}” 是不可数名词,不用加 s,写 “${singular}” 就好。`, + native: P().uncountable(word, singular), en: `“${word}” is uncountable — drop the “s”: just “${singular}”.`, fix: { from: m.index, to: m.index + word.length, replacement: singular }, }) @@ -439,7 +445,7 @@ function properCaps(text: string, out: ProseHint[]) { out.push({ id: `propercap:${m.index}`, rule: 'propercap', - zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`, + native: P().capitalizeProper(fixed), en: `Languages, days, and months are capitalized in English: “${fixed}”.`, fix: { from: m.index, to: m.index + word.length, replacement: fixed }, }) @@ -485,7 +491,7 @@ function subjectVerbAgreement(text: string, out: ProseHint[]) { out.push({ id: `sva:${m.index}`, rule: 'sva', - zh: `主语是 he/she/it 时,动词要加 -s:“${m[2]} ${fixed}”。`, + native: P().thirdPersonS(m[2], fixed), en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`, fix: { from: m.index, to: m.index + m[0].length, replacement: head + matchCase(verb, fixed) }, }) @@ -515,7 +521,7 @@ function pluralAfterNumber(text: string, out: ProseHint[]) { out.push({ id: `plural:${m.index}`, rule: 'plural', - zh: `“${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`, + native: P().pluralAfter(m[1], m[2]), en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`, fix: { from: m.index, to: m.index + m[0].length, replacement: `${m[0]}s` }, }) @@ -537,7 +543,7 @@ function doubleDeterminer(text: string, out: ProseHint[]) { out.push({ id: `doubledet:${m.index}`, rule: 'doubledet', - zh: `“${m[1]} ${m[3]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`, + native: P().doubleDeterminer(m[1], m[3]), en: `“${m[1]} ${m[3]}” stacks two determiners — keep just one.`, // Drop the article (m[1]); keep the second determiner, casing preserved. fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], m[3]) }, @@ -561,7 +567,7 @@ function thereIsPlural(text: string, out: ProseHint[]) { out.push({ id: `thereis:${m.index}`, rule: 'thereis', - zh: `后面是复数时用 “there are”:“there are ${m[2]}…”。`, + native: P().thereArePlural(m[2]), en: `With a plural, use “there are”: “there are ${m[2]}…”.`, fix: { from: m.index, to: m.index + m[0].length, replacement }, }) @@ -581,7 +587,7 @@ function itsConfusion(text: string, out: ProseHint[]) { out.push({ id: `its-own:${m.index}`, rule: 'its', - zh: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。', + native: P().itsOwn, en: '“it’s” means “it is” — the possessive is “its”: “its own”.', fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'its') + m[0].slice(m[1].length) }, }) @@ -592,7 +598,7 @@ function itsConfusion(text: string, out: ProseHint[]) { out.push({ id: `its-article:${m.index}`, rule: 'its', - zh: `这里应该是 “it’s ${m[1]}”(it is),“its” 是“它的”。`, + native: P().itsIs(m[1]), en: `Here it should be “it’s ${m[1]}” (it is); “its” means belonging to it.`, fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[0][0], "it's") + m[0].slice(3) }, }) @@ -617,7 +623,7 @@ function thanThen(text: string, out: ProseHint[]) { out.push({ id: `than:${m.index}`, rule: 'than', - zh: `比较的时候用 “than”,不是 “then”:“${m[1]} than”。`, + native: P().thanNotThen(m[1]), en: `For comparisons use “than”, not “then”: “${m[1]} than”.`, fix: { from: m.index, to: m.index + m[0].length, replacement: m[0].slice(0, m[0].length - 4) + matchCase(text[thenFrom], 'than') }, }) diff --git a/web/src/components/Companion/tips.ts b/web/src/components/Companion/tips.ts index 64fc93b..ad92fb8 100644 --- a/web/src/components/Companion/tips.ts +++ b/web/src/components/Companion/tips.ts @@ -1,93 +1,28 @@ -// 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.) +// The companion's line *selection*. The lines themselves moved to the langpack +// in Phase 19 (`src/i18n`) — the kitten speaks whichever pair its writer is in, +// and the copy for a pair belongs with the rest of that pair's copy rather than +// next to the timing rules that decide when to say it. +// +// Everything here is read at call time, never at import time, so a bubble +// composed after /api/me answers is in the right language. -export interface Line { - zh: string - en: string -} +import { pack, type Line } from '../../i18n' -// 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.' }, - { zh: '这个词用得真好 🌷', en: 'That’s such a good word choice.' }, - { zh: '哇,这一段读起来真舒服 ☁️', en: 'Ooh, that paragraph flows so nicely.' }, - { zh: '看你越写越有信心,真好 💛', en: 'I love watching you write with more confidence.' }, - { zh: '一点点进步,都是了不起的进步 🌱', en: 'Every little bit of progress counts.' }, - { zh: '今天的你,文字闪闪发光 ✨', en: 'Your words are sparkling today.' }, -] +export type { Line } -// 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 🍎' }, -] +export const encouragements = (): Line[] => pack().companion.encouragements +export const tips = (): Line[] => pack().companion.tips +export const breaks = (): Line[] => pack().companion.breaks +export const bedtime = (): Line[] => pack().companion.bedtime +export const errors = (): Line[] => pack().companion.errors +export const greeting = (): Line => pack().companion.greeting +export const welcomeBack = (): Line => pack().companion.welcomeBack +export const milestoneLine = (n: number): Line => pack().companion.milestone(n) -// 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. 🌿' }, -] - -// Shown when she's still writing late at night (≥11pm). Caring, a little -// playful — the kitten is always asleep, so "you should be too" lands as a gag, -// never a scold. zh stays gentle; the English subtitle carries the wink. -export const BEDTIME: Line[] = [ - { zh: '你的床在想你了哦 🛏️', en: 'I bet your bed is missing you right now.' }, - { zh: '太累可写不出好文字呀,早点歇着吧 🌙', en: 'A tired writer is a bad writer — get some rest.' }, - { zh: '好好睡一觉,灵感自己会来 ✨', en: 'Sleep is a wondrous enabler.' }, - { zh: '听见了吗?没有吧——大家都睡了,你也该睡啦 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." }, - // A few old Chinese proverbs on sleep (zh = a faithful rendering of the - // English sense — the verified classical 原文 isn't recoverable; swap in the - // exact source text if you have it). They read a touch wittier/wiser than the - // gentle lines above, which suits a late-night nudge. - { zh: '一夜不眠,十日不安。', en: "The loss of one night's sleep is followed by ten days of inconvenience." }, - { zh: '前半夜醒着想自己的过错,后半夜睡着才想别人的不是。', en: 'Think of your own faults the first part of the night when you are awake, and of the faults of others the latter part of the night when you are asleep.' }, - { zh: '黄昏与人相骂,夜半独自难眠。', en: 'Curse your spouse at evening, sleep alone at night.' }, -] - -// 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!', -} - -// Gentle "haiya, something went wrong" lines — paired with the error sound when -// the LLM is unreachable or a save fails. Never alarming, always reassuring. -export const ERRORS: Line[] = [ - { zh: '哎呀~出了点小问题,你的字都还在哦。', en: 'Oops — a little hiccup, but your words are safe.' }, - { zh: '哎呀,我这边卡了一下,马上就好。', en: 'Haiya, I got stuck for a sec — back in a moment.' }, - { zh: '别担心,等一下再试试看 🍵', en: "Don't worry — let's try again in a bit. 🍵" }, -] - -// Word-count milestones worth a little cheer — every 100 words, on up. +// Word-count milestones worth a little cheer — every 100 words, on up. A count, +// not copy: the same in every language. export const MILESTONES = Array.from({ length: 100 }, (_, i) => (i + 1) * 100) -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(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)] diff --git a/web/src/components/Companion/useCompanion.ts b/web/src/components/Companion/useCompanion.ts index 1f93f36..8a8e63b 100644 --- a/web/src/components/Companion/useCompanion.ts +++ b/web/src/components/Companion/useCompanion.ts @@ -1,16 +1,16 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { SaveStatus } from '../../hooks/useAutoSave' import { - BEDTIME, - BREAKS, - ENCOURAGEMENTS, - ERRORS, - GREETING, MILESTONES, - TIPS, - WELCOME_BACK, + bedtime, + breaks, + encouragements, + errors, + greeting, milestoneLine, pick, + tips, + welcomeBack, type Line, } from './tips' import { analyzeProse } from './prose' @@ -60,11 +60,11 @@ 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 +// length of the native + 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 : b.tone === 'bedtime' ? BUBBLE_MS + 4_000 : BUBBLE_MS - const chars = b.zh.length + b.en.length + const chars = b.native.length + b.en.length return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR) } @@ -138,9 +138,9 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT 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 { native: hint.native, en: hint.en, tone: 'tip' } } - return { ...pick(TIPS), tone: 'tip' } + return { ...pick(tips()), tone: 'tip' } }, []) const dismiss = useCallback(() => { @@ -167,7 +167,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT // Opening hello (once), after a short beat so it doesn't race the first paint. useEffect(() => { - const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200) + const id = setTimeout(() => say({ ...greeting(), tone: 'tip' }), 1200) return () => clearTimeout(id) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -183,7 +183,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT if (sleeping.current) { sleeping.current = false sessionStart.current = now() // a fresh stretch starts on return - say({ ...WELCOME_BACK, tone: 'cheer' }) + say({ ...welcomeBack(), tone: 'cheer' }) } else if (mood === 'sleeping') { setMood('idle') } @@ -197,7 +197,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT firstAccept.current = false return } - say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { celebrate: true }) + say({ ...pick(encouragements()), tone: 'cheer' }, { celebrate: true }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [acceptTick]) @@ -229,7 +229,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT return } lastError.current = t - say({ ...pick(ERRORS), tone: 'error' }, { sound: 'error' }) + say({ ...pick(errors()), tone: 'error' }, { sound: 'error' }) }, [say]) useEffect(() => { @@ -246,7 +246,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT // 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 }) + say({ ...pick(encouragements()), tone: 'cheer' }, { proactive: true }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [saveStatus]) @@ -267,7 +267,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT if (t - sessionStart.current > BREAK_MS && t - lastBreak.current > BREAK_MS) { lastBreak.current = t sessionStart.current = t - say({ ...pick(BREAKS), tone: 'break' }, { proactive: true }) + say({ ...pick(breaks()), tone: 'break' }, { proactive: true }) return } @@ -276,7 +276,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT // returned if she's away/napping). if (isBedtime() && t - lastBedtime.current > BEDTIME_GAP) { lastBedtime.current = t - say({ ...pick(BEDTIME), tone: 'bedtime' }, { proactive: true }) + say({ ...pick(bedtime()), tone: 'bedtime' }, { proactive: true }) return } diff --git a/web/src/components/DocList/DocList.tsx b/web/src/components/DocList/DocList.tsx index e91ded6..a5d64b6 100644 --- a/web/src/components/DocList/DocList.tsx +++ b/web/src/components/DocList/DocList.tsx @@ -3,6 +3,7 @@ import { api, type DocSummary, type Tag, type TagColor } from '../../api/client' import { DocListItem } from './DocListItem' import { SearchBox } from './SearchBox' import { TagChip } from './TagChip' +import { usePack, type Pack } from '../../i18n' interface Props { docs: DocSummary[] @@ -21,10 +22,10 @@ interface Props { // Sidebar sort orders. 'recent' keeps the server's updated_at-desc ordering. type SortMode = 'recent' | 'title' | 'longest' -const SORTS: { value: SortMode; label: string }[] = [ - { value: 'recent', label: '最近 · Recent' }, - { value: 'title', label: '标题 · Title' }, - { value: 'longest', label: '字数 · Longest' }, +const SORTS: { value: SortMode; label: (t: Pack) => string }[] = [ + { value: 'recent', label: (t) => t.docs.sortRecent }, + { value: 'title', label: (t) => t.docs.sortTitle }, + { value: 'longest', label: (t) => t.docs.sortLongest }, ] // DocList is the sidebar: a cross-document search box, a tag filter bar, the New @@ -41,6 +42,7 @@ export function DocList({ onCreateTag, account, }: Props) { + const t = usePack() // Active tag filter (null = show all). Cleared automatically if the tag // disappears from the roster. const [filterId, setFilterId] = useState(null) @@ -108,7 +110,7 @@ export function DocList({ > {SORTS.map((s) => ( ))} @@ -144,7 +146,7 @@ export function DocList({ className="flex items-center gap-2 px-1 pt-1 text-xs" style={{ color: 'var(--color-muted)', borderTop: '1px solid var(--color-border)' }} > - 备份 · Back up all: + {t.docs.backUpAll} Word @@ -169,7 +171,7 @@ export function DocList({ className="shrink-0 font-bold hover:underline" style={{ color: 'var(--color-accent-hover)' }} > - 退出 · Sign out + {t.docs.signOut} )} diff --git a/web/src/components/DocList/DocListItem.tsx b/web/src/components/DocList/DocListItem.tsx index 1ffa527..eff7b41 100644 --- a/web/src/components/DocList/DocListItem.tsx +++ b/web/src/components/DocList/DocListItem.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import type { DocSummary, Tag, TagColor } from '../../api/client' import { TagChip } from './TagChip' import { TagPicker } from './TagPicker' +import { usePack } from '../../i18n' interface Props { doc: DocSummary @@ -27,6 +28,7 @@ export function DocListItem({ onCreateTag, }: Props) { const [picking, setPicking] = useState(false) + const t = usePack() const assignedIds = new Set(doc.tags.map((t) => t.id)) return ( @@ -70,7 +72,7 @@ export function DocListItem({ )} diff --git a/web/src/components/Editor/MisspellCard.tsx b/web/src/components/Editor/MisspellCard.tsx index d96b403..f14a0c6 100644 --- a/web/src/components/Editor/MisspellCard.tsx +++ b/web/src/components/Editor/MisspellCard.tsx @@ -4,6 +4,8 @@ // bilingual (zh-first, en subtitle) to match the rest of Petal's chrome — the // user writes in Mandarin and English (spec Note #17). +import { usePack } from '../../i18n' + interface Props { word: string suggestions: string[] @@ -13,6 +15,7 @@ interface Props { } export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Props) { + const t = usePack() const shown = suggestions.slice(0, 5) return (

- 拼写 · Spelling + {t.editor.spelling} {word} @@ -58,7 +61,7 @@ export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Pro
) : (

- 没有建议 · No suggestions + {t.editor.noSuggestions}

)} @@ -68,7 +71,7 @@ export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Pro 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 + {t.editor.addToDictionary} ) diff --git a/web/src/components/Editor/RewritePreview.tsx b/web/src/components/Editor/RewritePreview.tsx index 5d6520c..b3f3961 100644 --- a/web/src/components/Editor/RewritePreview.tsx +++ b/web/src/components/Editor/RewritePreview.tsx @@ -1,4 +1,5 @@ import { REWRITE_STYLES } from './SelectionBubble' +import { usePack } from '../../i18n' // 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 @@ -30,6 +31,7 @@ export function RewritePreview({ onCancel, onRetry, }: Props) { + const t = usePack() const meta = REWRITE_STYLES.find((s) => s.value === style) ?? REWRITE_STYLES[0] return ( @@ -54,10 +56,10 @@ export function RewritePreview({ style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }} > {meta.emoji} - {meta.zh} · {meta.en} + {t.styles[meta.value].native} · {t.styles[meta.value].en} - 改写 · Rewrite + {t.editor.rewrite} @@ -76,14 +78,14 @@ export function RewritePreview({ style={{ background: 'var(--color-accent)' }} aria-hidden /> - 改写中… · Rewriting… + {t.editor.rewriting} )} {status === 'error' && (

- 改写失败,请再试一次 · Couldn’t rewrite — try again + {t.editor.rewriteFailed}

@@ -118,7 +120,7 @@ export function RewritePreview({ className="rounded-full px-3 py-1 text-xs font-semibold" style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }} > - 取消 · Cancel + {t.editor.cancel} diff --git a/web/src/components/Editor/SelectionBubble.tsx b/web/src/components/Editor/SelectionBubble.tsx index 65840c8..516a0e5 100644 --- a/web/src/components/Editor/SelectionBubble.tsx +++ b/web/src/components/Editor/SelectionBubble.tsx @@ -1,27 +1,27 @@ // 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 +// prominent "say it naturally" action plus the tone vocabulary mirrored from the +// document-tone picker (labels come from the langpack). 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. +import { usePack } from '../../i18n' + 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' }, + { value: 'natural', emoji: '✨' }, + { value: 'academic', emoji: '🎓' }, + { value: 'professional', emoji: '💼' }, + { value: 'casual', emoji: '☕' }, + { value: 'humorous', emoji: '😄' }, + { value: 'creative', emoji: '🎨' }, + { value: 'persuasive', emoji: '📣' }, ] interface Props { @@ -35,6 +35,7 @@ interface Props { const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif" export function SelectionBubble({ style, onRewrite, onSpeak }: Props) { + const pk = usePack() const [natural, ...tones] = REWRITE_STYLES return ( @@ -64,9 +65,9 @@ export function SelectionBubble({ style, onRewrite, onSpeak }: Props) { title="Rewrite the selection to sound more natural" > {natural.emoji} - {natural.zh} + {pk.styles[natural.value].native} - {natural.en} + {pk.styles[natural.value].en} @@ -77,7 +78,7 @@ export function SelectionBubble({ style, onRewrite, onSpeak }: Props) { onClick={onSpeak} className="inline-flex h-8 items-center justify-center px-2 text-sm" style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }} - title="朗读所选 · Read selection aloud" + title={pk.editor.readSelection} aria-label="Read selection aloud" > 🔊 @@ -96,10 +97,10 @@ export function SelectionBubble({ style, onRewrite, onSpeak }: Props) { style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }} 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`} + title={`Rewrite in a ${pk.styles[t.value].en.toLowerCase()} tone`} > {t.emoji} - {t.zh} + {pk.styles[t.value].native} ))} diff --git a/web/src/components/Editor/ToneSelect.tsx b/web/src/components/Editor/ToneSelect.tsx index e861d21..2036fe7 100644 --- a/web/src/components/Editor/ToneSelect.tsx +++ b/web/src/components/Editor/ToneSelect.tsx @@ -1,28 +1,29 @@ import { useEffect, useRef, useState } from 'react' +import { usePack } from '../../i18n' + // 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