- Three new Lottie companions: Wiggle Dog (摇尾狗), Butterfly (蝴蝶), Parrot (鹦鹉). Parrot is mirrored via a new `flip` flag on Companion, threaded through LottiePlayer (scaleX(-1)) since the asset faces left. - Mascot size now scales with the viewport: --petal-companion-size clamp(10rem, 17vw, 20rem); art, emoji fallback, and the sleep "z" all derive from it. - Larger, more legible tip bubble (1.4rem zh / 1.15rem en, wider bubble). - Bubbles linger longer for a bilingual ESL read: baseline 9s→14s, cheer 6s→9s, per-char 45→55ms, cap 20s→32s. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import type { SaveStatus } from '../../hooks/useAutoSave'
|
|
import {
|
|
BREAKS,
|
|
ENCOURAGEMENTS,
|
|
ERRORS,
|
|
GREETING,
|
|
MILESTONES,
|
|
TIPS,
|
|
WELCOME_BACK,
|
|
milestoneLine,
|
|
pick,
|
|
type Line,
|
|
} from './tips'
|
|
import { analyzeProse } from './prose'
|
|
import { playPop, playSound, type SoundName } from '../../audio/sounds'
|
|
|
|
// The kitten's expression. Maps to a Lottie animation when assets are present,
|
|
// otherwise to an emoji placeholder (see PetalCompanion).
|
|
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate'
|
|
|
|
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error'
|
|
export interface Bubble extends Line {
|
|
tone: BubbleTone
|
|
}
|
|
|
|
interface Signals {
|
|
wordCount: number
|
|
saveStatus: SaveStatus
|
|
// True while the writing-assist LLM is unreachable (timeout / down). A
|
|
// false→true flip earns a gentle "haiya".
|
|
llmDown: boolean
|
|
// Monotonic counters bumped by the app on each editor change / accepted
|
|
// 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.
|
|
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
|
|
// 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 = 14_000 // baseline for a tip / break
|
|
const CHEER_MS = 9_000 // a short cheer still needs a beat in two languages
|
|
const READ_MS_PER_CHAR = 55 // ~18 chars/sec, generous for a bilingual ESL read
|
|
const MAX_BUBBLE_MS = 32_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, llmDown, editTick, acceptTick, text }: Signals) {
|
|
const [mood, setMood] = useState<Mood>('idle')
|
|
const [bubble, setBubble] = useState<Bubble | null>(null)
|
|
|
|
const lastActivity = useRef(now())
|
|
const sessionStart = useRef(now())
|
|
const lastProactive = useRef(0)
|
|
const lastTip = useRef(0)
|
|
const lastBreak = useRef(0)
|
|
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)
|
|
|
|
// Show a bubble + talking mood, then settle back. `proactive` messages respect
|
|
// the spacing floor; user-triggered ones (cheers) always go through.
|
|
const say = useCallback(
|
|
(b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean; sound?: SoundName }) => {
|
|
const t = now()
|
|
if (opts?.proactive) {
|
|
if (bubble) return
|
|
if (t - lastProactive.current < PROACTIVE_GAP) return
|
|
lastProactive.current = t
|
|
}
|
|
sleeping.current = false
|
|
// A sound to match the bubble: callers can name a specific one (the milestone
|
|
// fanfare, the "haiya" on errors); everything else gets a rotating pop so the
|
|
// same blip never repeats back to back.
|
|
if (opts?.sound) playSound(opts.sound)
|
|
else playPop()
|
|
setBubble(b)
|
|
setMood(opts?.celebrate ? 'celebrate' : 'talking')
|
|
clearTimeout(bubbleTimer.current)
|
|
clearTimeout(moodTimer.current)
|
|
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)
|
|
setBubble(null)
|
|
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)
|
|
return () => clearTimeout(id)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
// Edits: record activity and wake from a nap with a warm welcome-back.
|
|
const firstEdit = useRef(true)
|
|
useEffect(() => {
|
|
if (firstEdit.current) {
|
|
firstEdit.current = false
|
|
return
|
|
}
|
|
lastActivity.current = now()
|
|
if (sleeping.current) {
|
|
sleeping.current = false
|
|
sessionStart.current = now() // a fresh stretch starts on return
|
|
say({ ...WELCOME_BACK, tone: 'cheer' })
|
|
} else if (mood === 'sleeping') {
|
|
setMood('idle')
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [editTick])
|
|
|
|
// Accepts: always cheer (it's a direct response to her action).
|
|
const firstAccept = useRef(true)
|
|
useEffect(() => {
|
|
if (firstAccept.current) {
|
|
firstAccept.current = false
|
|
return
|
|
}
|
|
say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { celebrate: true })
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [acceptTick])
|
|
|
|
// Word-count milestones: cheer the first time each threshold is crossed.
|
|
useEffect(() => {
|
|
while (
|
|
nextMilestone.current < MILESTONES.length &&
|
|
wordCount >= MILESTONES[nextMilestone.current]
|
|
) {
|
|
const n = MILESTONES[nextMilestone.current]
|
|
nextMilestone.current += 1
|
|
// Skip silently if we're just loading a long doc (no edits yet).
|
|
if (!firstEdit.current)
|
|
say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true, sound: 'milestone' })
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [wordCount])
|
|
|
|
// Trouble — a save failed or the writing-assist LLM went unreachable. Play the
|
|
// playful "haiya" and reassure her, but never spam: at most one per gap, and
|
|
// only on the *transition* into trouble (not while it lingers).
|
|
const lastError = useRef(0)
|
|
const ERROR_GAP = 20_000
|
|
const wasLlmDown = useRef(false)
|
|
const haiya = useCallback(() => {
|
|
const t = now()
|
|
if (t - lastError.current < ERROR_GAP) {
|
|
playSound('error') // still acknowledge it, just without a fresh bubble
|
|
return
|
|
}
|
|
lastError.current = t
|
|
say({ ...pick(ERRORS), tone: 'error' }, { sound: 'error' })
|
|
}, [say])
|
|
|
|
useEffect(() => {
|
|
if (saveStatus === 'error') haiya()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [saveStatus])
|
|
|
|
useEffect(() => {
|
|
if (llmDown && !wasLlmDown.current) haiya()
|
|
wasLlmDown.current = llmDown
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [llmDown])
|
|
|
|
// Occasional gentle cheer on a successful save (kept rare so it isn't noise).
|
|
useEffect(() => {
|
|
if (saveStatus === 'saved' && Math.random() < 0.18) {
|
|
say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { proactive: true })
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [saveStatus])
|
|
|
|
// Heartbeat: nap when idle, suggest breaks, and drop the odd writing tip.
|
|
useEffect(() => {
|
|
const id = setInterval(() => {
|
|
const t = now()
|
|
const idleFor = t - lastActivity.current
|
|
|
|
if (idleFor > IDLE_MS) {
|
|
sleeping.current = true
|
|
if (!bubble) setMood('sleeping')
|
|
return // resting — no nudges while she's away
|
|
}
|
|
|
|
// Active: time for a break?
|
|
if (t - sessionStart.current > BREAK_MS && t - lastBreak.current > BREAK_MS) {
|
|
lastBreak.current = t
|
|
sessionStart.current = t
|
|
say({ ...pick(BREAKS), tone: 'break' }, { proactive: true })
|
|
return
|
|
}
|
|
|
|
// 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(nextTip(), { proactive: true })
|
|
}
|
|
}, 10_000)
|
|
return () => clearInterval(id)
|
|
}, [bubble, say, nextTip])
|
|
|
|
useEffect(
|
|
() => () => {
|
|
clearTimeout(bubbleTimer.current)
|
|
clearTimeout(moodTimer.current)
|
|
},
|
|
[],
|
|
)
|
|
|
|
return { mood, bubble, dismiss, holdBubble, releaseBubble }
|
|
}
|