mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-11 13:41:53 +00:00
OpenRouter/Nous image gen now runs a quality-first model chain by default: attempt the highest-fidelity OpenAI image model first, then fall back to Gemini 3 Pro Image when it's access-gated/unavailable/times out. An explicit OPENROUTER_IMAGE_MODEL / config model override pins one model with no fallback. Atlas validation rejects malformed model output instead of shipping it: adds a per-state collapse guard (a single sliver/fragment row no longer passes because other rows are healthy), on top of the existing postage-stamp + multi-pose checks. Desktop: pet-gen native notifications are now "global" (not tied to a chat session), so a background generation started from the command center fires an OS notification when the user is away even with no active session. Adds a neutral "This can take up to 5 minutes." banner on step 1, and lets the provider picker auto-size. Tests updated/added for the OpenRouter fallback chain, the collapse guard, and the global notification path.
217 lines
6.2 KiB
TypeScript
217 lines
6.2 KiB
TypeScript
import { atom } from 'nanostores'
|
|
|
|
import { persistString, storedString } from '@/lib/storage'
|
|
|
|
import { $gateway } from './gateway'
|
|
import { clearApprovalRequest } from './prompts'
|
|
import { $activeSessionId } from './session'
|
|
|
|
// Native OS notifications (Electron `Notification`), separate from the in-app
|
|
// toast feed in `notifications.ts`. Each kind toggles independently.
|
|
export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError'
|
|
|
|
export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [
|
|
'approval',
|
|
'input',
|
|
'turnDone',
|
|
'turnError',
|
|
'backgroundDone'
|
|
]
|
|
|
|
// Blocking prompts — surface even while focused if they're for another session.
|
|
const ATTENTION_KINDS = new Set<NativeNotificationKind>(['approval', 'input'])
|
|
|
|
export interface NativeNotificationPrefs {
|
|
enabled: boolean
|
|
kinds: Record<NativeNotificationKind, boolean>
|
|
}
|
|
|
|
const STORAGE_KEY = 'hermes:native-notifications'
|
|
|
|
const DEFAULT_PREFS: NativeNotificationPrefs = {
|
|
enabled: true,
|
|
kinds: { approval: true, backgroundDone: true, input: true, turnDone: true, turnError: true }
|
|
}
|
|
|
|
function readPrefs(): NativeNotificationPrefs {
|
|
const raw = storedString(STORAGE_KEY)
|
|
|
|
if (!raw) {
|
|
return DEFAULT_PREFS
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<NativeNotificationPrefs>
|
|
const kinds = { ...DEFAULT_PREFS.kinds }
|
|
|
|
for (const kind of NATIVE_NOTIFICATION_KINDS) {
|
|
const value = parsed.kinds?.[kind]
|
|
|
|
if (typeof value === 'boolean') {
|
|
kinds[kind] = value
|
|
}
|
|
}
|
|
|
|
return {
|
|
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULT_PREFS.enabled,
|
|
kinds
|
|
}
|
|
} catch {
|
|
return DEFAULT_PREFS
|
|
}
|
|
}
|
|
|
|
export const $nativeNotifyPrefs = atom<NativeNotificationPrefs>(readPrefs())
|
|
|
|
function writePrefs(next: NativeNotificationPrefs) {
|
|
$nativeNotifyPrefs.set(next)
|
|
persistString(STORAGE_KEY, JSON.stringify(next))
|
|
}
|
|
|
|
export function setNativeNotifyEnabled(enabled: boolean) {
|
|
writePrefs({ ...$nativeNotifyPrefs.get(), enabled })
|
|
}
|
|
|
|
export function setNativeNotifyKind(kind: NativeNotificationKind, on: boolean) {
|
|
const prev = $nativeNotifyPrefs.get()
|
|
writePrefs({ ...prev, kinds: { ...prev.kinds, [kind]: on } })
|
|
}
|
|
|
|
// De-dupe replayed events for the same kind+session. Self-evicting: entries
|
|
// older than the window are pruned on every dispatch, so the map can't grow.
|
|
const THROTTLE_MS = 1000
|
|
const lastFiredAt = new Map<string, number>()
|
|
|
|
function throttled(key: string, now: number): boolean {
|
|
for (const [k, at] of lastFiredAt) {
|
|
if (now - at >= THROTTLE_MS) {
|
|
lastFiredAt.delete(k)
|
|
}
|
|
}
|
|
|
|
if (lastFiredAt.has(key)) {
|
|
return true
|
|
}
|
|
|
|
lastFiredAt.set(key, now)
|
|
|
|
return false
|
|
}
|
|
|
|
// "Backgrounded" = the user isn't on Hermes. `document.hidden` only flips when
|
|
// minimized/occluded; an alt-tabbed window is visible-but-unfocused, so we also
|
|
// check `document.hasFocus()`.
|
|
function isBackgrounded(): boolean {
|
|
if (typeof document === 'undefined') {
|
|
return false
|
|
}
|
|
|
|
if (document.hidden) {
|
|
return true
|
|
}
|
|
|
|
return typeof document.hasFocus === 'function' && !document.hasFocus()
|
|
}
|
|
|
|
function shouldFire(kind: NativeNotificationKind, sessionId?: null | string, global = false): boolean {
|
|
// Global notifications aren't tied to a chat session (e.g. pet generation,
|
|
// which runs from the command center with no active conversation). They fire
|
|
// whenever the user is away, with no session-match requirement — otherwise a
|
|
// background run started without an open session would be silently dropped.
|
|
if (global) {
|
|
return isBackgrounded()
|
|
}
|
|
|
|
// Attention kinds break through for an off-screen session even while focused.
|
|
if (ATTENTION_KINDS.has(kind)) {
|
|
return isBackgrounded() || (Boolean(sessionId) && sessionId !== $activeSessionId.get())
|
|
}
|
|
|
|
// Completion kinds: only the active session, only while away — so a busy
|
|
// gateway (messaging, kanban, cron) can't spam a toast per background session.
|
|
return isBackgrounded() && Boolean(sessionId) && sessionId === $activeSessionId.get()
|
|
}
|
|
|
|
export interface NativeNotificationAction {
|
|
id: string
|
|
text: string
|
|
}
|
|
|
|
export interface NativeNotificationInput {
|
|
kind: NativeNotificationKind
|
|
title: string
|
|
body?: string
|
|
sessionId?: null | string
|
|
/**
|
|
* Not tied to a chat session (e.g. pet generation). Fires whenever the user
|
|
* is away, bypassing the session-match gate that completion kinds normally
|
|
* require.
|
|
*/
|
|
global?: boolean
|
|
silent?: boolean
|
|
actions?: NativeNotificationAction[]
|
|
}
|
|
|
|
export function dispatchNativeNotification(input: NativeNotificationInput): void {
|
|
const prefs = $nativeNotifyPrefs.get()
|
|
|
|
if (!prefs.enabled || !prefs.kinds[input.kind]) {
|
|
return
|
|
}
|
|
|
|
if (!shouldFire(input.kind, input.sessionId, input.global)) {
|
|
return
|
|
}
|
|
|
|
if (throttled(`${input.kind}:${input.sessionId ?? (input.global ? 'global' : '')}`, Date.now())) {
|
|
return
|
|
}
|
|
|
|
void window.hermesDesktop?.notify({
|
|
actions: input.actions,
|
|
body: input.body,
|
|
kind: input.kind,
|
|
sessionId: input.sessionId ?? undefined,
|
|
silent: input.silent,
|
|
title: input.title
|
|
})
|
|
}
|
|
|
|
// Resolve a pending approval from a notification button, mirroring the in-app
|
|
// Run/Reject bar. Keyed by session id — a background approval has no local guard.
|
|
export async function respondToApprovalAction(sessionId: null | string, actionId: string): Promise<void> {
|
|
const choice = actionId === 'approve' ? 'once' : actionId === 'reject' ? 'deny' : null
|
|
|
|
if (!choice) {
|
|
return
|
|
}
|
|
|
|
const gateway = $gateway.get()
|
|
|
|
if (!gateway) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await gateway.request('approval.respond', { choice, session_id: sessionId ?? undefined })
|
|
clearApprovalRequest(sessionId)
|
|
} catch {
|
|
// Leave the prompt parked so the user can still resolve it in-app.
|
|
}
|
|
}
|
|
|
|
// Settings "send test" — bypasses gating. Returns whether the OS accepted it so
|
|
// the panel can flag a silent permission failure instead of looking dead.
|
|
export async function sendTestNativeNotification(title: string, body: string): Promise<boolean> {
|
|
const bridge = window.hermesDesktop
|
|
|
|
if (!bridge?.notify) {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
return await bridge.notify({ body, kind: 'turnDone', title })
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|