- T1 usage 口径: anthropic usage 两层容错+cache 三口径归一(prompt=input+read+creation) - T2 压缩水位判据: compress_should_trigger 双模式+ratio 钳位,开关关逐字节等价 - T3 跨会话检索: history_search 工具(两跳式)+message_repo 关键词检索+prompt 指引 - T4 代语义闭环: status 终局补写+飞书停靠/换代推卡+交付引导+watcher 兜底补漏 - 段序缓存重排: build_runtime_prompt 恒静段上移/工作上下文压尾(前缀缓存失效面收窄) - 智谱缓存解析: anthropic 臂补 prompt_tokens_details.cached_tokens 嵌套回退 - 工时禁令: 提示词双语加「禁止预估工时,AI Coding 工时无意义」 - 拖拽文件落盘: 非图片附件存引用行,防整段文本灌入输入框 - 事实修正: 提示词「40+ 工具」→「80+」(基线 81);tool_failure_memory 测试串行锁除竞态 - UI 拆分: TopBar/ToolResultBody 壳层化(top-bar/tool-result-body 子件目录)
370 lines
20 KiB
TypeScript
370 lines
20 KiB
TypeScript
//! AI 事件监听与分发(分派器)— startListener(租约)/ ensureListenerStarted(幂等确保)/ handleEvent
|
|
//!
|
|
//! 拆分(批次 A-B1):14 个事件 case 按域拆到 3 个叶子 handler:
|
|
//! - useAiStreamingEvents.handleStreamingEvent(流式)
|
|
//! - useAiToolEvents.handleToolEvent(工具)
|
|
//! - useAiLifecycleEvents.handleLifecycleEvent(生命周期)
|
|
//! 跨域共享的 helper/状态(textIdle/pendingMaxRounds/pendingHelp、工具慢执行
|
|
//! 计时器、delta 去重 Map、flushCurrentText/friendlyError/notifyConversationChanged)已下沉到
|
|
//! aiShared 或 useAiPendingState(叶子)。依赖方向:分派器 → 各叶子 handler → aiShared / 叶子(无环)。
|
|
//!
|
|
//! handleEvent 保留外围逻辑(convId 同步/路由/看门狗)与 L2 状态机、跨端用户消息两个域内 handler。
|
|
//! useAiEvents() 返回 shape 保持不变(stores/ai.ts spread 形状不变 → 组件零改动)。
|
|
|
|
import { listen, emit } from '@tauri-apps/api/event'
|
|
import { t } from '@/i18n/i18n-helpers'
|
|
import { aiApi } from '@/api'
|
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
|
import { state } from '@/stores/ai'
|
|
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, addConvAgentFinished, clearConvAgentFinished, setConvRound, clearConvRound, type AgentFinishedStatus } from './aiShared'
|
|
import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
|
|
import { setStreaming } from './streamingGuard'
|
|
import { scheduleConversationsRefresh, activeConvKvKey } from './useAiConversations'
|
|
import { pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
|
import { clearConvBoundaryCache } from './useTaskBoundary'
|
|
import { handleStreamingEvent } from './useAiStreamingEvents'
|
|
import { handleToolEvent } from './useAiToolEvents'
|
|
import { handleLifecycleEvent, recordTaskParked } from './useAiLifecycleEvents'
|
|
import type { AiChatEvent, MessageId } from '@/api/types'
|
|
|
|
// 跨域共享 helper re-export(原为 useAiEvents 导出,拆分后从 aiShared/叶子透传,消费方 import 路径不变)
|
|
export { getConvState } from './aiShared'
|
|
export { flushCurrentText, friendlyError, notifyConversationChanged }
|
|
export { setContextCallbacks } from './useAiLifecycleEvents'
|
|
export type { ContextCallbacks } from './useAiLifecycleEvents'
|
|
|
|
let _unlistenAiEvent: (() => void) | null = null
|
|
let _unlistenConvChanged: (() => void) | null = null
|
|
// onStreamTimeout 广播的审批计时器清理事件 unlistener
|
|
let _unlistenApprovalClear: (() => void) | null = null
|
|
// startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener
|
|
// (两回调写同一 state.currentText → 流式文字双倍)
|
|
let _startPromise: Promise<void> | null = null
|
|
// 租约计数(所有权模型,2026-10-05 根本修):仅组件挂载级 startListener 借/还,
|
|
// send 等运行时路径走 ensureListenerStarted 不计数——计数只由对称生命周期驱动,不漂移。
|
|
let _leaseCount = 0
|
|
|
|
const appSettings = useAppSettingsStore()
|
|
|
|
// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/求助/停靠由各自 case 内 clear)。
|
|
// 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。
|
|
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiHelpRequired', 'AiTaskParked'])
|
|
|
|
// 后台会话完成 toast 的摘要截长(轻提醒只够判断「做完了/值不值得切回去看」)
|
|
const AGENT_FINISHED_TOAST_SUMMARY_MAX = 40
|
|
|
|
/** 全局审批类事件:跨会话放行,不因非当前会话被过滤。审批挂起是等用户决策的跨会话资产,
|
|
* 丢弃后用户在切回该会话前没有任何感知与处理入口;归口仍按 conversation_id 写 per-conv
|
|
* 待审批集合(aiShared.addPendingApproval),不污染当前视图。 */
|
|
function isGlobalApprovalEvent(event: AiChatEvent): boolean {
|
|
return event.type === 'AiApprovalRequired'
|
|
}
|
|
|
|
/** L2 状态机域:消费 AiConvStateChanged,写 convStates(per-conv conv_state 真相源)。
|
|
* 返回 true=已处理;无 convId 无法路由时忽略(防误写全局态)。 */
|
|
function handleConvStateEvent(event: AiChatEvent): boolean {
|
|
if (event.type !== 'AiConvStateChanged') return false
|
|
const convId = event.conversation_id || state.activeConversationId
|
|
if (!convId) return true
|
|
setConvState(convId, event.conv_state)
|
|
return true
|
|
}
|
|
|
|
/** S1 子代理完成域:消费 AiAgentFinished,写 per-conv 完成卡集合(aiShared.convAgentFinished)。
|
|
* AiChatEvent 联合类型(types.ts 归口)暂未收编该事件,经局部 shape 窄化取字段,不改 types。
|
|
* 归集挂在 isCurrent 守卫之前(同 AiConvStateChanged 范式):后台会话的子代理结束也要落卡,
|
|
* 切回仍可见;「不串当前视图」由消费侧只读当前会话数组天然保证,无需在此按活跃会话过滤。 */
|
|
function handleAgentFinishedEvent(event: AiChatEvent): boolean {
|
|
if ((event as { type?: string }).type !== 'AiAgentFinished') return false
|
|
const ev = event as unknown as {
|
|
agent_id: string
|
|
summary: string
|
|
status: AgentFinishedStatus
|
|
rounds: number
|
|
tokens: number
|
|
rejected_tools?: string[] | null
|
|
stop_kind?: string | null
|
|
}
|
|
const convId = event.conversation_id
|
|
if (!convId || !ev.agent_id) return true
|
|
addConvAgentFinished(convId, {
|
|
agentId: ev.agent_id,
|
|
summary: ev.summary,
|
|
status: ev.status,
|
|
rounds: ev.rounds,
|
|
tokens: ev.tokens,
|
|
// 后端 Option→null/缺省,卡片语义统一为缺省=零拒绝
|
|
rejected_tools: ev.rejected_tools ?? undefined,
|
|
// 结构化终止原因(可选,旧后端不发):存卡供卡面状态词优先消费,替代 summary 启发式
|
|
stop_kind: ev.stop_kind ?? undefined,
|
|
})
|
|
// P3-6:后台会话完成轻提醒(当前会话卡已在消息流可见,不弹)。
|
|
// composable 无组件上下文,复用 ai-toast 事件总线先例(ai-tool-slow-toast),AiChat listen 弹本地 toast;
|
|
// 多个子代理连发时 toast 单槽自然合并,无风暴面。
|
|
if (convId !== state.activeConversationId) {
|
|
// 未知 status 兜底按 completed 语气(防 t 回显键名);failed 用 warning 色与完成区分
|
|
const statusKey = ev.status === 'failed' ? 'failed' : ev.status === 'truncated' ? 'truncated' : 'completed'
|
|
const collapsed = ev.summary.replace(/\s+/g, ' ').trim()
|
|
const summary = collapsed.length > AGENT_FINISHED_TOAST_SUMMARY_MAX
|
|
? collapsed.slice(0, AGENT_FINISHED_TOAST_SUMMARY_MAX) + '…'
|
|
: collapsed
|
|
void emit('ai-toast', {
|
|
msg: t('ai.agentFinishedToast', { status: t(`ai.agentFinished.${statusKey}`), summary }),
|
|
type: ev.status === 'failed' ? 'warning' : 'info',
|
|
})
|
|
}
|
|
// 子代理执行是该会话的真实活跃信号(父 loop 在 spawn_agent 内等待):此前该事件穿透到
|
|
// 活跃事件分支会重置看门狗,现提前归集后显式补上,保持既有不误判停滞的语义。
|
|
resetStreamWatchdog(convId)
|
|
return true
|
|
}
|
|
|
|
/** 无人值守停靠域:消费 AiTaskParked(per-conv 落卡 + 后台会话收敛,挂起家族终局)。
|
|
* 返回 true=已处理。仅拦截后台会话:当前会话放行到 lifecycle 域统一收尾(看门狗/流式复位
|
|
* 在 cleanupTerminatedConversation,后台分支此处的收敛集合与其对齐但不重复触发两次)。
|
|
* 停靠卡必须 per-conv 落卡:多会话并发各自可停靠,后台会话的卡丢了用户切回前无从得知。 */
|
|
function handleTaskParkedEvent(event: AiChatEvent): boolean {
|
|
if (event.type !== 'AiTaskParked') return false
|
|
const convId = event.conversation_id || state.activeConversationId
|
|
if (convId && convId !== state.activeConversationId) {
|
|
recordTaskParked(convId, event.reason, event.stop_kind ?? undefined)
|
|
// 对齐 AiCompleted 家族后台分支的收敛:删状态项回不在生成、清 per-conv 流式态/去重记录/
|
|
// 挂起审批集合/自动续跑计数/进度快照,再刷新侧栏(防抖调度)
|
|
convStates.delete(convId)
|
|
clearConvStreamState(convId)
|
|
clearLastDelta(convId)
|
|
discardConvPendingApprovals(convId)
|
|
clearResumeAttempts(convId || undefined)
|
|
pendingTaskProgress.delete(convId)
|
|
scheduleConversationsRefresh()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/** 无人值守进度域:消费 AiTaskProgress(per-conv 快照,静默期「在干活」可见)。
|
|
* 返回 true=已处理。isCurrent 守卫之前归集(同停靠卡范式):后台会话的进度也要落 Map,
|
|
* 切回仍可见。进度事件即活体信号,顺带 resetStreamWatchdog(子代理长跑/重入退避期间
|
|
* 后端心跳之外的第二重存活证明,防看门狗误判停滞)。 */
|
|
function handleTaskProgressEvent(event: AiChatEvent): boolean {
|
|
if (event.type !== 'AiTaskProgress') return false
|
|
const convId = event.conversation_id || state.activeConversationId
|
|
if (!convId) return true
|
|
pendingTaskProgress.set(convId, {
|
|
round: event.round,
|
|
maxRounds: event.max_rounds,
|
|
tokensUsed: event.tokens_used,
|
|
updatedAt: Date.now(),
|
|
})
|
|
resetStreamWatchdog(convId)
|
|
return true
|
|
}
|
|
|
|
/** 跨端用户消息域:miniapp→桌面 user 气泡同步(仅处理 AiUserMessage)。
|
|
* 去重:末条已是同 content 的 user 则跳过(防本地乐观 push + 事件回灌双气泡)。 */
|
|
function handleUserMessageEvent(event: AiChatEvent): boolean {
|
|
if (event.type !== 'AiUserMessage') return false
|
|
// 多会话并行隔离:AiUserMessage 必带 conversation_id;非当前会话不污染当前视图,仅刷新列表
|
|
const uc = event.conversation_id ?? null
|
|
if (uc && state.activeConversationId && uc !== state.activeConversationId) {
|
|
// FE1:非当前会话的用户消息只刷新列表(走防抖调度,与收尾 notify 合并为一次)。
|
|
scheduleConversationsRefresh()
|
|
return true
|
|
}
|
|
const last = state.messages[state.messages.length - 1]
|
|
if (last && last.role === 'user' && last.content === event.message) {
|
|
return true
|
|
}
|
|
state.messages.push({
|
|
id: `user-${nextMsgId()}` as MessageId,
|
|
role: 'user',
|
|
content: event.message,
|
|
timestamp: Date.now(),
|
|
})
|
|
return true
|
|
}
|
|
|
|
/** 后端事件分发:按 conversation_id 路由,流式累积文本,工具状态流转,看门狗联动 */
|
|
export function handleEvent(event: AiChatEvent) {
|
|
const convId = event.conversation_id
|
|
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)并持久化
|
|
if (convId && !state.activeConversationId) {
|
|
state.activeConversationId = convId
|
|
void appSettings.set(activeConvKvKey(), convId)
|
|
}
|
|
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号,在 isCurrent 守卫之前处理
|
|
// (切走会话时也须更新 conv_state,防停止按钮/MaxRoundsCard 错态)
|
|
if (handleConvStateEvent(event)) return
|
|
// 跨端用户消息同步:在 isCurrent 守卫之前处理(补 user 气泡信号,非流式活跃事件)
|
|
if (handleUserMessageEvent(event)) return
|
|
// S1 子代理完成卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡)
|
|
if (handleAgentFinishedEvent(event)) return
|
|
// 无人值守停靠卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡)
|
|
if (handleTaskParkedEvent(event)) return
|
|
// 无人值守进度快照归集:在 isCurrent 守卫之前处理(per-conv 落 Map,后台会话不丢进度)
|
|
if (handleTaskProgressEvent(event)) return
|
|
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏;
|
|
// 全局审批类事件例外(isGlobalApprovalEvent),放行到下方按域分派
|
|
const isCurrent = !convId || convId === state.activeConversationId
|
|
if (!isCurrent && !isGlobalApprovalEvent(event)) {
|
|
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
|
|
// 非当前会话终止:收敛会话状态(删 Map 项回不在生成)
|
|
convStates.delete(convId || '')
|
|
// 同步清该会话 per-conv 流式态。残留的 streaming/currentText 是死数据:切回时会被
|
|
// switchConversation 恢复进 currentText,下轮发送的防御 flush 又把它写进上一轮气泡,
|
|
// 把已完成回复覆盖为残片;回复正文以切换时 DB 加载为准,流式态无需保留。
|
|
clearConvStreamState(convId || '')
|
|
// 对齐前台 cleanupTerminatedConversation 的 per-conv 收敛:清 delta 去重记录(残留记录
|
|
// 会在切回后把下一轮首个与上轮末个相同内容的 delta 误判重复丢弃)、待审批集合(终止即作废,
|
|
// 切回时由 pendingToolCalls 从后端审计表按需重建)、自动续跑计数(终止即清,不带入新轮)
|
|
clearLastDelta(convId || '')
|
|
discardConvPendingApprovals(convId || '')
|
|
clearResumeAttempts(convId || undefined)
|
|
// 终态即清进度快照(事件即逝态,会话已终止不留陈旧「进行中」)
|
|
pendingTaskProgress.delete(convId || '')
|
|
// #14 桌面任务卡:终态即清轮次(任务卡据 convRounds 渲染在途项,终止不留陈旧项)
|
|
clearConvRound(convId || '')
|
|
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
|
|
scheduleConversationsRefresh()
|
|
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
|
|
if (event.type === 'AiCompleted') {
|
|
emit('ai-drain-queue', { conversationId: event.conversation_id })
|
|
} else if (event.type === 'AiError') {
|
|
// 9-28#5 收口:无 convId 的 AiError 属异常防御路径(后端 emit 必带),清全部队列
|
|
// 会误伤其他会话排队消息,改保留 + console.warn 留痕
|
|
if (event.conversation_id) {
|
|
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
|
|
} else {
|
|
console.warn('[ai] AiError 缺 conversation_id,跳过清队列(防误伤其他会话排队)')
|
|
}
|
|
}
|
|
} else if (event.type === 'AiTextDelta' && convId && switchingConvs.has(convId)) {
|
|
// 切换中缓冲:目标会话正被 switchConversation 拉取,其 delta 累积到 per-conv 流式态,
|
|
// 切换完成后由渲染层续显,避免切换窗口丢可见内容
|
|
setConvStreaming(convId, true)
|
|
setConvCurrentText(convId, (getConvStreamState(convId)?.currentText ?? '') + (event.delta ?? ''))
|
|
} else if (event.type === 'AiAgentRound' && convId && event.round > 0) {
|
|
// #14 桌面任务卡:后台会话轮次追踪(isCurrent=false 原本直接丢弃本事件,
|
|
// 任务卡需要后台轮次显示「进行中 N 轮」;取 max 防事件乱序回退)
|
|
setConvRound(convId, event.round)
|
|
}
|
|
return
|
|
}
|
|
// 无目标会话(事件缺 convId 且当前无活跃会话):流式 delta 无处累积,跳过写防 null 键无意义写入
|
|
if (!convId && !state.activeConversationId && event.type === 'AiTextDelta') return
|
|
// 活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中(enum 兜底写入口;
|
|
// 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用)。
|
|
// 终态家族(AiCompleted/AiError/AiHelpRequired/AiTaskParked)除外:标记生成中会与
|
|
// 收尾的删项收敛打架(停靠后端已复位 generating,不能被兜底写回)
|
|
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired' && event.type !== 'AiTaskParked') {
|
|
setConvState(convId, 'generating')
|
|
}
|
|
// 流式看门狗:活跃事件重置;审批等待/完成/错误在 case 内 clear(per-conv,各会话独立计时)
|
|
if (!NO_RESET_WATCHDOG.has(event.type)) {
|
|
resetStreamWatchdog(convId || undefined)
|
|
}
|
|
// 按域分派(streaming → tool → lifecycle);未命中任一域 → 无操作
|
|
if (handleStreamingEvent(event)) return
|
|
if (handleToolEvent(event)) return
|
|
void handleLifecycleEvent(event)
|
|
}
|
|
|
|
/** 启动事件监听(幂等确保注册,无所有权——send 等运行时路径调用,不计入租约)。
|
|
* 所有权模型(2026-10-05 根本修):
|
|
* - ensureListenerStarted:仅保证 listener 存在,谁都可调、任意次,不持租约;
|
|
* - startListener:组件挂载用,返回 release 租约句柄,unmount 时调用归还;
|
|
* 最后一份租约归还才真拆 listener + 清全局计时器。
|
|
* 两者分离根治「send 裸调 start 无配对 stop → 裸计数只涨不跌 → 永不真拆」的结构性漂移:
|
|
* 计数只由组件挂载/卸载这一对对称生命周期驱动,不变量可静态审计。 */
|
|
export async function startListener(): Promise<() => void> {
|
|
await ensureListenerStarted()
|
|
_leaseCount++
|
|
let released = false
|
|
return () => {
|
|
if (released) {
|
|
// 双重归还是调用方 bug(组件卸载钩子重复执行等),dev 下即刻暴露防静默漂移
|
|
if (import.meta.env.DEV) console.error('[useAiEvents] listener 租约重复归还(调用方 bug)')
|
|
return
|
|
}
|
|
released = true
|
|
releaseLease()
|
|
}
|
|
}
|
|
|
|
/** 幂等确保 listener 已注册(并发去重;不持租约,不触发拆卸)。 */
|
|
export async function ensureListenerStarted(): Promise<void> {
|
|
if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) {
|
|
return
|
|
}
|
|
if (_startPromise) return _startPromise
|
|
_startPromise = (async () => {
|
|
try {
|
|
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
|
|
_unlistenConvChanged = await listen('ai-conversation-changed', (e) => {
|
|
// FE1:notify 自触发的刷新走防抖调度,同窗口内多次 notify 合并为一次列表拉取。
|
|
scheduleConversationsRefresh()
|
|
// 主窗口删除了本窗口当前展示的会话:清空视图防陈旧幽灵(分离窗口独立 JS context,经事件同步)。
|
|
const deleted = (e.payload as { deletedConvId?: string } | undefined)?.deletedConvId
|
|
if (deleted && state.activeConversationId === deleted) {
|
|
state.activeConversationId = null
|
|
state.messages = []
|
|
clearConvStreamState(deleted)
|
|
setStreaming(false, { convId: deleted, reason: 'conv-deleted' })
|
|
convStates.delete(deleted)
|
|
}
|
|
// S1:会话删除连带清其完成卡(Map 不持已删会话条目;存在即清,不限当前视图)
|
|
if (deleted) {
|
|
clearConvAgentFinished(deleted)
|
|
// 无人值守三件随会话同亡:停靠卡挂起 + 进度快照 + 边界镜像(边界生命周期=会话存续,设计 §二)
|
|
pendingTaskParked.delete(deleted)
|
|
pendingTaskProgress.delete(deleted)
|
|
clearConvBoundaryCache(deleted)
|
|
}
|
|
})
|
|
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
|
|
emit('ai-client-ready', { lastConvId: state.activeConversationId })
|
|
} catch (err) {
|
|
console.error('[useAiEvents] startListener 注册失败,事件监听未生效:', err)
|
|
throw err
|
|
}
|
|
})()
|
|
try {
|
|
await _startPromise
|
|
} finally {
|
|
_startPromise = null
|
|
}
|
|
}
|
|
|
|
/** 归还一份租约:计数归零才真拆(多实例挂载时先退出的实例不误拆他人在用的 listener)。 */
|
|
function releaseLease() {
|
|
if (_leaseCount === 0) {
|
|
if (import.meta.env.DEV) console.error('[useAiEvents] 租约下溢(归还多于借出,调用方 bug)')
|
|
return
|
|
}
|
|
_leaseCount--
|
|
if (_leaseCount > 0) return
|
|
_unlistenAiEvent?.()
|
|
_unlistenConvChanged?.()
|
|
_unlistenApprovalClear?.()
|
|
_unlistenAiEvent = null
|
|
_unlistenConvChanged = null
|
|
_unlistenApprovalClear = null
|
|
// stop 清零去重 promise:极速 mount/unmount/mount(HMR)时,首 mount 的 _startPromise 可能仍 pending,
|
|
// 再 mount 若命中并发去重分支会返回这个已代表「旧注册」的过期 promise,新 listener 实际未注册
|
|
_startPromise = null
|
|
clearAllStreamWatchdogs()
|
|
clearAllToolSlowTimers()
|
|
clearAllApprovalTimers()
|
|
}
|
|
|
|
export function useAiEvents() {
|
|
return {
|
|
startListener,
|
|
ensureListenerStarted,
|
|
handleEvent,
|
|
flushCurrentText,
|
|
friendlyError,
|
|
notifyConversationChanged,
|
|
}
|
|
}
|