mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
Merge pull request #62016 from NousResearch/bb/desktop-vibe-hearts
feat: vibe reactions — floating hearts on affection, across CLI/TUI/desktop
This commit is contained in:
commit
291eae63b7
24 changed files with 687 additions and 19 deletions
|
|
@ -302,6 +302,7 @@ def init_agent(
|
|||
notice_callback: callable = None,
|
||||
notice_clear_callback: callable = None,
|
||||
event_callback: Optional[Callable[[str, dict], None]] = None,
|
||||
reaction_callback: Optional[Callable[[str], None]] = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
|
|
@ -535,6 +536,7 @@ def init_agent(
|
|||
agent.notice_callback = notice_callback
|
||||
agent.notice_clear_callback = notice_clear_callback
|
||||
agent.event_callback = event_callback
|
||||
agent.reaction_callback = reaction_callback
|
||||
agent.tool_gen_callback = tool_gen_callback
|
||||
|
||||
|
||||
|
|
|
|||
56
agent/reactions.py
Normal file
56
agent/reactions.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Token-free detection of user *reactions* to the agent.
|
||||
|
||||
Currently the only reaction is ``vibe`` — an expression of affection or
|
||||
gratitude toward the agent (``ily``, ``<3``, ``love you``, ``good bot``, a heart
|
||||
emoji, …). Detection is a curated regex/lexicon: **no model call, no tokens**.
|
||||
|
||||
This is the single source of truth shared by every surface — the CLI pet, the
|
||||
TUI heart, and the desktop floating hearts all react off the same signal,
|
||||
delivered via ``AIAgent.reaction_callback`` (wired per interactive host).
|
||||
|
||||
Generalized on purpose: :func:`detect_reaction` returns a reaction *kind*
|
||||
string, so new kinds (other emoji reactions, etc.) can be added here without
|
||||
touching any caller. We match affection specifically — not general positive
|
||||
sentiment — so "this is great" does NOT fire, but "good bot" / "❤️" do.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
#: The affection/gratitude reaction — the only kind today.
|
||||
VIBE = "vibe"
|
||||
|
||||
# Curated affection lexicon. Kept deliberately narrow: gratitude + love aimed at
|
||||
# the agent, heart emoji, and ``<3`` (but not the broken heart ``</3``).
|
||||
_VIBE_RE = re.compile(
|
||||
"|".join(
|
||||
(
|
||||
r"\bgood\s*bot\b",
|
||||
r"\bi\s*(?:love|luv)\s*(?:you|u|ya)\b",
|
||||
r"\b(?:love|luv)\s*(?:you|u|ya)\b",
|
||||
r"\bily(?:sm)?\b",
|
||||
r"\bthank\s*(?:you|u)\b",
|
||||
r"\b(?:thanks|thx|tysm|ty)\b",
|
||||
r"<3+", # <3, <33 … but not </3
|
||||
# Hearts + affection faces (❤ ♥ 🥰 😍 😘 💕 💖 💗 💞 💛 💜 💚 💙 💓 💘 💝 🩷).
|
||||
r"[\u2764\u2665"
|
||||
r"\U0001F970\U0001F60D\U0001F618"
|
||||
r"\U0001F495\U0001F496\U0001F497\U0001F49E"
|
||||
r"\U0001F49B\U0001F49C\U0001F49A\U0001F499"
|
||||
r"\U0001F493\U0001F498\U0001F49D\U0001FA77]",
|
||||
)
|
||||
),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def detect_reaction(text: str | None) -> str | None:
|
||||
"""Return the reaction kind for *text* (currently :data:`VIBE`), or ``None``.
|
||||
|
||||
Pure, token-free, and safe to call on every user turn.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
return VIBE if _VIBE_RE.search(text) else None
|
||||
|
|
@ -319,6 +319,20 @@ def build_turn_context(
|
|||
current_turn_user_idx = len(messages) - 1
|
||||
agent._persist_user_message_idx = current_turn_user_idx
|
||||
|
||||
# Cosmetic side-signal: detect an affection "reaction" (ily / <3 / good bot)
|
||||
# and notify the host so it can play hearts. Token-free, never touches the
|
||||
# conversation, and never fatal — a purely optional UI beat.
|
||||
reaction_callback = getattr(agent, "reaction_callback", None)
|
||||
if reaction_callback is not None:
|
||||
try:
|
||||
from agent.reactions import detect_reaction
|
||||
|
||||
kind = detect_reaction(original_user_message)
|
||||
if kind:
|
||||
reaction_callback(kind)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not agent.quiet_mode:
|
||||
_print_preview = summarize_user_message_for_log(user_message)
|
||||
agent._safe_print(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useLocation } from 'react-router-dom'
|
|||
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts'
|
||||
import { PromptOverlays } from '@/components/prompt-overlays'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
|
|
@ -30,6 +31,8 @@ import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-s
|
|||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { $petActive } from '@/store/pet'
|
||||
import { $petOverlayActive } from '@/store/pet-overlay'
|
||||
import { $gatewaySwapTarget } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
|
|
@ -297,6 +300,10 @@ export function ChatView({
|
|||
const currentCwd = useStore($currentCwd)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
// A pet anywhere (in-window or popped out) owns the hearts; composer only when none.
|
||||
const petActive = useStore($petActive)
|
||||
const petOverlayActive = useStore($petOverlayActive)
|
||||
const petPresent = petActive || petOverlayActive
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const gatewaySwapTarget = useStore($gatewaySwapTarget)
|
||||
|
|
@ -491,6 +498,18 @@ export function ChatView({
|
|||
</div>
|
||||
)}
|
||||
{showChatBar && <ScrollToBottomButton />}
|
||||
{/* Vibe hearts rise from the composer only when no pet is out (else
|
||||
they play on the pet). Fired by the core `reaction` event. */}
|
||||
{!petPresent && (
|
||||
<HeartField
|
||||
className="absolute inset-x-0 z-30"
|
||||
config={COMPOSER_HEART_CONFIG}
|
||||
style={{
|
||||
top: 0,
|
||||
bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.25rem)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { PetHeartField, playVibeHearts } from '@/components/chat/vibe-hearts'
|
||||
import { PetBubble } from '@/components/pet/pet-bubble'
|
||||
import { PetSprite } from '@/components/pet/pet-sprite'
|
||||
import { type PetZoomAnchor, usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture'
|
||||
|
|
@ -72,6 +73,8 @@ export function PetOverlayApp() {
|
|||
const zoomAnchorRef = useRef<PetZoomAnchor | null>(null)
|
||||
const petRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
// Last mirrored reaction id — a bump means the main window fired a reaction.
|
||||
const lastReactionRef = useRef<number | null>(null)
|
||||
const ignoreRef = useRef(true)
|
||||
const composerOpenRef = useRef(false)
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
|
@ -91,6 +94,19 @@ export function PetOverlayApp() {
|
|||
setBusy(Boolean(payload.busy))
|
||||
setAwaitingResponse(Boolean(payload.awaiting))
|
||||
setUnread(Boolean(payload.unread))
|
||||
|
||||
// Play a reaction on a new id (ignore the first sync, which just primes it).
|
||||
const reaction = payload.reaction ?? null
|
||||
|
||||
if (lastReactionRef.current === null) {
|
||||
lastReactionRef.current = reaction?.id ?? 0
|
||||
} else if (reaction && reaction.id > lastReactionRef.current) {
|
||||
lastReactionRef.current = reaction.id
|
||||
|
||||
if (reaction.kind === 'vibe') {
|
||||
playVibeHearts()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Tell the main renderer we're mounted so it pushes the current frame (the
|
||||
|
|
@ -416,6 +432,12 @@ export function PetOverlayApp() {
|
|||
<div style={{ lineHeight: 0, position: 'relative' }}>
|
||||
<PetSprite info={info} />
|
||||
|
||||
{/* Hearts on the popped-out pet — identical to in-window. */}
|
||||
<PetHeartField
|
||||
petH={(info.frameH ?? DEFAULT_FRAME_H) * (info.scale ?? DEFAULT_SCALE)}
|
||||
petW={(info.frameW ?? DEFAULT_FRAME_W) * (info.scale ?? DEFAULT_SCALE)}
|
||||
/>
|
||||
|
||||
{/* Mail icon: only when a finish landed while you were away. Jumps to
|
||||
the app's most recent thread. Anchored to the sprite (kept inside
|
||||
its box so the overlay's click-through hit-test still catches it);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { type MutableRefObject, useCallback } from 'react'
|
|||
import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'
|
||||
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
|
||||
import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { burstVibeHearts } from '@/components/chat/vibe-hearts'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { type GatewayEventPayload, textPart } from '@/lib/chat-messages'
|
||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
|
|
@ -264,6 +265,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
// KawaiiSpinner), not real reasoning. The bottom-of-thread loading
|
||||
// indicator already covers that UX, so we ignore these events to
|
||||
// avoid a duplicative "Thinking" disclosure showing spinner text.
|
||||
} else if (event.type === 'reaction') {
|
||||
// Core-detected affection (ily / <3 / good bot) on the user's message.
|
||||
// Play hearts only for the visible session so background turns stay quiet.
|
||||
if (isActiveEvent && (payload?.kind ?? 'vibe') === 'vibe') {
|
||||
burstVibeHearts()
|
||||
}
|
||||
} else if (event.type === 'reasoning.delta') {
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text))
|
||||
|
|
|
|||
119
apps/desktop/src/components/chat/vibe-hearts.tsx
Normal file
119
apps/desktop/src/components/chat/vibe-hearts.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { type CSSProperties } from 'react'
|
||||
|
||||
import {
|
||||
createParticleEmitter,
|
||||
ParticleField,
|
||||
type ParticleFieldConfig
|
||||
} from '@/components/particles/particle-field'
|
||||
import { $petActive, flashPetActivity } from '@/store/pet'
|
||||
import { $petOverlayActive, forwardPetReaction } from '@/store/pet-overlay'
|
||||
|
||||
/**
|
||||
* TikTok-style floating hearts — a thin skin over {@link ParticleField} (pixel
|
||||
* heart glyph + pink). Placed two ways: rising from the composer when no pet is
|
||||
* out, or from the pet when one is. Fired by the core `reaction` event (affection
|
||||
* in a user message) via {@link burstVibeHearts}.
|
||||
*/
|
||||
|
||||
// Light pink reads on both light and dark chat surfaces.
|
||||
const HEART_COLORS = ['#ff9ec4'] as const
|
||||
|
||||
/** Composer placement: hearts rise the thread height (rise = % of the tall lane). */
|
||||
export const COMPOSER_HEART_CONFIG: Partial<ParticleFieldConfig> = {
|
||||
count: 12,
|
||||
size: [6, 13],
|
||||
rise: [6.75, 15.75],
|
||||
duration: [320, 700]
|
||||
}
|
||||
|
||||
/** Pet placement: a compact puff off the pet. The field box spans feet→head, so
|
||||
* rise ≥100% carries hearts from the feet to ~10-20% above the pet before fading. */
|
||||
const PET_HEART_CONFIG: Partial<ParticleFieldConfig> = {
|
||||
count: 10,
|
||||
spawnWindowMs: 450,
|
||||
size: [6, 12],
|
||||
rise: [98, 118],
|
||||
duration: [480, 880],
|
||||
swayAmp: [5, 14],
|
||||
bank: [6, 14]
|
||||
}
|
||||
|
||||
// Pixel-art heart from @nous-research/ui (14×12), crisp + `currentColor`.
|
||||
const HEART_GLYPH = (
|
||||
<svg fill="none" shapeRendering="crispEdges" viewBox="0 0 14 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M13.2 0v5.65714h-1.8857v1.88572H9.42857v1.88571H7.54286v1.88573H5.65714V9.42857H3.77143V7.54286H1.88571V5.65714H0V0h5.65714v1.88571h1.88572V0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const emitter = createParticleEmitter()
|
||||
|
||||
/** Play hearts in THIS window (whichever HeartField is mounted). The overlay
|
||||
* window calls this directly off the mirrored vibe signal. */
|
||||
export const playVibeHearts = (count?: number) => emitter.burst(count)
|
||||
|
||||
/**
|
||||
* Fire a vibe burst (from the core `reaction` event). Routes to where the
|
||||
* affection should land:
|
||||
* - pet popped out → forward to the overlay window + celebrate (mirrored)
|
||||
* - pet in-window → play here (on the pet) + celebrate
|
||||
* - no pet → play here (composer)
|
||||
*/
|
||||
export const burstVibeHearts = (count?: number) => {
|
||||
const overlay = $petOverlayActive.get()
|
||||
|
||||
if (overlay || $petActive.get()) {
|
||||
flashPetActivity({ celebrate: true })
|
||||
}
|
||||
|
||||
if (overlay) {
|
||||
forwardPetReaction('vibe')
|
||||
} else {
|
||||
playVibeHearts(count)
|
||||
}
|
||||
}
|
||||
|
||||
export interface HeartFieldProps {
|
||||
config?: Partial<ParticleFieldConfig>
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
/** Heart-skinned particle field. Caller supplies placement + a config preset. */
|
||||
export function HeartField({ config, className, style }: HeartFieldProps) {
|
||||
return (
|
||||
<ParticleField
|
||||
className={className}
|
||||
colors={HEART_COLORS}
|
||||
config={config}
|
||||
emitter={emitter}
|
||||
glyph={HEART_GLYPH}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pet-anchored hearts, feet→~10-20% above. One place owns the geometry so the
|
||||
* in-window pet and the popped-out overlay stay identical. `petW`/`petH` are the
|
||||
* rendered sprite dimensions (frame × scale).
|
||||
*/
|
||||
export function PetHeartField({ petW, petH }: { petW: number; petH: number }) {
|
||||
return (
|
||||
<HeartField
|
||||
config={PET_HEART_CONFIG}
|
||||
style={{
|
||||
bottom: 0,
|
||||
height: Math.max(96, petH),
|
||||
left: '50%',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
transform: 'translateX(-50%)',
|
||||
width: Math.max(90, petW * 1.5),
|
||||
zIndex: 2
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
126
apps/desktop/src/components/particles/particle-field.css
Normal file
126
apps/desktop/src/components/particles/particle-field.css
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/* ── Particle field ─────────────────────────────────────────────────────────
|
||||
Reusable float-up emitter. Three nested layers, each owning ONE transform so
|
||||
they never fight:
|
||||
.particle — full-height track: vertical rise + fade (linear)
|
||||
.particle__sway — horizontal weave + bank/tilt on its OWN period
|
||||
(ease-in-out, alternating), so the path desyncs from
|
||||
the rise and never repeats — the organic-motion trick
|
||||
.particle__glyph — one-shot springy pop-in scale
|
||||
The track is full-height and anchored at the field bottom, so the rise's
|
||||
`translateY(-rise%)` measures against the field height, not the glyph's. All
|
||||
tuning arrives as inline `--particle-*` custom properties. */
|
||||
.particle-field {
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: var(--particle-left);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
width: var(--particle-size);
|
||||
margin-left: calc(var(--particle-size) / -2);
|
||||
will-change: transform, opacity;
|
||||
animation: particle-rise var(--particle-duration) linear var(--particle-delay) both;
|
||||
}
|
||||
|
||||
.particle__sway {
|
||||
display: block;
|
||||
will-change: transform;
|
||||
animation: particle-sway var(--particle-sway-duration) ease-in-out var(--particle-sway-delay)
|
||||
infinite alternate;
|
||||
}
|
||||
|
||||
.particle__glyph {
|
||||
display: block;
|
||||
width: var(--particle-size);
|
||||
color: var(--particle-color);
|
||||
will-change: transform;
|
||||
animation: particle-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1) var(--particle-delay) both;
|
||||
}
|
||||
|
||||
.particle__glyph > svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Rise: straight up the field, holds opacity through most of the climb, fades
|
||||
near the top. */
|
||||
@keyframes particle-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
6% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, calc(var(--particle-rise) * -0.06%), 0);
|
||||
}
|
||||
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, calc(var(--particle-rise) * -0.7%), 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, calc(var(--particle-rise) * -1%), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sway + bank: swings left↔right and tilts INTO the direction of travel, like a
|
||||
leaf on a breeze. Runs on its own clock. */
|
||||
@keyframes particle-sway {
|
||||
from {
|
||||
transform: translateX(calc(var(--particle-sway) * -1)) rotate(calc(var(--particle-bank) * -1));
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(var(--particle-sway)) rotate(var(--particle-bank));
|
||||
}
|
||||
}
|
||||
|
||||
/* Pop: a springy scale-in overshoot (the bezier is a spring, not a path wobble). */
|
||||
@keyframes particle-pop {
|
||||
0% {
|
||||
transform: scale(0.3);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.particle {
|
||||
inset-block: auto 0;
|
||||
height: var(--particle-size);
|
||||
animation: particle-flash 650ms ease-out var(--particle-delay) both;
|
||||
}
|
||||
|
||||
.particle__sway,
|
||||
.particle__glyph {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes particle-flash {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 0, 0) scale(0.6);
|
||||
}
|
||||
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, -0.5rem, 0) scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -1rem, 0) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
204
apps/desktop/src/components/particles/particle-field.tsx
Normal file
204
apps/desktop/src/components/particles/particle-field.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import './particle-field.css'
|
||||
|
||||
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Reusable float-up particle emitter. It owns the motion (rise + organic sway +
|
||||
* springy pop) and lifecycle (staggered burst, lifetime, cleanup); callers just
|
||||
* hand it a `glyph` (any element using `currentColor`) and `colors`, then place
|
||||
* it with `className` / `style`. See {@link VibeHearts} for the chat-hearts use.
|
||||
*/
|
||||
|
||||
type Range = readonly [min: number, max: number]
|
||||
|
||||
const rand = ([min, max]: Range) => min + Math.random() * (max - min)
|
||||
/** Sample a range along `t` (0→min, 1→max) — couples travel/lifetime to `life`. */
|
||||
const lerp = ([min, max]: Range, t: number) => min + (max - min) * t
|
||||
|
||||
export interface ParticleFieldConfig {
|
||||
/** Particles per burst when `burst()` is called without a count. */
|
||||
count: number
|
||||
/** Window (ms) over which a burst releases its particles — small = one poof. */
|
||||
spawnWindowMs: number
|
||||
/** Glyph edge size (px), uniform. */
|
||||
size: Range
|
||||
/** Vertical travel before fade-out (% of field height), `life`-biased. */
|
||||
rise: Range
|
||||
/** Rise duration (ms), `life`-biased so short-lived particles also rise less. */
|
||||
duration: Range
|
||||
/** Sway amplitude each side of center (px), uniform. */
|
||||
swayAmp: Range
|
||||
/** Peak tilt into the sway (deg), uniform. */
|
||||
bank: Range
|
||||
/** Sway period (ms), uniform — independent of the rise so paths never repeat. */
|
||||
swayDuration: Range
|
||||
/** Cap on simultaneously-alive particles. */
|
||||
maxAlive: number
|
||||
}
|
||||
|
||||
export const DEFAULT_PARTICLE_CONFIG: ParticleFieldConfig = {
|
||||
count: 12,
|
||||
spawnWindowMs: 550,
|
||||
size: [6, 13],
|
||||
rise: [6.75, 15.75],
|
||||
duration: [320, 700],
|
||||
swayAmp: [9, 24],
|
||||
bank: [7, 16],
|
||||
swayDuration: [1300, 2800],
|
||||
maxAlive: 200
|
||||
}
|
||||
|
||||
export interface ParticleEmitter {
|
||||
/** Fire a burst (defaults to the field's configured `count`). */
|
||||
burst: (count?: number) => void
|
||||
/** Internal: field subscription. */
|
||||
subscribe: (fn: (count?: number) => void) => () => void
|
||||
}
|
||||
|
||||
/** Create an emitter handle. `burst()` is safe to call from anywhere. */
|
||||
export function createParticleEmitter(): ParticleEmitter {
|
||||
const listeners = new Set<(count?: number) => void>()
|
||||
|
||||
return {
|
||||
burst: count => listeners.forEach(fn => fn(count)),
|
||||
subscribe: fn => {
|
||||
listeners.add(fn)
|
||||
|
||||
return () => void listeners.delete(fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
id: number
|
||||
leftPct: number
|
||||
size: number
|
||||
color: string
|
||||
delayMs: number
|
||||
durationMs: number
|
||||
rise: number
|
||||
swayAmp: number
|
||||
bank: number
|
||||
swayDurationMs: number
|
||||
swayDelayMs: number
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
|
||||
function spawn(cfg: ParticleFieldConfig, colors: readonly string[]): Particle {
|
||||
// Short-lived particles fade out lower; a few live longer and rise higher.
|
||||
const life = Math.random() ** 1.7
|
||||
const swayDurationMs = Math.round(rand(cfg.swayDuration))
|
||||
|
||||
return {
|
||||
id: nextId++,
|
||||
// Spread edge to edge across the lane, not clustered near center.
|
||||
leftPct: 4 + Math.random() * 92,
|
||||
size: rand(cfg.size),
|
||||
color: colors[Math.floor(Math.random() * colors.length)]!,
|
||||
delayMs: Math.round(Math.random() * 120),
|
||||
durationMs: Math.round(lerp(cfg.duration, life)),
|
||||
rise: lerp(cfg.rise, life),
|
||||
swayAmp: rand(cfg.swayAmp),
|
||||
bank: rand(cfg.bank),
|
||||
swayDurationMs,
|
||||
// Negative delay drops each particle in mid-swing (desynced phases).
|
||||
swayDelayMs: -Math.round(Math.random() * swayDurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
|
||||
|
||||
export interface ParticleFieldProps {
|
||||
emitter: ParticleEmitter
|
||||
/** Any element that paints with `currentColor` (SVG, glyph, …). */
|
||||
glyph: ReactNode
|
||||
colors: readonly string[]
|
||||
config?: Partial<ParticleFieldConfig>
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
export function ParticleField({ emitter, glyph, colors, config, className, style }: ParticleFieldProps) {
|
||||
const cfg = useMemo(() => ({ ...DEFAULT_PARTICLE_CONFIG, ...config }), [config])
|
||||
const [particles, setParticles] = useState<Particle[]>([])
|
||||
const timers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const pool = timers.current
|
||||
const add = () => setParticles(prev => [...prev, spawn(cfg, colors)].slice(-cfg.maxAlive))
|
||||
|
||||
// Release a burst across a tight window so it reads as one poof, each node
|
||||
// with its own random birth time; reduced motion gets a single flash.
|
||||
const onBurst = (count?: number) => {
|
||||
const n = Math.max(1, Math.min(cfg.maxAlive, Math.round(count ?? cfg.count)))
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
add()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const timer = setTimeout(() => {
|
||||
pool.delete(timer)
|
||||
add()
|
||||
}, Math.random() * cfg.spawnWindowMs)
|
||||
|
||||
pool.add(timer)
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = emitter.subscribe(onBurst)
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
pool.forEach(clearTimeout)
|
||||
pool.clear()
|
||||
}
|
||||
}, [cfg, colors, emitter])
|
||||
|
||||
const remove = (id: number) => setParticles(prev => prev.filter(p => p.id !== id))
|
||||
|
||||
if (particles.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-hidden className={cn('particle-field', className)} style={style}>
|
||||
{particles.map(p => (
|
||||
<span
|
||||
className="particle"
|
||||
key={p.id}
|
||||
// Retire on the RISE track only (sway is infinite, pop is shorter).
|
||||
onAnimationEnd={e => {
|
||||
if (e.animationName === 'particle-rise' || e.animationName === 'particle-flash') {
|
||||
remove(p.id)
|
||||
}
|
||||
}}
|
||||
style={
|
||||
{
|
||||
'--particle-left': `${p.leftPct}%`,
|
||||
'--particle-size': `${p.size}px`,
|
||||
'--particle-color': p.color,
|
||||
'--particle-delay': `${p.delayMs}ms`,
|
||||
'--particle-duration': `${p.durationMs}ms`,
|
||||
'--particle-rise': p.rise,
|
||||
'--particle-sway': `${p.swayAmp}px`,
|
||||
'--particle-bank': `${p.bank}deg`,
|
||||
'--particle-sway-duration': `${p.swayDurationMs}ms`,
|
||||
'--particle-sway-delay': `${p.swayDelayMs}ms`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="particle__sway">
|
||||
<span className="particle__glyph">{glyph}</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { useOnProfileSwitch } from '@/app/hooks/use-on-profile-switch'
|
||||
import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active'
|
||||
import { PetHeartField } from '@/components/chat/vibe-hearts'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import {
|
||||
$petAtRest,
|
||||
|
|
@ -447,6 +448,9 @@ export function FloatingPet() {
|
|||
>
|
||||
<PetSprite info={info} rowOverride={walk.row} />
|
||||
</div>
|
||||
{/* Hearts puff off the pet; its celebrate ("yay"/jump) pose is driven by
|
||||
burstVibeHearts's router. */}
|
||||
<PetHeartField petH={petH} petW={petW} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export interface PetOverlayStatePayload {
|
|||
awaiting: boolean
|
||||
/** Drives the overlay's mail icon: a finish landed while you were away. */
|
||||
unread: boolean
|
||||
/** Latest reaction — bumping its id forwards a burst to the overlay. */
|
||||
reaction: PetReaction | null
|
||||
}
|
||||
|
||||
export type PetOverlayControl =
|
||||
|
|
@ -67,6 +69,22 @@ export const $petOverlayActive = atom(storedBoolean(OVERLAY_ACTIVE_KEY, false))
|
|||
// Persist the in/out choice so a popped-out pet comes back popped out.
|
||||
$petOverlayActive.subscribe(active => persistBoolean(OVERLAY_ACTIVE_KEY, active))
|
||||
|
||||
/**
|
||||
* Reaction signal forwarded to the popped-out overlay window via the state
|
||||
* mirror below. `id` is a monotonic nonce so the overlay fires once per bump;
|
||||
* `kind` selects the renderer (today only `vibe` → hearts). Generic on purpose
|
||||
* so future reactions (emoji, etc.) ride the same channel.
|
||||
*/
|
||||
export interface PetReaction {
|
||||
id: number
|
||||
kind: string
|
||||
}
|
||||
|
||||
export const $petReaction = atom<PetReaction | null>(null)
|
||||
|
||||
export const forwardPetReaction = (kind: string) =>
|
||||
$petReaction.set({ id: ($petReaction.get()?.id ?? 0) + 1, kind })
|
||||
|
||||
function loadSavedBounds(): null | PetOverlayBounds {
|
||||
try {
|
||||
const raw = storedString(OVERLAY_BOUNDS_KEY)
|
||||
|
|
@ -129,7 +147,8 @@ function currentPayload(): PetOverlayStatePayload {
|
|||
activity: $petActivity.get(),
|
||||
busy: $busy.get(),
|
||||
awaiting: $awaitingResponse.get(),
|
||||
unread: $petUnread.get()
|
||||
unread: $petUnread.get(),
|
||||
reaction: $petReaction.get()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,7 +184,8 @@ function openOverlay(request: PetOverlayOpenRequest): void {
|
|||
$petActivity.subscribe(pushNow),
|
||||
$busy.subscribe(pushNow),
|
||||
$awaitingResponse.subscribe(pushNow),
|
||||
$petUnread.subscribe(pushNow)
|
||||
$petUnread.subscribe(pushNow),
|
||||
$petReaction.subscribe(pushNow)
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,9 @@ export function derivePetState(activity: PetActivity): PetState {
|
|||
export const $petInfo = atom<PetInfo>({ enabled: false })
|
||||
export const $petActivity = atom<PetActivity>({})
|
||||
|
||||
/** Pet installed + enabled with a loaded spritesheet (ready to show/react). */
|
||||
export const $petActive = computed($petInfo, info => info.enabled && Boolean(info.spritesheetBase64))
|
||||
|
||||
/**
|
||||
* Profile the pet RPCs should resolve against. Pets are per-profile — the active
|
||||
* pet (`display.pet.*`) and the installed sprites live under each profile's
|
||||
|
|
|
|||
6
cli.py
6
cli.py
|
|
@ -4862,6 +4862,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
self._pet_event = state
|
||||
self._pet_event_until = time.monotonic() + secs
|
||||
|
||||
def _on_reaction(self, kind: str) -> None:
|
||||
"""User affection (ily / <3 / good bot), core-detected — the pet's share
|
||||
of the vibe signal that plays hearts on the TUI/desktop. Flash a celebrate."""
|
||||
if kind == "vibe":
|
||||
self._pet_flash("jump")
|
||||
|
||||
def _pet_react_turn_end(self) -> None:
|
||||
"""Flash the end-of-turn beat: failed on error, jump on a finished plan, else wave."""
|
||||
if not self._pet_enabled:
|
||||
|
|
|
|||
|
|
@ -390,6 +390,7 @@ class CLIAgentSetupMixin:
|
|||
tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None,
|
||||
notice_callback=self._on_notice,
|
||||
notice_clear_callback=self._on_notice_clear,
|
||||
reaction_callback=self._on_reaction,
|
||||
)
|
||||
# Store reference for atexit memory provider shutdown.
|
||||
# NOTE: this MUST write to the ``cli`` module's global, not a
|
||||
|
|
|
|||
|
|
@ -458,6 +458,7 @@ class AIAgent:
|
|||
notice_callback: callable = None,
|
||||
notice_clear_callback: callable = None,
|
||||
event_callback: Optional[Callable[[str, dict], None]] = None,
|
||||
reaction_callback: Optional[Callable[[str], None]] = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
|
|
@ -533,6 +534,7 @@ class AIAgent:
|
|||
notice_callback=notice_callback,
|
||||
notice_clear_callback=notice_clear_callback,
|
||||
event_callback=event_callback,
|
||||
reaction_callback=reaction_callback,
|
||||
max_tokens=max_tokens,
|
||||
reasoning_config=reasoning_config,
|
||||
service_tier=service_tier,
|
||||
|
|
|
|||
56
tests/agent/test_reactions.py
Normal file
56
tests/agent/test_reactions.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Behavior tests for the token-free reaction detector."""
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.reactions import VIBE, detect_reaction
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"good bot",
|
||||
"Good Bot!",
|
||||
"ily",
|
||||
"ilysm",
|
||||
"i love you",
|
||||
"love you",
|
||||
"love u",
|
||||
"luv ya",
|
||||
"thanks",
|
||||
"thank you",
|
||||
"thx",
|
||||
"ty",
|
||||
"tysm",
|
||||
"you're the best <3",
|
||||
"here you go <33",
|
||||
"❤️",
|
||||
"🥰 amazing",
|
||||
"sending 💖",
|
||||
"great job, thank you so much!",
|
||||
],
|
||||
)
|
||||
def test_affection_fires_vibe(text):
|
||||
assert detect_reaction(text) == VIBE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"",
|
||||
None,
|
||||
"run the tests",
|
||||
"this is great", # positive sentiment, NOT affection — must not fire
|
||||
"awesome work on the refactor",
|
||||
"it's broken </3", # broken heart is not affection
|
||||
"the ferry departs at 3", # 'ty' must be word-bounded, not match 'ferTY'-like
|
||||
"commit the changes",
|
||||
],
|
||||
)
|
||||
def test_neutral_or_negative_does_not_fire(text):
|
||||
assert detect_reaction(text) is None
|
||||
|
||||
|
||||
def test_case_insensitive_invariant():
|
||||
# Casing must never change the classification.
|
||||
for text in ("ILY", "iLy", "ily"):
|
||||
assert detect_reaction(text) == detect_reaction("ily")
|
||||
|
|
@ -3870,6 +3870,9 @@ def _agent_cbs(sid: str) -> dict:
|
|||
"tool_gen_callback": lambda name: _tool_progress_enabled(sid)
|
||||
and _emit("tool.generating", sid, {"name": name}),
|
||||
"thinking_callback": lambda text: _emit("thinking.delta", sid, {"text": text}),
|
||||
# Affection reaction (ily / <3 / good bot) → hearts. Core-detected, so
|
||||
# the TUI heart and desktop floating hearts share one signal.
|
||||
"reaction_callback": lambda kind: _emit("reaction", sid, {"kind": kind}),
|
||||
"reasoning_callback": lambda text: _emit(
|
||||
"reasoning.delta",
|
||||
sid,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ function makeDeps(gw: GatewayClient, over: Partial<SubmitPromptDeps> = {}): Subm
|
|||
enqueue: vi.fn(),
|
||||
expand: (t: string) => t,
|
||||
gw,
|
||||
maybeGoodVibes: vi.fn(),
|
||||
setLastUserMsg: vi.fn(),
|
||||
sys: vi.fn(),
|
||||
...over
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import type { Msg, SubagentProgress, SubagentStatus } from '../types.js'
|
|||
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
|
||||
import type { GatewayEventHandlerContext } from './interfaces.js'
|
||||
import { getOverlayState, patchOverlayState } from './overlayStore.js'
|
||||
import { flashPet } from './petFlashStore.js'
|
||||
import { flashGoodVibes, flashPet } from './petFlashStore.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { getTurnState } from './turnStore.js'
|
||||
import { getUiState, patchUiState } from './uiStore.js'
|
||||
|
|
@ -717,6 +717,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
|
||||
return
|
||||
|
||||
case 'reaction':
|
||||
// Core-detected affection (ily / <3 / good bot): flash the ♥ and let the
|
||||
// pet celebrate. Same signal drives the desktop's floating hearts.
|
||||
flashGoodVibes()
|
||||
flashPet('jump')
|
||||
|
||||
return
|
||||
|
||||
case 'tool.start':
|
||||
turnController.recordTodos(ev.payload.todos)
|
||||
turnController.recordToolStart(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ export const $petFlash = atom<PetFlash | null>(null)
|
|||
|
||||
export const flashPet = (state: PetState, ms = 1600) => $petFlash.set({ state, until: Date.now() + ms })
|
||||
|
||||
// Affection-heart beat: a monotonic tick the status-bar ♥ flashes on. Bumped by
|
||||
// the gateway `reaction` event (core-detected ily / <3 / good bot) — the TUI's
|
||||
// share of the same signal that plays the desktop's floating hearts.
|
||||
export const $goodVibesTick = atom(0)
|
||||
|
||||
export const flashGoodVibes = () => $goodVibesTick.set($goodVibesTick.get() + 1)
|
||||
|
||||
// The floating pet's footprint, or null when no pet is shown. The transcript
|
||||
// keeps its text clear of the pet responsively: on wide terminals it reserves a
|
||||
// right gutter (`width`) so lines wrap to the pet's LEFT; on narrow terminals it
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ export interface SubmitPromptDeps {
|
|||
enqueue: (text: string) => void
|
||||
expand: (text: string) => string
|
||||
gw: GatewayClient
|
||||
maybeGoodVibes: (text: string) => void
|
||||
setLastUserMsg: (value: string) => void
|
||||
sys: (text: string) => void
|
||||
}
|
||||
|
|
@ -61,7 +60,6 @@ export function submitPrompt(text: string, deps: SubmitPromptDeps, showUserMessa
|
|||
}
|
||||
|
||||
turnController.clearStatusTimer()
|
||||
deps.maybeGoodVibes(submitText)
|
||||
deps.setLastUserMsg(text)
|
||||
|
||||
if (show) {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { planGatewayRecovery } from './gatewayRecovery.js'
|
|||
import { getInputSelection } from './inputSelectionStore.js'
|
||||
import { type GatewayRpc, type TranscriptRow } from './interfaces.js'
|
||||
import { $overlayState, patchOverlayState } from './overlayStore.js'
|
||||
import { $goodVibesTick } from './petFlashStore.js'
|
||||
import { scrollWithSelectionBy } from './scroll.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { patchTurnState, useTurnSelector } from './turnStore.js'
|
||||
|
|
@ -48,7 +49,6 @@ import { useLongRunToolCharms } from './useLongRunToolCharms.js'
|
|||
import { useSessionLifecycle } from './useSessionLifecycle.js'
|
||||
import { useSubmission } from './useSubmission.js'
|
||||
|
||||
const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i
|
||||
const BRACKET_PASTE_ON = '\x1b[?2004h'
|
||||
const BRACKET_PASTE_OFF = '\x1b[?2004l'
|
||||
const MAX_HEIGHT_CACHE_BUCKETS = 12
|
||||
|
|
@ -185,7 +185,8 @@ export function useMainApp(gw: GatewayClient) {
|
|||
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
|
||||
const [turnStartedAt, setTurnStartedAt] = useState<null | number>(null)
|
||||
const [lastTurnEndedAt, setLastTurnEndedAt] = useState<null | number>(null)
|
||||
const [goodVibesTick, setGoodVibesTick] = useState(0)
|
||||
// Bumped by the gateway `reaction` event (core-detected affection).
|
||||
const goodVibesTick = useStore($goodVibesTick)
|
||||
const [bellOnComplete, setBellOnComplete] = useState(false)
|
||||
|
||||
const ui = useStore($uiState)
|
||||
|
|
@ -445,12 +446,6 @@ export function useMainApp(gw: GatewayClient) {
|
|||
[sys]
|
||||
)
|
||||
|
||||
const maybeGoodVibes = useCallback((text: string) => {
|
||||
if (GOOD_VIBES_RE.test(text)) {
|
||||
setGoodVibesTick(v => v + 1)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const rpc: GatewayRpc = useCallback(
|
||||
async <T extends Record<string, any> = Record<string, any>>(
|
||||
method: string,
|
||||
|
|
@ -690,7 +685,6 @@ export function useMainApp(gw: GatewayClient) {
|
|||
composerRefs,
|
||||
composerState,
|
||||
gw,
|
||||
maybeGoodVibes,
|
||||
setLastUserMsg,
|
||||
slashRef,
|
||||
submitRef,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
|||
composerRefs,
|
||||
composerState,
|
||||
gw,
|
||||
maybeGoodVibes,
|
||||
setLastUserMsg,
|
||||
slashRef,
|
||||
submitRef,
|
||||
|
|
@ -87,14 +86,13 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
|||
enqueue: composerActions.enqueue,
|
||||
expand,
|
||||
gw,
|
||||
maybeGoodVibes,
|
||||
setLastUserMsg,
|
||||
sys
|
||||
},
|
||||
showUserMessage
|
||||
)
|
||||
},
|
||||
[appendMessage, composerActions, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys]
|
||||
[appendMessage, composerActions, composerState.pasteSnips, gw, setLastUserMsg, sys]
|
||||
)
|
||||
|
||||
const shellExec = useCallback(
|
||||
|
|
@ -362,7 +360,6 @@ export interface UseSubmissionOptions {
|
|||
composerRefs: ComposerRefs
|
||||
composerState: ComposerState
|
||||
gw: GatewayClient
|
||||
maybeGoodVibes: (text: string) => void
|
||||
setLastUserMsg: (value: string) => void
|
||||
slashRef: MutableRefObject<(cmd: string) => boolean>
|
||||
submitRef: MutableRefObject<(value: string) => void>
|
||||
|
|
|
|||
|
|
@ -618,6 +618,7 @@ export type GatewayEvent =
|
|||
| { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' }
|
||||
| { payload: SessionInfo; session_id?: string; type: 'session.info' }
|
||||
| { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' }
|
||||
| { payload?: { kind?: string }; session_id?: string; type: 'reaction' }
|
||||
| { payload?: undefined; session_id?: string; type: 'message.start' }
|
||||
| { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' }
|
||||
| {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue