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

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, type DocSummary, type Document } from './api/client'
import { api, type DocSummary, type Document, type Suggestion } from './api/client'
import { useAutoSave } from './hooks/useAutoSave'
import { useCheckpoint } from './hooks/useCheckpoint'
import { DocList } from './components/DocList/DocList'
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
import { StatusBar } from './components/StatusBar/StatusBar'
@@ -13,6 +14,12 @@ export default function App() {
const [ready, setReady] = useState(false)
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
const {
suggestions,
checking,
schedule: scheduleCheckpoint,
removeSuggestion,
} = useCheckpoint(currentDoc?.id ?? null)
// Patch a summary in the sidebar list (optimistic title / word-count updates).
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
@@ -106,9 +113,36 @@ export default function App() {
if (currentDoc) {
patchSummary(currentDoc.id, { word_count: change.word_count })
schedule(change)
scheduleCheckpoint()
}
},
[currentDoc, patchSummary, schedule],
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
)
// Accept applies the replacement in the editor (handled in EditorCore) and
// marks the suggestion accepted; dismiss just rejects it. Both drop it locally.
const handleAccept = useCallback(
async (s: Suggestion) => {
removeSuggestion(s.id)
try {
await api.acceptSuggestion(s.id)
} catch (err) {
console.error('accept failed', err)
}
},
[removeSuggestion],
)
const handleDismiss = useCallback(
async (s: Suggestion) => {
removeSuggestion(s.id)
try {
await api.dismissSuggestion(s.id)
} catch (err) {
console.error('dismiss failed', err)
}
},
[removeSuggestion],
)
return (
@@ -148,10 +182,13 @@ export default function App() {
docId={currentDoc.id}
initialContent={currentDoc.content}
onChange={handleEditorChange}
suggestions={suggestions}
onAccept={handleAccept}
onDismiss={handleDismiss}
/>
</div>
</div>
<StatusBar wordCount={wordCount} saveStatus={status} />
<StatusBar wordCount={wordCount} saveStatus={status} checking={checking} />
</>
) : (
<div

View File

@@ -28,6 +28,25 @@ export interface DocUpdate {
word_count?: number
}
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
// A single LLM-proposed edit. `original` is the source of truth for placement —
// the editor re-anchors by matching this string in the live document (spec
// Note #6); from_pos/to_pos are server-side advisory only. `replacement` is
// empty for voice flags (awareness-only, no correction to apply).
export interface Suggestion {
id: string
doc_id: string
from_pos: number
to_pos: number
original: string
replacement: string
explanation: string
type: SuggestionType
status: 'pending' | 'accepted' | 'rejected'
created_at: string
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' },
@@ -48,4 +67,14 @@ export const api = {
updateDoc: (id: string, body: DocUpdate) =>
req<Document>(`/docs/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteDoc: (id: string) => req<void>(`/docs/${id}`, { method: 'DELETE' }),
// Grammar checkpoint: runs an LLM pass and returns the fresh pending set.
// Rate-limited per document server-side (returns the existing set if too soon).
checkDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/check`, { method: 'POST' }),
// Pending suggestions for a doc, loaded when the editor opens it.
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
acceptSuggestion: (id: string) =>
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
dismissSuggestion: (id: string) =>
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
}

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>

View File

@@ -0,0 +1,75 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, type Suggestion } from '../api/client'
const DEBOUNCE_MS = 4000
// useCheckpoint manages the grammar-checkpoint lifecycle for one document:
// it loads any existing pending suggestions when the doc opens, then fires a
// fresh check 4s after the user stops typing. `checking` drives the breathing
// dot in the StatusBar. The server rate-limits per document, so a check that
// fires too soon simply returns the current set unchanged.
export function useCheckpoint(docId: string | null) {
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [checking, setChecking] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const docIdRef = useRef(docId)
docIdRef.current = docId
// Token to discard responses from a doc we've since navigated away from.
const runRef = useRef(0)
const runCheck = useCallback(async () => {
const id = docIdRef.current
if (!id) return
const run = ++runRef.current
setChecking(true)
try {
const fresh = await api.checkDoc(id)
if (run === runRef.current && id === docIdRef.current) {
setSuggestions(fresh)
}
} catch (err) {
console.error('checkpoint failed', err)
} finally {
if (run === runRef.current) setChecking(false)
}
}, [])
// Call on every edit; schedules a check 4s after typing settles.
const schedule = useCallback(() => {
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(runCheck, DEBOUNCE_MS)
}, [runCheck])
// Load existing pending suggestions whenever the document changes, and cancel
// any in-flight debounce from the previous doc.
useEffect(() => {
clearTimeout(debounceRef.current)
runRef.current++
setSuggestions([])
setChecking(false)
if (!docId) return
let cancelled = false
void (async () => {
try {
const existing = await api.listSuggestions(docId)
if (!cancelled && docIdRef.current === docId) setSuggestions(existing)
} catch (err) {
console.error('failed to load suggestions', err)
}
})()
return () => {
cancelled = true
}
}, [docId])
useEffect(() => () => clearTimeout(debounceRef.current), [])
// Drop one suggestion locally (after accept/dismiss) without a refetch.
const removeSuggestion = useCallback((id: string) => {
setSuggestions((prev) => prev.filter((s) => s.id !== id))
}, [])
return { suggestions, checking, schedule, removeSuggestion }
}

View File

@@ -106,3 +106,41 @@ button, a, input {
height: 0;
pointer-events: none;
}
/* --- Suggestion decorations -------------------------------------------------
Inline highlights anchored by string at render time (not stored marks). Each
type gets a soft underline in its palette color; hovering opens its card. */
.petal-suggestion {
border-bottom: 2px solid transparent;
border-radius: 2px;
cursor: pointer;
transition: background 200ms ease;
/* gentle fade + slight upward float as decorations appear */
animation: petal-suggestion-in 260ms ease both;
}
.petal-suggestion:hover {
background: var(--color-surface-alt);
}
.petal-suggestion-grammar { border-bottom-color: var(--color-mint); }
.petal-suggestion-phrasing { border-bottom-color: var(--color-peach); }
.petal-suggestion-idiom { border-bottom-color: var(--color-lavender); }
.petal-suggestion-clarity { border-bottom-color: var(--color-sky); }
.petal-suggestion-voice { border-bottom-color: var(--color-honey); }
@keyframes petal-suggestion-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.petal-suggestion-card {
animation: petal-suggestion-in 200ms ease both;
}
/* Breathing rose dot shown in the StatusBar while a checkpoint runs. */
.petal-checkpoint-dot {
animation: petal-breathe 2s ease-in-out infinite;
}
@keyframes petal-breathe {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}