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:
prosolis
2026-06-25 20:45:30 -07:00
parent 5e00cdce88
commit a4069d5755
18 changed files with 1640 additions and 19 deletions

View File

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

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

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

View File

@@ -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>