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:
@@ -4,8 +4,11 @@ import Underline from '@tiptap/extension-underline'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import CharacterCount from '@tiptap/extension-character-count'
|
||||
import { useEffect } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Toolbar } from '../Toolbar/Toolbar'
|
||||
import { SuggestionCard } from './SuggestionCard'
|
||||
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
|
||||
import type { Suggestion } from '../../api/client'
|
||||
|
||||
export interface EditorChange {
|
||||
content: string // Tiptap JSON, stringified
|
||||
@@ -18,6 +21,11 @@ interface Props {
|
||||
docId: string
|
||||
initialContent: string
|
||||
onChange: (change: EditorChange) => void
|
||||
// LLM suggestions to highlight; accept/dismiss notify the parent for the API
|
||||
// call + state removal (accept's text replacement happens here in the editor).
|
||||
suggestions: Suggestion[]
|
||||
onAccept: (s: Suggestion) => void
|
||||
onDismiss: (s: Suggestion) => void
|
||||
}
|
||||
|
||||
// parseDoc turns the stored content string into a Tiptap doc node, tolerating
|
||||
@@ -33,9 +41,21 @@ function parseDoc(raw: string): object | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface HoverState {
|
||||
suggestion: Suggestion
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
|
||||
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
|
||||
// alignment, a placeholder, and character counting (words feed the StatusBar).
|
||||
export function EditorCore({ docId, initialContent, onChange }: Props) {
|
||||
// alignment, a placeholder, character counting, and the suggestion-highlight
|
||||
// decoration layer. Hovering a highlight opens its SuggestionCard.
|
||||
export function EditorCore({ docId, initialContent, onChange, suggestions, onAccept, onDismiss }: Props) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const [hover, setHover] = useState<HoverState | null>(null)
|
||||
// Delays card close so the pointer can travel from highlight to card.
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
@@ -43,6 +63,7 @@ export function EditorCore({ docId, initialContent, onChange }: Props) {
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Placeholder.configure({ placeholder: 'Start writing…' }),
|
||||
CharacterCount,
|
||||
SuggestionHighlight,
|
||||
],
|
||||
content: parseDoc(initialContent),
|
||||
editorProps: {
|
||||
@@ -62,13 +83,111 @@ export function EditorCore({ docId, initialContent, onChange }: Props) {
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
|
||||
setHover(null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [docId, editor])
|
||||
|
||||
// Push the current suggestion list into the decoration plugin.
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
setSuggestions(editor.state, editor.view.dispatch, suggestions)
|
||||
// Close the card if its suggestion is gone.
|
||||
setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null))
|
||||
}, [editor, suggestions])
|
||||
|
||||
const openCardFor = useCallback(
|
||||
(id: string, el: HTMLElement) => {
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) return
|
||||
const suggestion = suggestions.find((s) => s.id === id)
|
||||
if (!suggestion) return
|
||||
const elRect = el.getBoundingClientRect()
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const cardWidth = 300
|
||||
const left = Math.max(
|
||||
0,
|
||||
Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth),
|
||||
)
|
||||
const top = elRect.bottom - wrapRect.top + 6
|
||||
setHover({ suggestion, top, left })
|
||||
},
|
||||
[suggestions],
|
||||
)
|
||||
|
||||
const handleMouseOver = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
|
||||
if (!target) return
|
||||
const id = target.getAttribute('data-suggestion-id')
|
||||
if (!id) return
|
||||
clearTimeout(closeTimer.current)
|
||||
openCardFor(id, target)
|
||||
},
|
||||
[openCardFor],
|
||||
)
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
clearTimeout(closeTimer.current)
|
||||
closeTimer.current = setTimeout(() => setHover(null), 160)
|
||||
}, [])
|
||||
|
||||
// Leaving a highlight schedules a close; entering the card cancels it, so the
|
||||
// pointer can bridge the small gap from text to card.
|
||||
const handleMouseOut = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.petal-suggestion')) scheduleClose()
|
||||
},
|
||||
[scheduleClose],
|
||||
)
|
||||
|
||||
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
|
||||
|
||||
// Accept applies the replacement to the document, then notifies the parent.
|
||||
const handleAccept = useCallback(
|
||||
(s: Suggestion) => {
|
||||
if (editor && s.replacement.trim() !== '') {
|
||||
const range = findRange(editor.state.doc, s.original)
|
||||
if (range) {
|
||||
editor.chain().focus().insertContentAt(range, s.replacement).run()
|
||||
}
|
||||
}
|
||||
setHover(null)
|
||||
onAccept(s)
|
||||
},
|
||||
[editor, onAccept],
|
||||
)
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
(s: Suggestion) => {
|
||||
setHover(null)
|
||||
onDismiss(s)
|
||||
},
|
||||
[onDismiss],
|
||||
)
|
||||
|
||||
useEffect(() => () => clearTimeout(closeTimer.current), [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Toolbar editor={editor} />
|
||||
<EditorContent editor={editor} className="flex-1" />
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative flex-1"
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
>
|
||||
<EditorContent editor={editor} className="h-full" />
|
||||
{hover && (
|
||||
<SuggestionCard
|
||||
suggestion={hover.suggestion}
|
||||
style={{ top: hover.top, left: hover.left }}
|
||||
onAccept={handleAccept}
|
||||
onDismiss={handleDismiss}
|
||||
onPointerEnter={keepOpen}
|
||||
onPointerLeave={scheduleClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
121
web/src/components/Editor/SuggestionHighlight.ts
Normal file
121
web/src/components/Editor/SuggestionHighlight.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import type { EditorState, Transaction } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import type { Suggestion } from '../../api/client'
|
||||
|
||||
// SuggestionHighlight renders LLM suggestions as ProseMirror *decorations*, not
|
||||
// stored marks. Decorations are ephemeral overlays recomputed from the live
|
||||
// document on every change, which is exactly the string-anchoring the spec
|
||||
// requires (Note #6): each suggestion is re-located by matching its `original`
|
||||
// text in the current doc, so edits made while a checkpoint is in flight never
|
||||
// leave a highlight stranded on stale coordinates.
|
||||
|
||||
export const suggestionPluginKey = new PluginKey<PluginState>('petalSuggestions')
|
||||
|
||||
interface PluginState {
|
||||
suggestions: Suggestion[]
|
||||
decorations: DecorationSet
|
||||
}
|
||||
|
||||
// mapOffset converts a character offset within a textblock's flattened text into
|
||||
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
|
||||
// breaks) that occupy a position but contribute no text.
|
||||
function mapOffset(block: PMNode, blockPos: number, targetOffset: number): number {
|
||||
let textOffset = 0
|
||||
let pmPos = blockPos + 1 // inline content starts just inside the block
|
||||
let result = pmPos
|
||||
let done = false
|
||||
block.forEach((child) => {
|
||||
if (done) return
|
||||
const len = child.isText ? (child.text?.length ?? 0) : 0
|
||||
if (textOffset + len >= targetOffset) {
|
||||
result = pmPos + (targetOffset - textOffset)
|
||||
done = true
|
||||
} else {
|
||||
textOffset += len
|
||||
pmPos += child.nodeSize
|
||||
}
|
||||
})
|
||||
if (!done) result = pmPos
|
||||
return result
|
||||
}
|
||||
|
||||
// findRange locates the first occurrence of `search` within a single textblock
|
||||
// and returns its ProseMirror range, or null if the string isn't present (the
|
||||
// user may have edited or removed it since the checkpoint ran). Exported so the
|
||||
// accept flow can resolve the same span to replace.
|
||||
export function findRange(doc: PMNode, search: string): { from: number; to: number } | null {
|
||||
if (!search) return null
|
||||
let result: { from: number; to: number } | null = null
|
||||
doc.descendants((node, pos) => {
|
||||
if (result) return false
|
||||
if (!node.isTextblock) return true // keep descending to the textblock
|
||||
const idx = node.textContent.indexOf(search)
|
||||
if (idx >= 0) {
|
||||
result = {
|
||||
from: mapOffset(node, pos, idx),
|
||||
to: mapOffset(node, pos, idx + search.length),
|
||||
}
|
||||
}
|
||||
return false // never descend into a textblock's inline children
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet {
|
||||
const decos: Decoration[] = []
|
||||
for (const s of suggestions) {
|
||||
const range = findRange(doc, s.original)
|
||||
if (!range) continue
|
||||
decos.push(
|
||||
Decoration.inline(range.from, range.to, {
|
||||
class: `petal-suggestion petal-suggestion-${s.type}`,
|
||||
'data-suggestion-id': s.id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return DecorationSet.create(doc, decos)
|
||||
}
|
||||
|
||||
// setSuggestions pushes a new suggestion list into the plugin. Decorations are
|
||||
// rebuilt against the current document immediately.
|
||||
export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) {
|
||||
const tr = state.tr.setMeta(suggestionPluginKey, suggestions)
|
||||
dispatch(tr)
|
||||
}
|
||||
|
||||
export const SuggestionHighlight = Extension.create({
|
||||
name: 'suggestionHighlight',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin<PluginState>({
|
||||
key: suggestionPluginKey,
|
||||
state: {
|
||||
init: () => ({ suggestions: [], decorations: DecorationSet.empty }),
|
||||
apply(tr, value, _oldState, newState) {
|
||||
const meta = tr.getMeta(suggestionPluginKey) as Suggestion[] | undefined
|
||||
if (meta) {
|
||||
return { suggestions: meta, decorations: buildDecorations(newState.doc, meta) }
|
||||
}
|
||||
// On any document change, re-anchor by string against the new doc.
|
||||
if (tr.docChanged) {
|
||||
return {
|
||||
suggestions: value.suggestions,
|
||||
decorations: buildDecorations(newState.doc, value.suggestions),
|
||||
}
|
||||
}
|
||||
return value
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return suggestionPluginKey.getState(state)?.decorations
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -3,6 +3,8 @@ import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||
interface Props {
|
||||
wordCount: number
|
||||
saveStatus: SaveStatus
|
||||
// True while a grammar checkpoint is in flight — shows the breathing rose dot.
|
||||
checking: boolean
|
||||
}
|
||||
|
||||
const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||
@@ -13,9 +15,10 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||
error: "Couldn't save",
|
||||
}
|
||||
|
||||
// StatusBar is the slim footer: word count on the left, save state on the right.
|
||||
// The checkpoint dot (grammar pass) joins it in Phase 3.
|
||||
export function StatusBar({ wordCount, saveStatus }: Props) {
|
||||
// StatusBar is the slim footer: word count on the left, save state and the
|
||||
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
||||
// circle that breathes while a check is in flight (spec → Signature animations).
|
||||
export function StatusBar({ wordCount, saveStatus, checking }: Props) {
|
||||
const label = SAVE_LABEL[saveStatus]
|
||||
return (
|
||||
<footer
|
||||
@@ -25,6 +28,18 @@ export function StatusBar({ wordCount, saveStatus }: Props) {
|
||||
<span>
|
||||
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
||||
</span>
|
||||
{checking && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="inline-flex items-center gap-1.5" title="Petal is reading your writing…">
|
||||
<span
|
||||
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
/>
|
||||
Checking…
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{label && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
|
||||
Reference in New Issue
Block a user