chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
This commit is contained in:
2026-10-16 10:34:35 +08:00
commit 1e9ea89d86
912 changed files with 287328 additions and 0 deletions
@@ -0,0 +1,16 @@
export const CHAT_INPUT_MOBILE_BREAKPOINT = 768
export const MIN_CHAT_INPUT_HEIGHT = 48
export const MAX_CHAT_INPUT_HEIGHT = 400
export function clampChatInputHeight(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) return null
return Math.min(MAX_CHAT_INPUT_HEIGHT, Math.max(MIN_CHAT_INPUT_HEIGHT, Math.round(numericValue)))
}
export function isMobileChatInputViewport(width: number): boolean {
return width <= CHAT_INPUT_MOBILE_BREAKPOINT
}
@@ -0,0 +1,27 @@
export function isSameLocalDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate()
}
export function formatChatTimestamp(
timestamp: number | string | Date,
options: { now?: Date; locale?: string | string[] } = {},
): string {
const date = timestamp instanceof Date ? timestamp : new Date(timestamp)
if (Number.isNaN(date.getTime())) return ''
const now = options.now || new Date()
const locale = options.locale
const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' }
if (isSameLocalDay(date, now)) {
return date.toLocaleTimeString(locale, timeOptions)
}
const dateOptions: Intl.DateTimeFormatOptions = date.getFullYear() === now.getFullYear()
? { month: '2-digit', day: '2-digit', ...timeOptions }
: { year: 'numeric', month: '2-digit', day: '2-digit', ...timeOptions }
return date.toLocaleString(locale, dateOptions)
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Copy text to clipboard with a fallback for non-secure contexts (HTTP, non-localhost).
* `navigator.clipboard` is only available in secure contexts; intranet HTTP deployments
* must fall back to the legacy `document.execCommand('copy')` flow via a hidden textarea.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// fall through to legacy fallback
}
}
if (typeof document === 'undefined') return false
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.top = '0'
textarea.style.left = '0'
textarea.style.width = '1px'
textarea.style.height = '1px'
textarea.style.padding = '0'
textarea.style.border = 'none'
textarea.style.outline = 'none'
textarea.style.boxShadow = 'none'
textarea.style.background = 'transparent'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
let ok = false
try {
textarea.focus()
textarea.select()
textarea.setSelectionRange(0, text.length)
ok = document.execCommand('copy')
} catch {
ok = false
} finally {
document.body.removeChild(textarea)
}
return ok
}
@@ -0,0 +1,132 @@
interface CompletionNotificationPayload {
title: string
body?: string
icon?: string
tag?: string
}
interface HermesDesktopBridge {
isDesktop?: boolean
notifyCompletion?: (payload: CompletionNotificationPayload) => Promise<boolean>
}
export interface CompletionNotificationPermissionResult {
granted: boolean
reason?: 'unsupported' | 'insecure' | 'denied'
}
type WindowWithHermesDesktop = Window & typeof globalThis & {
hermesDesktop?: HermesDesktopBridge
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, fallback: T): Promise<T> {
return new Promise<T>((resolve) => {
const timer = window.setTimeout(() => resolve(fallback), timeoutMs)
promise.then(
value => {
window.clearTimeout(timer)
resolve(value)
},
() => {
window.clearTimeout(timer)
resolve(fallback)
},
)
})
}
function desktopBridge(): HermesDesktopBridge | undefined {
if (typeof window === 'undefined') return undefined
return (window as WindowWithHermesDesktop).hermesDesktop
}
function supportsBrowserNotification(): boolean {
return typeof window !== 'undefined' && 'Notification' in window
}
function isBrowserNotificationSecureContext(): boolean {
if (typeof window === 'undefined') return false
return window.isSecureContext
}
function browserNotificationOptions(payload: CompletionNotificationPayload): NotificationOptions {
return {
body: payload.body,
icon: payload.icon ? new URL(payload.icon, window.location.origin).href : undefined,
tag: payload.tag,
}
}
async function showServiceWorkerNotification(payload: CompletionNotificationPayload): Promise<boolean> {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return false
try {
const registration = await withTimeout(
navigator.serviceWorker.register('/notification-sw.js'),
3000,
null,
)
if (!registration) return false
await withTimeout(navigator.serviceWorker.ready, 3000, registration)
await registration.showNotification(payload.title, browserNotificationOptions(payload))
return true
} catch (err) {
console.warn('Failed to show service worker notification:', err)
return false
}
}
export function isDesktopNotificationRuntime(): boolean {
return desktopBridge()?.isDesktop === true
}
export async function requestCompletionNotificationPermission(): Promise<CompletionNotificationPermissionResult> {
if (isDesktopNotificationRuntime()) return { granted: true }
if (!supportsBrowserNotification()) return { granted: false, reason: 'unsupported' }
if (!isBrowserNotificationSecureContext()) return { granted: false, reason: 'insecure' }
if (Notification.permission === 'granted') return { granted: true }
if (Notification.permission === 'denied') return { granted: false, reason: 'denied' }
try {
const permission = await withTimeout(
Notification.requestPermission(),
5000,
'default' as NotificationPermission,
)
return permission === 'granted'
? { granted: true }
: { granted: false, reason: permission === 'denied' ? 'denied' : 'unsupported' }
} catch {
return { granted: false, reason: 'unsupported' }
}
}
export async function showCompletionNotification(payload: CompletionNotificationPayload): Promise<boolean> {
const bridge = desktopBridge()
if (bridge?.isDesktop && bridge.notifyCompletion) {
try {
return await bridge.notifyCompletion(payload)
} catch (err) {
console.warn('Failed to show desktop completion notification:', err)
return false
}
}
if (!supportsBrowserNotification() || !isBrowserNotificationSecureContext() || Notification.permission !== 'granted') {
return false
}
try {
if (await showServiceWorkerNotification(payload)) return true
const notification = new Notification(payload.title, browserNotificationOptions(payload))
notification.onclick = () => {
window.focus()
notification.close()
}
return true
} catch (err) {
console.warn('Failed to show browser completion notification:', err)
return false
}
}
@@ -0,0 +1,68 @@
type AudioContextConstructor = typeof AudioContext
type WindowWithWebkitAudio = Window & typeof globalThis & {
webkitAudioContext?: AudioContextConstructor
}
let audioContext: AudioContext | null = null
function getAudioContext(): AudioContext | null {
if (typeof window === 'undefined') return null
const AudioContextCtor = window.AudioContext || (window as WindowWithWebkitAudio).webkitAudioContext
if (!AudioContextCtor) return null
if (!audioContext) {
audioContext = new AudioContextCtor()
}
return audioContext
}
export function primeCompletionSound(): void {
const ctx = getAudioContext()
if (!ctx || ctx.state !== 'suspended') return
void ctx.resume().catch(() => {
// Browser autoplay policy may still reject until a user gesture. Ignore; the
// next send action will try again.
})
}
export async function playCompletionSound(): Promise<boolean> {
const ctx = getAudioContext()
if (!ctx) return false
try {
if (ctx.state === 'suspended') {
await ctx.resume()
}
const now = ctx.currentTime
const duration = 0.16
const oscillator = ctx.createOscillator()
const gain = ctx.createGain()
oscillator.type = 'sine'
oscillator.frequency.setValueAtTime(880, now)
oscillator.frequency.exponentialRampToValueAtTime(660, now + duration)
gain.gain.setValueAtTime(0.0001, now)
gain.gain.exponentialRampToValueAtTime(0.18, now + 0.015)
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration)
oscillator.connect(gain)
gain.connect(ctx.destination)
oscillator.start(now)
oscillator.stop(now + duration)
return true
} catch (err) {
console.warn('Failed to play completion sound:', err)
return false
}
}
export function __resetCompletionSoundForTests(): void {
audioContext = null
}
@@ -0,0 +1,43 @@
export interface DesktopWindowBounds {
x: number
y: number
width: number
height: number
}
export interface DesktopPetWindowState {
bounds: DesktopWindowBounds
visible: boolean
}
export interface HermesDesktopBridge {
getToken: () => Promise<string>
ensureAuth?: () => Promise<boolean>
retryBootstrap: (source?: 'cf' | 'github') => Promise<void>
notifyCompletion: (payload: { title: string; body?: string; icon?: string; tag?: string }) => Promise<boolean>
getWindowState: () => Promise<{ isMaximized: boolean }>
windowControl: (action: 'minimize' | 'toggle-maximize' | 'close') => Promise<{ isMaximized: boolean }>
getPetWindowState?: () => Promise<DesktopPetWindowState>
setPetWindowBounds?: (bounds: DesktopWindowBounds) => Promise<DesktopPetWindowState>
setPetWindowVisible?: (visible: boolean) => Promise<DesktopPetWindowState>
onPetWindowRefresh?: (callback: () => void) => () => void
platform: string
isDesktop: boolean
windowKind?: 'main' | 'pet'
}
export type WindowWithHermesDesktop = Window & typeof globalThis & {
hermesDesktop?: HermesDesktopBridge
}
export function desktopBridge(): HermesDesktopBridge | undefined {
return (window as WindowWithHermesDesktop).hermesDesktop
}
export function isDesktopShell(): boolean {
return desktopBridge()?.isDesktop === true
}
export function isDesktopPetWindow(): boolean {
return desktopBridge()?.windowKind === 'pet'
}
+5
View File
@@ -0,0 +1,5 @@
import type { FileEntry } from '@/api/hermes/files'
export function getClipboardPathForEntry(entry: FileEntry): string {
return entry.absolutePath || entry.path
}
@@ -0,0 +1,86 @@
export type BridgeSessionCommandName =
| 'usage'
| 'status'
| 'abort'
| 'queue'
| 'skill'
| 'learn'
| 'plan'
| 'moa'
| 'goal'
| 'subgoal'
| 'clear'
| 'title'
| 'compress'
| 'fork'
| 'steer'
| 'destroy'
| 'reload-mcp'
| 'reload-skills'
export interface BridgeSessionCommandDefinition {
key: string
name: BridgeSessionCommandName
descriptionKey: string
argsKey?: string
args?: string
insertText?: string
opensSkillPicker?: boolean
}
export const BRIDGE_SESSION_COMMAND_DEFINITIONS: BridgeSessionCommandDefinition[] = [
{ key: 'command:usage', name: 'usage', args: '', descriptionKey: 'chat.slashCommands.usage' },
{ key: 'command:status', name: 'status', args: '', descriptionKey: 'chat.slashCommands.status' },
{ key: 'command:abort', name: 'abort', args: '', descriptionKey: 'chat.slashCommands.abort' },
{ key: 'command:queue', name: 'queue', argsKey: 'chat.slashCommandArgs.message', descriptionKey: 'chat.slashCommands.queue' },
{ key: 'command:skill', name: 'skill', args: '', descriptionKey: 'skills.title', opensSkillPicker: true },
{ key: 'command:learn', name: 'learn', argsKey: 'chat.slashCommandArgs.text', descriptionKey: 'chat.slashCommands.learn' },
{ key: 'command:plan', name: 'plan', argsKey: 'chat.slashCommandArgs.text', descriptionKey: 'chat.slashCommands.plan' },
{ key: 'command:moa', name: 'moa', argsKey: 'chat.slashCommandArgs.text', descriptionKey: 'chat.slashCommands.moa' },
{ key: 'command:goal', name: 'goal', argsKey: 'chat.slashCommandArgs.text', descriptionKey: 'chat.slashCommands.goal' },
{ key: 'command:goal-status', name: 'goal', args: 'status', insertText: 'goal status', descriptionKey: 'chat.slashCommands.goalStatus' },
{ key: 'command:goal-pause', name: 'goal', args: 'pause', insertText: 'goal pause', descriptionKey: 'chat.slashCommands.goalPause' },
{ key: 'command:goal-resume', name: 'goal', args: 'resume', insertText: 'goal resume', descriptionKey: 'chat.slashCommands.goalResume' },
{ key: 'command:goal-done', name: 'goal', args: 'done', insertText: 'goal done', descriptionKey: 'chat.slashCommands.goalDone' },
{ key: 'command:goal-clear', name: 'goal', args: 'clear', insertText: 'goal clear', descriptionKey: 'chat.slashCommands.goalClear' },
{ key: 'command:subgoal', name: 'subgoal', argsKey: 'chat.slashCommandArgs.text', descriptionKey: 'chat.slashCommands.subgoal' },
{ key: 'command:clear', name: 'clear', args: '', descriptionKey: 'chat.slashCommands.clear' },
{ key: 'command:clear-history', name: 'clear', args: '--history', insertText: 'clear --history', descriptionKey: 'chat.slashCommands.clearHistory' },
{ key: 'command:title', name: 'title', argsKey: 'chat.slashCommandArgs.title', descriptionKey: 'chat.slashCommands.title' },
{ key: 'command:compress', name: 'compress', args: '', descriptionKey: 'chat.slashCommands.compress' },
{ key: 'command:fork', name: 'fork', argsKey: 'chat.slashCommandArgs.title', descriptionKey: 'chat.slashCommands.fork' },
{ key: 'command:steer', name: 'steer', argsKey: 'chat.slashCommandArgs.text', descriptionKey: 'chat.slashCommands.steer' },
{ key: 'command:destroy', name: 'destroy', args: '', descriptionKey: 'chat.slashCommands.destroy' },
{ key: 'command:reload-mcp', name: 'reload-mcp', args: '', descriptionKey: 'chat.slashCommands.reloadMcp' },
{ key: 'command:reload-skills', name: 'reload-skills', args: '', descriptionKey: 'chat.slashCommands.reloadSkills' },
]
export const BRIDGE_SESSION_COMMAND_NAMES = Array.from(
new Set(BRIDGE_SESSION_COMMAND_DEFINITIONS.map(command => command.name)),
)
const BRIDGE_SESSION_COMMAND_ALIASES = new Map<string, BridgeSessionCommandName>([
['reload_skills', 'reload-skills'],
])
export function normalizeBridgeSessionCommandName(name: string): BridgeSessionCommandName | null {
const normalized = name.trim().toLowerCase()
if (!normalized) return null
const alias = BRIDGE_SESSION_COMMAND_ALIASES.get(normalized)
if (alias) return alias
return (BRIDGE_SESSION_COMMAND_NAMES as string[]).includes(normalized)
? normalized as BridgeSessionCommandName
: null
}
export function readBridgeSessionCommandName(input: string): BridgeSessionCommandName | null {
const trimmed = input.trim()
if (!trimmed.startsWith('/')) return null
const match = trimmed.match(/^\/([a-zA-Z][\w-]*)(?:\s|$)/)
if (!match) return null
return normalizeBridgeSessionCommandName(match[1])
}
export function isKnownBridgeSessionCommand(input: string): boolean {
return readBridgeSessionCommandName(input) !== null
}
@@ -0,0 +1,21 @@
export const DEFAULT_KANBAN_ASSIGNEE = 'default'
export interface KanbanAssigneeSummary {
name: string
counts?: Record<string, number> | null
}
export function assigneeTaskTotal(assignee: KanbanAssigneeSummary): number {
return Object.values(assignee.counts || {}).reduce((sum, count) => sum + count, 0)
}
export function withDefaultAssignee<T extends KanbanAssigneeSummary>(
assignees: T[],
byAssignee: Record<string, number> = {},
): KanbanAssigneeSummary[] {
const defaultCount = byAssignee[DEFAULT_KANBAN_ASSIGNEE] || 0
return assignees.map(assignee => {
if (assignee.name !== DEFAULT_KANBAN_ASSIGNEE || assignee.counts) return assignee
return { ...assignee, counts: { total: defaultCount } }
})
}
@@ -0,0 +1,21 @@
import type { SkillInfo, SkillTarget, SkillsData } from '@/api/hermes/skills'
import type { WorkflowSelectOption } from '@/components/hermes/workflow/types'
export function workflowAgentToSkillTarget(agent: string): SkillTarget {
if (agent === 'claude-code') return 'claude'
if (agent === 'codex') return 'codex'
return 'hermes'
}
export function buildWorkflowSkillOptions(data: SkillsData): WorkflowSelectOption[] {
const byName = new Map<string, SkillInfo>()
for (const category of data.categories || []) {
for (const skill of category.skills || []) {
if (skill.enabled === false) continue
if (!byName.has(skill.name)) byName.set(skill.name, skill)
}
}
return [...byName.values()]
.map(skill => ({ label: skill.name, value: skill.name }))
.sort((a, b) => a.label.localeCompare(b.label))
}
+48
View File
@@ -0,0 +1,48 @@
function errorMessageText(error: unknown): string {
if (typeof error === 'string') return error.trim()
if (error == null) return ''
if (typeof error !== 'object') return String(error).trim()
if (Array.isArray(error)) {
return error.map(errorMessageText).filter(Boolean).join('\n')
}
const record = error as Record<string, unknown>
for (const key of ['message', 'error', 'detail', 'description', 'code']) {
const text = errorMessageText(record[key])
if (text) return text
}
try {
return JSON.stringify(error)
} catch {
return String(error)
}
}
export async function responseErrorMessage(
response: Response,
fallbackPrefix = 'Request failed',
): Promise<string> {
let detail = ''
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
try {
detail = errorMessageText(await response.clone().json())
} catch {
detail = ''
}
}
if (!detail) {
try {
detail = (await response.clone().text()).trim()
} catch {
detail = ''
}
}
const base = `${fallbackPrefix}: ${response.status}`
return detail ? `${base} - ${detail}` : base
}
@@ -0,0 +1,24 @@
export type ApiKeyFunPresetProvider = 'fun-codex' | 'fun-claude'
export function isApiKeyFunBaseUrl(baseUrl: string): boolean {
const value = baseUrl.trim()
if (!value) return false
try {
const parsed = new URL(value)
const hostname = parsed.hostname.toLowerCase()
return hostname === 'apikey.fun' || hostname.endsWith('.apikey.fun')
} catch {
// Fall back to a string match so partially typed values still route on submit.
return /^(?:https?:\/\/)?(?:[a-z0-9-]+\.)*apikey\.fun(?:\/|$)/i.test(value)
}
}
export function inferApiKeyFunPresetProvider(model: string): ApiKeyFunPresetProvider | null {
const normalized = model.trim().toLowerCase()
if (!normalized) return null
if (normalized.startsWith('claude')) return 'fun-claude'
if (normalized.startsWith('gpt') || normalized.includes('codex')) return 'fun-codex'
return null
}
@@ -0,0 +1,109 @@
export interface ParsedThinking {
segments: string[]
pending: string | null
body: string
hasThinking: boolean
}
export interface ParseOptions {
streaming: boolean
}
const TAG_RE = /<(think|thinking|reasoning)>([\s\S]*?)<\/\1>/gi
const PLACEHOLDER_PREFIX = '\u0000THKCODE'
const PLACEHOLDER_SUFFIX = '\u0000'
const FENCED_RE = /(^|\n)( {0,3})(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\2\3[ \t]*(?=\n|$)/g
const INLINE_CODE_RE = /`[^`\n]*`/g
function protectCodeBlocks(input: string): { masked: string; blocks: string[] } {
const blocks: string[] = []
let masked = input.replace(FENCED_RE, (m) => {
blocks.push(m)
return `${PLACEHOLDER_PREFIX}${blocks.length - 1}${PLACEHOLDER_SUFFIX}`
})
masked = masked.replace(INLINE_CODE_RE, (m) => {
blocks.push(m)
return `${PLACEHOLDER_PREFIX}${blocks.length - 1}${PLACEHOLDER_SUFFIX}`
})
return { masked, blocks }
}
function restoreCodeBlocks(text: string, blocks: string[]): string {
if (blocks.length === 0) return text
const placeholderRe = new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g')
let restored = text
for (let i = 0; i < blocks.length; i += 1) {
const next = restored.replace(
placeholderRe,
(_, idx) => blocks[Number(idx)] ?? '',
)
if (next === restored) break
restored = next
}
return restored
}
export function parseThinking(content: string, opts: ParseOptions): ParsedThinking {
const { masked, blocks } = protectCodeBlocks(content)
const segments: string[] = []
let pending: string | null = null
let body = ''
let lastIndex = 0
TAG_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = TAG_RE.exec(masked)) !== null) {
body += masked.slice(lastIndex, m.index)
segments.push(m[2])
lastIndex = m.index + m[0].length
}
const rest = masked.slice(lastIndex)
const openRe = /<(think|thinking|reasoning)>([\s\S]*)$/i
const openMatch = rest.match(openRe)
if (openMatch) {
body += rest.slice(0, openMatch.index)
if (opts.streaming) {
pending = openMatch[2]
} else {
body += rest.slice(openMatch.index!)
}
} else {
body += rest
}
return {
segments: segments.map(s => restoreCodeBlocks(s, blocks)),
pending: pending === null ? null : restoreCodeBlocks(pending, blocks),
body: restoreCodeBlocks(body, blocks),
hasThinking: segments.length > 0 || pending !== null,
}
}
export function countThinkingChars(parsed: ParsedThinking): number {
const len = (s: string) => [...s].length
return parsed.segments.reduce((a, s) => a + len(s), 0) + len(parsed.pending || '')
}
export interface ThinkingBoundary {
startedAtBoundary: boolean
endedAtBoundary: boolean
}
const ANY_OPEN_RE = /<(think|thinking|reasoning)>/i
const ANY_CLOSE_RE = /<\/(think|thinking|reasoning)>/i
export function detectThinkingBoundary(prev: string, next: string): ThinkingBoundary {
const prevMasked = protectCodeBlocks(prev).masked
const nextMasked = protectCodeBlocks(next).masked
return {
startedAtBoundary: !ANY_OPEN_RE.test(prevMasked) && ANY_OPEN_RE.test(nextMasked),
endedAtBoundary: !ANY_CLOSE_RE.test(prevMasked) && ANY_CLOSE_RE.test(nextMasked),
}
}
+16
View File
@@ -0,0 +1,16 @@
/**
* 语速倍率 → Edge TTS rate 字符串
* 1.0 → "0%", 1.2 → "+20%", 0.5 → "-50%"
*/
export function speedToEdgeRate(speed: number): string {
const percent = Math.round((speed - 1) * 100)
return percent >= 0 ? `+${percent}%` : `${percent}%`
}
/**
* Hz 偏移值 → Edge TTS pitch 字符串
* 0 → "0Hz", 12 → "+12Hz", -8 → "-8Hz"
*/
export function hzToEdgePitch(hz: number): string {
return hz >= 0 ? `+${hz}Hz` : `${hz}Hz`
}
@@ -0,0 +1,58 @@
export type VoiceDialogueEventType =
| 'session.started'
| 'session.closed'
| 'session.error'
| 'capture.started'
| 'capture.stopped'
| 'capture.cancelled'
| 'transcript.delta'
| 'transcript.done'
| 'turn.started'
| 'turn.ended'
| 'turn.cancelled'
| 'output.audio.started'
| 'output.audio.done'
| 'latency.metrics'
export interface VoiceDialogueEvent<TPayload = unknown> {
id: string
seq: number
sessionId: string
type: VoiceDialogueEventType
captureId?: string
turnId?: string
timestamp: string
final?: boolean
payload?: TPayload
}
export interface CreateVoiceDialogueEventOptions<TPayload = unknown> {
captureId?: string
turnId?: string
timestamp?: string
final?: boolean
payload?: TPayload
}
export function createVoiceEventSequencer(sessionId: string) {
let seq = 0
return function nextEvent<TPayload = unknown>(
type: VoiceDialogueEventType,
options: CreateVoiceDialogueEventOptions<TPayload> = {},
): VoiceDialogueEvent<TPayload> {
seq += 1
return {
id: `${sessionId}:${seq}`,
seq,
sessionId,
type,
captureId: options.captureId,
turnId: options.turnId,
timestamp: options.timestamp ?? new Date().toISOString(),
final: options.final,
payload: options.payload,
}
}
}