hermes-agent/apps/desktop/src/store/notifications.ts
2026-06-05 10:32:26 -07:00

165 lines
4.5 KiB
TypeScript

import { atom } from 'nanostores'
import { translateNow } from '@/i18n'
export type NotificationKind = 'error' | 'warning' | 'info' | 'success'
export interface NotificationAction {
label: string
onClick: () => void
}
export interface AppNotification {
id: string
kind: NotificationKind
title?: string
message: string
detail?: string
action?: NotificationAction
onDismiss?: () => void
createdAt: number
}
interface NotificationInput {
id?: string
kind?: NotificationKind
title?: string
message: string
detail?: string
action?: NotificationAction
onDismiss?: () => void
durationMs?: number
}
let notificationCounter = 0
const timers = new Map<string, number>()
export const $notifications = atom<AppNotification[]>([])
function defaultDuration(kind: NotificationKind) {
if (kind === 'error' || kind === 'warning') {
return 0
}
return 5_000
}
function cleanErrorText(value: string) {
return value.replace(/^Error:\s*/, '').trim()
}
const ERROR_SUMMARIES: { test: (msg: string) => boolean; summarize: (msg: string) => string }[] = [
{
test: msg => /incorrect api key provided/i.test(msg) || /['"]code['"]\s*:\s*['"]invalid_api_key['"]/i.test(msg),
summarize: msg => {
const status = msg.match(/(?:error code|status(?:Code)?)[^\d]*(\d{3})/i)?.[1]
return status
? translateNow('notifications.errors.openaiRejectedApiKeyWithStatus', status)
: translateNow('notifications.errors.openaiRejectedApiKey')
}
},
{
test: msg => /neither voice_tools_openai_key nor openai_api_key is set/i.test(msg),
summarize: () => translateNow('notifications.errors.openaiTtsNeedsKey')
},
{
test: msg => /ELEVENLABS_API_KEY not set/i.test(msg) || /ElevenLabs STT API error \(HTTP 401\)/i.test(msg),
summarize: msg =>
/ELEVENLABS_API_KEY not set/i.test(msg)
? translateNow('notifications.errors.elevenLabsNeedsKey')
: translateNow('notifications.errors.elevenLabsRejectedKey')
},
{
test: msg => /method not allowed/i.test(msg),
summarize: () => translateNow('notifications.errors.methodNotAllowed')
},
{
test: msg => /microphone permission/i.test(msg),
summarize: () => translateNow('notifications.errors.microphonePermission')
}
]
function summarizeErrorMessage(message: string, fallback: string) {
const rule = ERROR_SUMMARIES.find(r => r.test(message))
if (rule) {
return rule.summarize(message)
}
return message.length > 180 ? fallback : message || fallback
}
function readableError(error: unknown, fallback: string): { message: string; detail?: string } {
const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback
const unwrapped = raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw
const cleaned = cleanErrorText(unwrapped)
const detail = cleaned.match(/"detail"\s*:\s*"([^"]+)"/)?.[1] ?? cleaned
const summary = summarizeErrorMessage(detail, fallback)
return { message: summary, detail: detail === summary ? undefined : detail }
}
export function notify(input: NotificationInput): string {
const kind = input.kind ?? 'info'
const id = input.id ?? `${Date.now()}-${notificationCounter++}`
const notification: AppNotification = {
id,
kind,
title: input.title,
message: input.message,
detail: input.detail,
action: input.action,
onDismiss: input.onDismiss,
createdAt: Date.now()
}
window.clearTimeout(timers.get(id))
timers.delete(id)
$notifications.set([notification, ...$notifications.get().filter(item => item.id !== id)].slice(0, 4))
const duration = input.durationMs ?? defaultDuration(kind)
if (duration > 0) {
timers.set(
id,
window.setTimeout(() => dismissNotification(id), duration)
)
}
return id
}
export function notifyError(error: unknown, fallback: string): string {
const readable = readableError(error, fallback)
return notify({
kind: 'error',
title: fallback,
message: readable.message,
detail: readable.detail
})
}
export function dismissNotification(id: string) {
window.clearTimeout(timers.get(id))
timers.delete(id)
const dismissed = $notifications.get().find(item => item.id === id)
$notifications.set($notifications.get().filter(item => item.id !== id))
dismissed?.onDismiss?.()
}
export function clearNotifications() {
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
const all = $notifications.get()
$notifications.set([])
for (const item of all) {
item.onDismiss?.()
}
}