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
137 lines
4.4 KiB
TypeScript
137 lines
4.4 KiB
TypeScript
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>
|
||
)
|
||
}
|