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