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:
75
web/src/hooks/useCheckpoint.ts
Normal file
75
web/src/hooks/useCheckpoint.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user