Files

267 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Button,
host,
Popover,
PopoverContent,
PopoverTrigger,
STATUSBAR_AREAS,
Switch,
Tip
} from '@hermes/plugin-sdk'
import { useState } from 'react'
import { jsx, jsxs } from 'react/jsx-runtime'
const ID = 'completion-notification-sound'
const STORAGE_VOLUME = 'volume'
const STORAGE_ENABLED = 'enabled'
const STORAGE_DURATION = 'duration'
const DEFAULT_VOLUME = 0.35
const MIN_VOLUME = 0
const MAX_VOLUME = 1
const DEFAULT_DURATION = 0.35
const MIN_DURATION = 0.1
const MAX_DURATION = 5
const EVENT_DEDUPE_MS = 1200
let storage = null
let audioContext = null
let lastCompletionAt = 0
let lastEventKey = ''
let eventDisposer = null
function readVolume() {
try {
const value = Number(storage?.get(STORAGE_VOLUME, DEFAULT_VOLUME))
return Number.isFinite(value) ? Math.max(MIN_VOLUME, Math.min(MAX_VOLUME, value)) : DEFAULT_VOLUME
} catch {
return DEFAULT_VOLUME
}
}
function readEnabled() {
try {
return storage?.get(STORAGE_ENABLED, true) !== false
} catch {
return true
}
}
function readDuration() {
try {
const value = Number(storage?.get(STORAGE_DURATION, DEFAULT_DURATION))
return Number.isFinite(value) ? Math.max(MIN_DURATION, Math.min(MAX_DURATION, value)) : DEFAULT_DURATION
} catch {
return DEFAULT_DURATION
}
}
function getAudioContext() {
if (typeof window === 'undefined') return null
const AudioContextClass = window.AudioContext || window.webkitAudioContext
if (!AudioContextClass) return null
if (!audioContext || audioContext.state === 'closed') audioContext = new AudioContextClass()
if (audioContext.state === 'suspended') void audioContext.resume().catch(() => {})
return audioContext
}
function playTone({ preview = false } = {}) {
const ctx = getAudioContext()
if (!ctx) return false
const volume = readVolume()
if (volume <= 0) return false
const now = ctx.currentTime
const master = ctx.createGain()
master.gain.setValueAtTime(Math.min(1, volume) * 0.14, now)
master.connect(ctx.destination)
const duration = readDuration()
const melody = [
659.25, 783.99, 1046.5, 987.77,
880, 783.99, 659.25, 523.25
]
const noteCount = Math.max(2, Math.min(melody.length, Math.round(2 + (duration - MIN_DURATION) * 1.35)))
const interval = duration / noteCount
const notes = melody.slice(0, noteCount).map((frequency, index) => ({
frequency,
start: index * interval,
duration: Math.max(0.06, interval * 0.82)
}))
for (const note of notes) {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'triangle'
osc.frequency.setValueAtTime(note.frequency, now + note.start)
gain.gain.setValueAtTime(0.0001, now + note.start)
gain.gain.exponentialRampToValueAtTime(0.8, now + note.start + 0.018)
gain.gain.exponentialRampToValueAtTime(0.0001, now + note.start + note.duration)
osc.connect(gain)
gain.connect(master)
osc.start(now + note.start)
osc.stop(now + note.start + note.duration + 0.025)
}
master.gain.setTargetAtTime(0.0001, now + duration + 0.08, 0.08)
if (preview) void ctx.resume().catch(() => {})
return true
}
function SettingsPage({ onEnabledChange } = {}) {
const volume = readVolume()
const enabled = readEnabled()
const volumePct = Math.round(volume * 100)
const duration = readDuration()
const durationLabel = duration.toFixed(1) + ' сек.'
const updateVolume = event => {
const value = Number(event.currentTarget.value)
const next = Number.isFinite(value) ? Math.max(MIN_VOLUME, Math.min(MAX_VOLUME, value)) : DEFAULT_VOLUME
try { storage?.set(STORAGE_VOLUME, next) } catch { /* keep current session setting */ }
const output = event.currentTarget.parentElement?.querySelector('[data-volume-value]')
if (output) output.textContent = next + '%'
}
const updateDuration = event => {
const value = Number(event.currentTarget.value)
const next = Number.isFinite(value) ? Math.max(MIN_DURATION, Math.min(MAX_DURATION, value)) : DEFAULT_DURATION
try { storage?.set(STORAGE_DURATION, next) } catch { /* keep current session setting */ }
const output = event.currentTarget.parentElement?.querySelector('[data-duration-value]')
if (output) output.textContent = next.toFixed(1) + ' сек.'
}
const updateEnabled = event => {
try { storage?.set(STORAGE_ENABLED, event.currentTarget.checked) } catch { /* keep current session setting */ }
if (onEnabledChange) onEnabledChange(event.currentTarget.checked)
}
return jsxs('div', {
className: 'flex w-80 flex-col gap-4 p-4 text-sm',
children: [
jsxs('div', { children: [
jsx('h2', { className: 'text-lg font-semibold text-(--ui-text-primary)', children: 'Звук завершения' }),
jsx('p', { className: 'mt-1 text-(--ui-text-tertiary)', children: 'Свой сигнал при завершении ответа агента. Работает только в открытом Hermes Desktop.' })
] }),
jsxs('label', { className: 'flex items-center justify-between gap-4', children: [
jsxs('span', { children: [
jsx('span', { className: 'block font-medium text-(--ui-text-primary)', children: 'Воспроизводить звук' }),
jsx('span', { className: 'text-(--ui-text-tertiary)', children: 'Включи, если отключил штатный Completion Sound.' })
] }),
jsx(Switch, {
checked: enabled,
onCheckedChange: checked => updateEnabled({ currentTarget: { checked } }),
'aria-label': 'Воспроизводить звук завершения'
})
] }),
jsxs('label', { className: 'flex flex-col gap-2', children: [
jsxs('span', { className: 'flex items-center justify-between', children: [
jsx('span', { className: 'font-medium text-(--ui-text-primary)', children: 'Громкость' }),
jsx('span', { className: 'tabular-nums text-(--ui-text-secondary)', 'data-volume-value': true, children: volumePct + '%' })
] }),
jsx('input', { type: 'range', min: MIN_VOLUME, max: MAX_VOLUME, step: 0.01, defaultValue: volume, onInput: updateVolume, 'aria-label': 'Громкость звука завершения' }),
jsx('span', { className: 'text-xs text-(--ui-text-quaternary)', children: '0–100%' })
] }),
jsxs('label', { className: 'flex flex-col gap-2', children: [
jsxs('span', { className: 'flex items-center justify-between', children: [
jsx('span', { className: 'font-medium text-(--ui-text-primary)', children: 'Длительность' }),
jsx('span', { className: 'tabular-nums text-(--ui-text-secondary)', 'data-duration-value': true, children: durationLabel })
] }),
jsx('input', { type: 'range', min: MIN_DURATION, max: MAX_DURATION, step: 0.1, defaultValue: duration, onInput: updateDuration, 'aria-label': 'Длительность звука завершения' }),
jsx('span', { className: 'text-xs text-(--ui-text-quaternary)', children: '0,1–5,0 сек.' })
] }),
jsx(Button, {
type: 'button',
size: 'sm',
variant: 'outline',
className: 'self-start',
onClick: () => {
if (!playTone({ preview: true })) host.notify({ kind: 'warning', message: 'Web Audio недоступен или громкость установлена на 0.' })
},
children: 'Прослушать'
})
]
})
}
function SoundStatusbar() {
const [enabled, setEnabled] = useState(readEnabled())
const icon = enabled
? '♫'
: jsx('span', {
className: 'relative inline-flex h-4 w-4 items-center justify-center',
children: [
jsx('span', { children: '♫' }),
jsx('span', {
className: 'pointer-events-none absolute -left-0.5 -top-0.5 text-[1.25em] leading-none',
style: { transform: 'rotate(-22deg)' },
children: '╱'
})
]
})
const label = enabled ? 'Кастомный звук включён' : 'Кастомный звук выключен'
return jsx(Popover, {
children: [
jsx(Tip, {
label: label,
children: jsx(PopoverTrigger, {
asChild: true,
children: jsx('button', {
type: 'button',
'aria-label': label,
className: 'inline-flex h-full items-center px-1.5 text-sm text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
onClick: () => {},
children: icon
})
})
}),
jsx(PopoverContent, {
align: 'end',
side: 'top',
className: 'w-auto p-0',
children: jsx(SettingsPage, { onEnabledChange: setEnabled })
})
]
})
}
export default {
id: ID,
name: 'Completion Sound Volume',
defaultEnabled: true,
register(ctx) {
storage = ctx.storage
try {
const savedVolume = Number(storage.get(STORAGE_VOLUME, DEFAULT_VOLUME))
if (!Number.isFinite(savedVolume) || savedVolume < MIN_VOLUME || savedVolume > MAX_VOLUME) storage.set(STORAGE_VOLUME, DEFAULT_VOLUME)
const savedDuration = Number(storage.get(STORAGE_DURATION, DEFAULT_DURATION))
if (!Number.isFinite(savedDuration) || savedDuration < MIN_DURATION || savedDuration > MAX_DURATION) storage.set(STORAGE_DURATION, DEFAULT_DURATION)
} catch { /* storage is optional; defaults remain usable */ }
eventDisposer = host.onEvent('message.complete', event => {
if (!readEnabled()) return
const payload = event?.payload && typeof event.payload === 'object' ? event.payload : {}
const key = String(event?.session_id || payload.session_id || '') + ':' + String(payload.message_id || payload.id || '')
const now = Date.now()
if ((key !== ':' && key === lastEventKey) || now - lastCompletionAt < EVENT_DEDUPE_MS) return
lastEventKey = key
lastCompletionAt = now
playTone()
})
ctx.register({
id: 'statusbar-settings',
area: STATUSBAR_AREAS.right,
order: 125,
render: () => jsx(SoundStatusbar, {})
})
ctx.onDispose(() => {
if (eventDisposer) eventDisposer()
eventDisposer = null
storage = null
if (audioContext && audioContext.state !== 'closed') void audioContext.close().catch(() => {})
audioContext = null
lastEventKey = ''
lastCompletionAt = 0
})
}
}