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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user