Phase 9: ESL superpowers — Chinese gloss + tone-rewrite

Inline Chinese gloss (offline) and a "say it more naturally" / tone-rewrite,
the two ESL features for the Mandarin-speaking writer.

Gloss: embedded English→Chinese dictionary (gloss.json.gz, 57k common words
built from ECDICT via scripts/build_gloss.py). lexicon gains Gloss()/Result.Gloss
and a lightweight GET /api/gloss/{word}; the right-click WordCard leads with the
中文; GlossTip shows it on a 350ms hover (reuses wordAt, so CJK is never glossed).
Offline + instant, works with the LLM down.

Rewrite: selecting text pops a SelectionBubble (更自然 + the tone vocabulary);
picking a style calls POST /api/docs/:id/rewrite (llm.RunRewrite, stateless,
owner-scoped) and shows a RewritePreview (original→rewrite, accept/cancel/retry).
Accept applies it in-editor.

Tests added in lexicon and suggestions. go build/vet/test, tsc, vite all clean;
live smoke vs a fake vLLM verified gloss + rewrite + 400/404/502 paths.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 00:07:26 -07:00
parent 8e1111d768
commit 60eba25fee
20 changed files with 1036 additions and 14 deletions

View 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>
)
}