Files
petal/web/src/hooks/useCheckpoint.ts
prosolis a4069d5755 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
2026-06-25 20:45:30 -07:00

76 lines
2.5 KiB
TypeScript

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