b441f32949
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # client/src/components/Sagi/PetSprite.tsx # client/src/components/Sagi/useSagiBrain.ts
422 lines
16 KiB
TypeScript
422 lines
16 KiB
TypeScript
/**
|
|
* @file useSagiBrain.ts
|
|
* @description React hook that wires the pure Sagi brain to the live event bus
|
|
* and to real timers. It is the only unit that subscribes to `eventBus`. It
|
|
* exposes the derived mood, a status summary, the sessions that need the user,
|
|
* the current speech bubble (naming the session it is about, and coalescing a
|
|
* burst of the same event into one count), the pet's one-shot animation
|
|
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
|
|
* for the UI shell. It also greets once per browser session and makes an
|
|
* idle pet fidget now and then.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
/* =============================================================================
|
|
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
|
* =============================================================================
|
|
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome. React hook: isolates side effects and subscription wiring so presentational components stay declarative.
|
|
*
|
|
* ## Design constraints
|
|
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
|
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
|
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
|
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
|
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
|
*
|
|
* ## Remote data & SSH
|
|
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
|
*
|
|
* ## Internal dependencies
|
|
* - `../../lib/eventBus`
|
|
* - `../../lib/api`
|
|
* - `../../lib/types`
|
|
* - `./brain`
|
|
* - `./quips`
|
|
* - `./prefs`
|
|
*
|
|
* ## Public surface
|
|
* - `SagiBrain` — exported API; see TSDoc on the symbol for behavior.
|
|
* - `useSagiBrain` — exported API; see TSDoc on the symbol for behavior.
|
|
*
|
|
* ## Testing pointers
|
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
|
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
|
*
|
|
* ## Related docs
|
|
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
|
* - `docs/API.md` — REST reference.
|
|
* - `skills/file-headers/` — mandatory `@author` header policy.
|
|
* ============================================================================= */
|
|
/* -----------------------------------------------------------------------------
|
|
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
|
* -----------------------------------------------------------------------------
|
|
* **SagiBrain**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* **useSagiBrain**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* ----------------------------------------------------------------------------- */
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { eventBus } from "../../lib/eventBus";
|
|
import { api } from "../../lib/api";
|
|
import i18n from "../../i18n";
|
|
import type { WSMessage } from "../../lib/types";
|
|
import {
|
|
initialSagiState,
|
|
reduceSagi,
|
|
deriveMood,
|
|
statusOf,
|
|
attentionOf,
|
|
clearErrors,
|
|
seedSessions,
|
|
subjectLabel,
|
|
MOMENT_OF_PULSE,
|
|
type AttentionItem,
|
|
type Mood,
|
|
type PetMoment,
|
|
type SagiPulse,
|
|
type SagiState,
|
|
type SagiStatus,
|
|
type SagiSubject,
|
|
} from "./brain";
|
|
import { greetingKey, pickLine, pickQuip } from "./quips";
|
|
import { statusSummary } from "./intents";
|
|
import { sagiPrefs } from "./prefs";
|
|
|
|
/** `next` differs from `prev` only by a lastActivityAt less than a second
|
|
* newer — nothing the mascot shows, at the moods' 1 s resolution. */
|
|
function onlyActivityMoved(prev: SagiState, next: SagiState): boolean {
|
|
if (next === prev) return true;
|
|
if (next.lastActivityAt - prev.lastActivityAt >= 1000) return false;
|
|
for (const k of Object.keys(next) as (keyof SagiState)[]) {
|
|
if (k !== "lastActivityAt" && next[k] !== prev[k]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const BUBBLE_MS = 4500;
|
|
// A bubble that names a session stays long enough to click it.
|
|
const SUBJECT_BUBBLE_MS = 7000;
|
|
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
|
|
const BUBBLE_THROTTLE_MS = 3000;
|
|
// The same pulse again within this window joins the bubble on show as a count.
|
|
const COALESCE_MS = 8000;
|
|
// How long a moment (wave, hop, flinch…) plays over the mood.
|
|
const MOMENT_MS = 2400;
|
|
// An idle pet fidgets (waves or looks around) once in a while.
|
|
const FIDGET_MIN_MS = 45_000;
|
|
const FIDGET_SPAN_MS = 60_000;
|
|
// Greet once per browser session, shortly after the snapshot lands.
|
|
const GREETED_KEY = "agenthub.pet.greeted";
|
|
const GREET_DELAY_MS = 1200;
|
|
|
|
/** What the speech bubble shows. */
|
|
export interface SagiBubble {
|
|
text: string;
|
|
/** Which session(s) it is about, shown under the text. */
|
|
detail?: string;
|
|
/** The one session it is about, which a click opens. */
|
|
subject?: SagiSubject;
|
|
/** Errors and waits are drawn with an accent. */
|
|
tone?: "error" | "waiting";
|
|
}
|
|
|
|
export interface SagiBrain {
|
|
mood: Mood;
|
|
status: SagiStatus;
|
|
/** Errored, then waiting sessions, named. */
|
|
attention: AttentionItem[];
|
|
bubble: SagiBubble | null;
|
|
dismissBubble: () => void;
|
|
/** Imperative bubble setter for one-off messages from the shell (e.g. spawn
|
|
* errors). Bypasses the throttle because the caller decided this message
|
|
* must be seen right now. Auto-clears after the standard bubble TTL. */
|
|
setBubble: (text: string) => void;
|
|
/** The one-shot animation playing now, if any; `key` changes per moment so a
|
|
* repeat of the same one restarts it. */
|
|
moment: { kind: PetMoment; key: number } | null;
|
|
/** Play a moment by hand (an answer, a pat). */
|
|
playMoment: (kind: PetMoment) => void;
|
|
/** Pat the pet: a hop, hearts and a happy line right on the pet — shown
|
|
* even while the panel or chat is open, and while muted (it was asked). */
|
|
pat: () => void;
|
|
/** The pet's answer to a pat, drawn on the pet itself; `key` restarts it. */
|
|
reaction: { text: string; key: number } | null;
|
|
muted: boolean;
|
|
toggleMute: () => void;
|
|
clearAlerts: () => void;
|
|
setThinking: (v: boolean) => void;
|
|
}
|
|
|
|
type Burst = { pulse: NonNullable<SagiPulse>; count: number; subjects: SagiSubject[]; at: number };
|
|
|
|
function burstBubble(b: Burst): SagiBubble {
|
|
const tone = b.pulse === "error" ? "error" : b.pulse === "waiting" ? "waiting" : undefined;
|
|
const one = b.subjects.length === 1 ? b.subjects[0] : undefined;
|
|
const labels = b.subjects.map(subjectLabel);
|
|
const detail =
|
|
labels.length > 2
|
|
? i18n.t("settings:sagi.bubbleMore", {
|
|
list: labels.slice(0, 2).join(", "),
|
|
count: labels.length - 2,
|
|
})
|
|
: labels.join(", ") || undefined;
|
|
const text =
|
|
b.count > 1 ? i18n.t(`settings:sagi.notify.${b.pulse}`, { count: b.count }) : pickQuip(b.pulse);
|
|
return { text, detail, subject: one, tone };
|
|
}
|
|
|
|
export function useSagiBrain(): SagiBrain {
|
|
const now0 = Date.now();
|
|
// Start optimistically connected (idle, open eyes) so the cursor-tracking
|
|
// eyes are live from the first frame instead of after the WebSocket finishes
|
|
// its initial handshake. onConnection below corrects this if it's truly down.
|
|
const [state, setStateRaw] = useState<SagiState>(() => ({
|
|
...initialSagiState(now0),
|
|
connected: true,
|
|
dismissed: sagiPrefs.getDismissed(),
|
|
}));
|
|
// The reducer runs OUTSIDE React's updater (which StrictMode calls twice),
|
|
// so a pulse notifies exactly once; this ref is the state it folds into.
|
|
const stateRef = useRef(state);
|
|
const update = useCallback((fn: (prev: SagiState) => SagiState) => {
|
|
const next = fn(stateRef.current);
|
|
if (next === stateRef.current) return;
|
|
stateRef.current = next;
|
|
setStateRaw(next);
|
|
}, []);
|
|
|
|
const [tick, setTick] = useState(now0);
|
|
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
|
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
|
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
|
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
|
|
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
|
|
|
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
|
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
|
const lastBubbleAt = useRef(0);
|
|
const burst = useRef<Burst | null>(null);
|
|
const momentKey = useRef(0);
|
|
const mutedRef = useRef(muted);
|
|
mutedRef.current = muted;
|
|
|
|
// Keep cleared alerts cleared across reloads.
|
|
useEffect(() => sagiPrefs.setDismissed(state.dismissed), [state.dismissed]);
|
|
|
|
// Keep mute in sync with the Settings page / other tabs.
|
|
useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []);
|
|
|
|
const putBubble = useCallback((b: SagiBubble, ms = BUBBLE_MS) => {
|
|
clearTimeout(bubbleTimer.current);
|
|
setBubbleState(b);
|
|
bubbleTimer.current = setTimeout(() => {
|
|
setBubbleState(null);
|
|
burst.current = null;
|
|
}, ms);
|
|
}, []);
|
|
|
|
const playMoment = useCallback((kind: PetMoment) => {
|
|
clearTimeout(momentTimer.current);
|
|
setMoment({ kind, key: ++momentKey.current });
|
|
momentTimer.current = setTimeout(() => setMoment(null), MOMENT_MS);
|
|
}, []);
|
|
|
|
const notify = useCallback(
|
|
(pulse: NonNullable<SagiPulse>, subject?: SagiSubject) => {
|
|
playMoment(MOMENT_OF_PULSE[pulse]);
|
|
if (mutedRef.current) return;
|
|
const t = Date.now();
|
|
const b = burst.current;
|
|
// The same kind of news again, while its bubble is still up: count it in.
|
|
if (b && b.pulse === pulse && t - b.at < COALESCE_MS) {
|
|
b.count++;
|
|
b.at = t;
|
|
if (subject && !b.subjects.some((s) => s.id === subject.id)) b.subjects.push(subject);
|
|
putBubble(burstBubble(b), SUBJECT_BUBBLE_MS);
|
|
return;
|
|
}
|
|
const force = pulse === "error" || pulse === "waiting";
|
|
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
|
lastBubbleAt.current = t;
|
|
const next: Burst = { pulse, count: 1, subjects: subject ? [subject] : [], at: t };
|
|
burst.current = next;
|
|
putBubble(burstBubble(next), subject ? SUBJECT_BUBBLE_MS : BUBBLE_MS);
|
|
},
|
|
[playMoment, putBubble]
|
|
);
|
|
|
|
// Seed from the REST snapshot on mount so counts are accurate immediately -
|
|
// the brain otherwise only learns about sessions from WS deltas that arrive
|
|
// after it mounts, showing "0 live" on a fresh load even when sessions exist.
|
|
// Pull a generous page of non-finished sessions; live WS deltas refine it.
|
|
// Then say hello, once per browser session.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
let greetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
const greet = () => {
|
|
try {
|
|
if (sessionStorage.getItem(GREETED_KEY)) return;
|
|
sessionStorage.setItem(GREETED_KEY, "1");
|
|
} catch {
|
|
return; // No storage: rather silent than a hello on every page.
|
|
}
|
|
greetTimer = setTimeout(() => {
|
|
if (cancelled || mutedRef.current) return;
|
|
const hello = pickLine(`greet.${greetingKey(new Date().getHours())}`);
|
|
if (!hello) return;
|
|
lastBubbleAt.current = Date.now();
|
|
putBubble({ text: hello, detail: statusSummary(statusOf(stateRef.current)) });
|
|
playMoment("wave");
|
|
}, GREET_DELAY_MS);
|
|
};
|
|
api.sessions
|
|
.list({ status: "active", limit: 100 })
|
|
.then((res) => {
|
|
if (cancelled) return;
|
|
update((prev) => seedSessions(prev, res.sessions, Date.now()));
|
|
})
|
|
.catch(() => undefined)
|
|
.finally(greet);
|
|
return () => {
|
|
cancelled = true;
|
|
clearTimeout(greetTimer);
|
|
};
|
|
}, [update, putBubble, playMoment]);
|
|
|
|
// Subscribe to the live stream and connection status.
|
|
useEffect(() => {
|
|
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
|
const prev = stateRef.current;
|
|
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
|
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
|
// resolution: keep the old state then, so a hook burst (several frames a
|
|
// second) does not re-render the mascot on every frame.
|
|
if (!onlyActivityMoved(prev, next)) update(() => next);
|
|
if (pulse) notify(pulse, subject);
|
|
});
|
|
const unsubConn = eventBus.onConnection((connected) => {
|
|
update((prev) => (prev.connected === connected ? prev : { ...prev, connected }));
|
|
});
|
|
return () => {
|
|
unsubMsg();
|
|
unsubConn();
|
|
};
|
|
}, [update, notify]);
|
|
|
|
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
|
// happy/worried) re-evaluate without needing a new event.
|
|
useEffect(() => {
|
|
// Hidden tab: no re-render; the first visible tick catches the mood up.
|
|
// Only a tick that changes the mood re-renders: an idle mascot is not
|
|
// drawn again every second for nothing.
|
|
const id = setInterval(() => {
|
|
if (document.visibilityState !== "visible") return;
|
|
const now = Date.now();
|
|
if (deriveMood(stateRef.current, now) !== moodRef.current) setTick(now);
|
|
}, 1000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
clearTimeout(bubbleTimer.current);
|
|
clearTimeout(momentTimer.current);
|
|
clearTimeout(reactionTimer.current);
|
|
},
|
|
[]
|
|
);
|
|
|
|
// Judged at render time: `tick` only moves when the mood would change, so
|
|
// it may be old when a frame re-renders.
|
|
const mood = useMemo(() => deriveMood(state, Math.max(tick, Date.now())), [state, tick]);
|
|
const status = useMemo(() => statusOf(state), [state]);
|
|
const attention = useMemo(() => attentionOf(state), [state]);
|
|
|
|
// An idle pet is not a statue: every minute or so it waves or looks around.
|
|
const moodRef = useRef(mood);
|
|
moodRef.current = mood;
|
|
useEffect(() => {
|
|
let timer: ReturnType<typeof setTimeout>;
|
|
const schedule = () => {
|
|
timer = setTimeout(
|
|
() => {
|
|
if (moodRef.current === "idle" && document.visibilityState === "visible")
|
|
playMoment(Math.random() < 0.5 ? "wave" : "review");
|
|
schedule();
|
|
},
|
|
FIDGET_MIN_MS + Math.random() * FIDGET_SPAN_MS
|
|
);
|
|
};
|
|
schedule();
|
|
return () => clearTimeout(timer);
|
|
}, [playMoment]);
|
|
|
|
const dismissBubble = useCallback(() => {
|
|
clearTimeout(bubbleTimer.current);
|
|
setBubbleState(null);
|
|
burst.current = null;
|
|
}, []);
|
|
|
|
const toggleMute = useCallback(() => {
|
|
const next = !mutedRef.current;
|
|
sagiPrefs.setMuted(next);
|
|
setMuted(next);
|
|
if (next) dismissBubble();
|
|
}, [dismissBubble]);
|
|
|
|
const clearAlerts = useCallback(() => {
|
|
update((prev) => clearErrors(prev));
|
|
if (burst.current?.pulse === "error") dismissBubble();
|
|
}, [update, dismissBubble]);
|
|
|
|
const setThinking = useCallback(
|
|
(v: boolean) => update((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
|
|
[update]
|
|
);
|
|
|
|
// One-shot bubble: clears itself after BUBBLE_MS. Used by the shell for
|
|
// imperative messages (e.g. spawn errors) that must appear now and fade,
|
|
// bypassing the throttled mood-driven showBubble path.
|
|
const setBubble = useCallback(
|
|
(text: string) => {
|
|
if (!text) return;
|
|
burst.current = null;
|
|
putBubble({ text });
|
|
},
|
|
[putBubble]
|
|
);
|
|
|
|
const pat = useCallback(() => {
|
|
playMoment("jump");
|
|
update((prev) => ({ ...prev, happyUntil: Date.now() + MOMENT_MS }));
|
|
clearTimeout(reactionTimer.current);
|
|
setReaction({ text: pickLine("pat"), key: ++momentKey.current });
|
|
reactionTimer.current = setTimeout(() => setReaction(null), MOMENT_MS);
|
|
}, [playMoment, update]);
|
|
|
|
return {
|
|
mood,
|
|
status,
|
|
attention,
|
|
bubble,
|
|
dismissBubble,
|
|
setBubble,
|
|
moment,
|
|
playMoment,
|
|
pat,
|
|
reaction,
|
|
muted,
|
|
toggleMute,
|
|
clearAlerts,
|
|
setThinking,
|
|
};
|
|
}
|