Companion: rules-based, context-aware writing advice
Replace the kitten's random generic tips with a deterministic, LLM-free prose checker that reads the actual document and surfaces one gentle, bilingual note at a time — quoting a slice of her own sentence so the advice is clearly about *this* draft. Rules (all conservative, tuned for a Mandarin-native ESL writer): run-ons, comma splices, unclear antecedents, Oxford comma, a/an, uncountable plurals, capitalize languages/days/months, subject-verb agreement, plural-after-number, double determiner, "there is" + plural, its/it's, than/then, comma-after-transition, doubled words, lowercase "i", sentence capitals, and spacing around punctuation. Each rule is paired with false-positive guards (e.g. "a university", "After dinner, I went home and read", existing Oxford lists) and pinned by a 45-case Vitest suite (npm test). Also: bubbles now linger proportional to how much there is to read (zh+en) and pause-on-hover, so denser advice no longer vanishes mid-read. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
pick,
|
||||
type Line,
|
||||
} from './tips'
|
||||
import { analyzeProse } from './prose'
|
||||
|
||||
// The kitten's expression. Maps to a Lottie animation when assets are present,
|
||||
// otherwise to an emoji placeholder (see PetalCompanion).
|
||||
@@ -28,6 +29,9 @@ interface Signals {
|
||||
// suggestion — lets the companion react without a full event bus.
|
||||
editTick: number
|
||||
acceptTick: number
|
||||
// The document's plain text, used by the rules-based prose checker to offer
|
||||
// context-aware writing notes instead of only generic tips.
|
||||
text: string
|
||||
}
|
||||
|
||||
// Timing knobs (ms). Tuned to feel present but never naggy.
|
||||
@@ -35,14 +39,29 @@ const IDLE_MS = 75_000 // no edits → kitten naps
|
||||
const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break
|
||||
const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window
|
||||
const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
|
||||
const BUBBLE_MS = 6_500 // how long a bubble lingers
|
||||
const CHEER_MS = 4_500 // shorter for quick cheers
|
||||
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by
|
||||
// how much there is to read, since she reads both the Mandarin and the English
|
||||
// (and tips now quote a slice of her own sentence, so they run longer).
|
||||
const BUBBLE_MS = 9_000 // baseline for a tip / break
|
||||
const CHEER_MS = 6_000 // a short cheer still needs a beat in two languages
|
||||
const READ_MS_PER_CHAR = 45 // ~22 chars/sec, generous for a bilingual read
|
||||
const MAX_BUBBLE_MS = 20_000 // cap so a long quote can't pin the bubble forever
|
||||
const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
|
||||
const now = () => Date.now()
|
||||
|
||||
// Reading time for a bubble: a base floor by tone, stretched by the combined
|
||||
// length of the Mandarin + English lines so denser advice stays up long enough
|
||||
// to actually finish reading.
|
||||
function readBubbleMs(b: Bubble): number {
|
||||
const base = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS
|
||||
const chars = b.zh.length + b.en.length
|
||||
return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR)
|
||||
}
|
||||
|
||||
// useCompanion is the behavior engine: it watches writing signals and decides
|
||||
// when the kitten speaks, what mood it shows, and how to pace itself so the
|
||||
// companion feels alive without interrupting. UI-agnostic — returns state only.
|
||||
export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Signals) {
|
||||
export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Signals) {
|
||||
const [mood, setMood] = useState<Mood>('idle')
|
||||
const [bubble, setBubble] = useState<Bubble | null>(null)
|
||||
|
||||
@@ -54,6 +73,15 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
const nextMilestone = useRef(0) // index into MILESTONES
|
||||
const sleeping = useRef(false)
|
||||
|
||||
// Latest text for the prose checker, read lazily by the heartbeat (kept in a
|
||||
// ref so per-keystroke changes don't re-arm the interval).
|
||||
const textRef = useRef(text)
|
||||
textRef.current = text
|
||||
// Recently-surfaced hint ids, so the same untouched sentence isn't re-flagged
|
||||
// each cadence. A small ring — old ids age out and may resurface later.
|
||||
const recentHints = useRef<string[]>([])
|
||||
const lastRule = useRef<string>('')
|
||||
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const moodTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
@@ -71,11 +99,30 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
setMood(opts?.celebrate ? 'celebrate' : 'talking')
|
||||
clearTimeout(bubbleTimer.current)
|
||||
clearTimeout(moodTimer.current)
|
||||
const dur = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS
|
||||
const dur = readBubbleMs(b)
|
||||
bubbleTimer.current = setTimeout(() => setBubble(null), dur)
|
||||
moodTimer.current = setTimeout(() => setMood('idle'), dur)
|
||||
}, [bubble])
|
||||
|
||||
// Choose the next spontaneous tip. Prefer a real, context-aware note from the
|
||||
// rules-based prose checker (it's actionable and clearly about *her* writing);
|
||||
// fall back to a generic encouragement-style tip when the text reads clean or
|
||||
// every finding was shown recently. Avoids repeating a finding or the same
|
||||
// rule family back-to-back so the companion never feels like a broken record.
|
||||
const nextTip = useCallback((): Bubble => {
|
||||
const hints = analyzeProse(textRef.current)
|
||||
const fresh = hints.find(
|
||||
(h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current,
|
||||
)
|
||||
const hint = fresh ?? hints.find((h) => !recentHints.current.includes(h.id))
|
||||
if (hint) {
|
||||
lastRule.current = hint.rule
|
||||
recentHints.current = [hint.id, ...recentHints.current].slice(0, 8)
|
||||
return { zh: hint.zh, en: hint.en, tone: 'tip' }
|
||||
}
|
||||
return { ...pick(TIPS), tone: 'tip' }
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
clearTimeout(bubbleTimer.current)
|
||||
clearTimeout(moodTimer.current)
|
||||
@@ -83,6 +130,21 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
setMood('idle')
|
||||
}, [])
|
||||
|
||||
// Pause the auto-dismiss while she hovers a bubble — so a longer note never
|
||||
// vanishes mid-read. Releasing leaves it up for a short, fixed grace so it
|
||||
// doesn't linger forever once she looks away.
|
||||
const holdBubble = useCallback(() => {
|
||||
clearTimeout(bubbleTimer.current)
|
||||
clearTimeout(moodTimer.current)
|
||||
}, [])
|
||||
|
||||
const releaseBubble = useCallback(() => {
|
||||
clearTimeout(bubbleTimer.current)
|
||||
clearTimeout(moodTimer.current)
|
||||
bubbleTimer.current = setTimeout(() => setBubble(null), HOVER_GRACE_MS)
|
||||
moodTimer.current = setTimeout(() => setMood('idle'), HOVER_GRACE_MS)
|
||||
}, [])
|
||||
|
||||
// Opening hello (once), after a short beat so it doesn't race the first paint.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200)
|
||||
@@ -161,14 +223,15 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise an occasional tip.
|
||||
// Otherwise an occasional tip — context-aware when her text gives us
|
||||
// something concrete to gently point at, generic warmth otherwise.
|
||||
if (t - lastTip.current > TIP_MIN_GAP) {
|
||||
lastTip.current = t
|
||||
say({ ...pick(TIPS), tone: 'tip' }, { proactive: true })
|
||||
say(nextTip(), { proactive: true })
|
||||
}
|
||||
}, 10_000)
|
||||
return () => clearInterval(id)
|
||||
}, [bubble, say])
|
||||
}, [bubble, say, nextTip])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -178,5 +241,5 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
[],
|
||||
)
|
||||
|
||||
return { mood, bubble, dismiss }
|
||||
return { mood, bubble, dismiss, holdBubble, releaseBubble }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user