Phase 3: LLM grammar checkpoint
Backend (internal/llm): backend-agnostic LLMClient interface + factory
with vLLM (OpenAI-compat) and Ollama (native) clients, each Complete +
Stream. prompts.go holds the checkpoint and Ask Petal templates;
checkpoint.go salvages JSON from model output (brace-matched), enforces a
per-doc 30s RateLimiter, and truncates the doc to a latency cap.
internal/suggestions: POST /api/docs/:id/check runs a checkpoint and
replaces the doc's pending suggestions in one tx (accepted/rejected kept
as history); GET /api/docs/:id/suggestions lists pending;
POST /api/suggestions/:id/{accept,dismiss} resolves one. Throttled checks
return the current set rather than erroring.
Frontend: useCheckpoint (4s debounce, loads existing on open, stale-guard
tokens); SuggestionHighlight renders ProseMirror decorations re-anchored
by the `original` string on every doc change (not stored marks), with
precise textblock-offset→PM-position mapping; SuggestionCard shows the
type tag + diff + explanation and applies the replacement in-editor on
accept; breathing rose checkpoint dot in the StatusBar; fade-float +
breathe animations.
Tests: llm parse/rate-limit/truncate; suggestions full flow + rate-limit
over httptest with a stub client. Smoke-tested end-to-end against a fake
vLLM endpoint (anchoring verified) and the LLM-unreachable 502 path.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
97
web/src/components/Editor/SuggestionCard.tsx
Normal file
97
web/src/components/Editor/SuggestionCard.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Suggestion, SuggestionType } from '../../api/client'
|
||||
|
||||
// Per-type accent color + human label, mirroring the design tokens.
|
||||
const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
|
||||
grammar: { color: 'var(--color-mint)', label: 'Grammar' },
|
||||
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
|
||||
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
|
||||
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
|
||||
voice: { color: 'var(--color-honey)', label: 'Voice' },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
suggestion: Suggestion
|
||||
style: React.CSSProperties
|
||||
onAccept: (s: Suggestion) => void
|
||||
onDismiss: (s: Suggestion) => void
|
||||
onPointerEnter: () => void
|
||||
onPointerLeave: () => void
|
||||
}
|
||||
|
||||
// SuggestionCard is the hover panel for a single suggestion: a colored type tag,
|
||||
// the original → replacement diff, the friendly explanation, and accept/dismiss
|
||||
// actions. Voice flags carry no replacement, so the diff row is hidden and only
|
||||
// Dismiss is offered (awareness-only).
|
||||
export function SuggestionCard({
|
||||
suggestion,
|
||||
style,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: Props) {
|
||||
const meta = TYPE_META[suggestion.type]
|
||||
const hasReplacement = suggestion.replacement.trim() !== ''
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={`${meta.label} suggestion`}
|
||||
onMouseEnter={onPointerEnter}
|
||||
onMouseLeave={onPointerLeave}
|
||||
className="petal-suggestion-card absolute z-20 w-[300px] p-3.5 text-sm"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||
style={{ background: meta.color, color: 'var(--color-plum)' }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
|
||||
{hasReplacement && (
|
||||
<div className="mt-2.5 flex flex-col gap-1" style={{ fontFamily: 'var(--font-body)' }}>
|
||||
<span className="text-[0.95rem] line-through" style={{ color: 'var(--color-muted)' }}>
|
||||
{suggestion.original}
|
||||
</span>
|
||||
<span className="text-[0.95rem] font-medium" style={{ color: 'var(--color-plum)' }}>
|
||||
{suggestion.replacement}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2.5 leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||
{suggestion.explanation}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{hasReplacement && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAccept(suggestion)}
|
||||
className="rounded-full px-3.5 py-1.5 text-xs font-bold text-white"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDismiss(suggestion)}
|
||||
className="rounded-full px-3.5 py-1.5 text-xs font-semibold"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user