Phase 5: voice consistency pass
Tier-1 voice-consistency pass: whole-document LLM review surfacing passages that read tonally out of place (formal/over-polished/paraphrased-too-closely), as honey-decorated `voice` flags with no correction (awareness-only). - internal/llm/voice.go: RunVoice sends the whole document (no TruncateDoc), MaxTokens 2048, 20s per-doc floor (VoiceInterval). Standalone voice prompt in prompts.go (not bundled with the grammar checkpoint, per spec). - internal/suggestions: POST /api/docs/:id/voice. replacePending is now family-scoped (pendingScope) so grammar and voice never clobber each other's pending flags; both passes return the unified pending set. check/voice share one runPass helper. TestVoicePassCoexists covers both directions. - Frontend: api.voiceDoc, useCheckpoint voicing/runVoice, honey "Check my voice" toolbar pill, breathing honey dot in StatusBar. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -17,7 +17,9 @@ export default function App() {
|
||||
const {
|
||||
suggestions,
|
||||
checking,
|
||||
voicing,
|
||||
schedule: scheduleCheckpoint,
|
||||
runVoice,
|
||||
removeSuggestion,
|
||||
} = useCheckpoint(currentDoc?.id ?? null)
|
||||
|
||||
@@ -185,10 +187,17 @@ export default function App() {
|
||||
suggestions={suggestions}
|
||||
onAccept={handleAccept}
|
||||
onDismiss={handleDismiss}
|
||||
onVoiceCheck={runVoice}
|
||||
voicing={voicing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar wordCount={wordCount} saveStatus={status} checking={checking} />
|
||||
<StatusBar
|
||||
wordCount={wordCount}
|
||||
saveStatus={status}
|
||||
checking={checking}
|
||||
voicing={voicing}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
|
||||
@@ -70,7 +70,12 @@ export const api = {
|
||||
|
||||
// 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).
|
||||
// Both passes return the UNIFIED pending set (grammar + voice), so the client
|
||||
// never drops one family's highlights when the other refreshes.
|
||||
checkDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/check`, { method: 'POST' }),
|
||||
// Voice-consistency pass: whole-document, explicit-action, slower. Returns the
|
||||
// unified pending set too. Rate-limited per document server-side.
|
||||
voiceDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/voice`, { method: 'POST' }),
|
||||
// Pending suggestions for a doc, loaded when the editor opens it.
|
||||
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
|
||||
acceptSuggestion: (id: string) =>
|
||||
|
||||
@@ -26,6 +26,10 @@ interface Props {
|
||||
suggestions: Suggestion[]
|
||||
onAccept: (s: Suggestion) => void
|
||||
onDismiss: (s: Suggestion) => void
|
||||
// Triggers the whole-document voice-consistency pass; `voicing` is true while
|
||||
// it runs (drives the toolbar button's loading state).
|
||||
onVoiceCheck: () => void
|
||||
voicing: boolean
|
||||
}
|
||||
|
||||
// parseDoc turns the stored content string into a Tiptap doc node, tolerating
|
||||
@@ -50,7 +54,16 @@ interface HoverState {
|
||||
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
|
||||
// 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) {
|
||||
export function EditorCore({
|
||||
docId,
|
||||
initialContent,
|
||||
onChange,
|
||||
suggestions,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
onVoiceCheck,
|
||||
voicing,
|
||||
}: 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.
|
||||
@@ -195,7 +208,7 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Toolbar editor={editor} />
|
||||
<Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} />
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative flex-1"
|
||||
|
||||
@@ -5,6 +5,8 @@ interface Props {
|
||||
saveStatus: SaveStatus
|
||||
// True while a grammar checkpoint is in flight — shows the breathing rose dot.
|
||||
checking: boolean
|
||||
// True while a whole-document voice pass runs — shows a breathing honey dot.
|
||||
voicing: boolean
|
||||
}
|
||||
|
||||
const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||
@@ -18,7 +20,7 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||
// 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) {
|
||||
export function StatusBar({ wordCount, saveStatus, checking, voicing }: Props) {
|
||||
const label = SAVE_LABEL[saveStatus]
|
||||
return (
|
||||
<footer
|
||||
@@ -40,6 +42,18 @@ export function StatusBar({ wordCount, saveStatus, checking }: Props) {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{voicing && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="inline-flex items-center gap-1.5" title="Petal is reading your voice…">
|
||||
<span
|
||||
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||
style={{ background: 'var(--color-honey)' }}
|
||||
/>
|
||||
Reading your voice…
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{label && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useEditorState } from '@tiptap/react'
|
||||
|
||||
interface Props {
|
||||
editor: Editor | null
|
||||
// Runs the whole-document voice-consistency pass; `voicing` shows its progress.
|
||||
onVoiceCheck: () => void
|
||||
voicing: boolean
|
||||
}
|
||||
|
||||
// A formatting button. `active` gets the rose pill treatment so the writer can
|
||||
@@ -47,7 +50,7 @@ const Divider = () => (
|
||||
// Toolbar renders inline formatting controls bound to the live Tiptap editor.
|
||||
// useEditorState subscribes to just the flags it reads, so the buttons reflect
|
||||
// the current selection without re-rendering the whole tree on every keystroke.
|
||||
export function Toolbar({ editor }: Props) {
|
||||
export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) =>
|
||||
@@ -134,6 +137,28 @@ export function Toolbar({ editor }: Props) {
|
||||
>
|
||||
⇥
|
||||
</TBtn>
|
||||
<Divider />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Check my voice"
|
||||
disabled={voicing}
|
||||
onMouseDown={(e) => e.preventDefault()} // keep editor selection
|
||||
onClick={onVoiceCheck}
|
||||
className="ml-0.5 inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-xs font-bold transition-colors disabled:opacity-70"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
color: 'var(--color-plum)',
|
||||
background: 'var(--color-honey)',
|
||||
}}
|
||||
title="Read the whole document for passages that don't sound like you"
|
||||
>
|
||||
<span
|
||||
className={voicing ? 'petal-checkpoint-dot inline-block h-2 w-2 rounded-full' : 'hidden'}
|
||||
style={{ background: 'var(--color-plum)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
{voicing ? 'Reading…' : 'Check my voice 🍯'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ const DEBOUNCE_MS = 4000
|
||||
export function useCheckpoint(docId: string | null) {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
||||
const [checking, setChecking] = useState(false)
|
||||
// True while a whole-document voice pass is in flight (explicit user action).
|
||||
const [voicing, setVoicing] = useState(false)
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const docIdRef = useRef(docId)
|
||||
@@ -36,6 +38,26 @@ export function useCheckpoint(docId: string | null) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Run the voice-consistency pass now (explicit "Check my voice" action). Like
|
||||
// runCheck it returns the unified pending set, so grammar highlights survive.
|
||||
// Shares the run token so navigating away discards a late voice response.
|
||||
const runVoice = useCallback(async () => {
|
||||
const id = docIdRef.current
|
||||
if (!id) return
|
||||
const run = ++runRef.current
|
||||
setVoicing(true)
|
||||
try {
|
||||
const full = await api.voiceDoc(id)
|
||||
if (run === runRef.current && id === docIdRef.current) {
|
||||
setSuggestions(full)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('voice pass failed', err)
|
||||
} finally {
|
||||
if (run === runRef.current) setVoicing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Call on every edit; schedules a check 4s after typing settles.
|
||||
const schedule = useCallback(() => {
|
||||
clearTimeout(debounceRef.current)
|
||||
@@ -49,6 +71,7 @@ export function useCheckpoint(docId: string | null) {
|
||||
runRef.current++
|
||||
setSuggestions([])
|
||||
setChecking(false)
|
||||
setVoicing(false)
|
||||
if (!docId) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
@@ -71,5 +94,5 @@ export function useCheckpoint(docId: string | null) {
|
||||
setSuggestions((prev) => prev.filter((s) => s.id !== id))
|
||||
}, [])
|
||||
|
||||
return { suggestions, checking, schedule, removeSuggestion }
|
||||
return { suggestions, checking, voicing, schedule, runVoice, removeSuggestion }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user