@xmov/avatar 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1393 +0,0 @@
1
- import XmovAvatar from "../index";
2
- import type { ISessionResponse } from "../modules/ResourceManager";
3
- import type { IAudioPlaybackData, IAvatarOptions, IInitParams } from "../types";
4
- import { AgentE2EClient } from "./e2e-client";
5
- import { MicrophoneController } from "./microphone";
6
- import { logAudioChunkContent } from "./audio-debug";
7
- import { AgentAudioUplink } from "./audio-uplink";
8
- import type { AgentAudioBlock } from "./audio-uplink";
9
- import { DEFAULT_AGENT_AUDIO } from "./types";
10
- import type {
11
- AgentASRResult,
12
- AgentASRStartOptions,
13
- AgentASRState,
14
- AgentClientMessage,
15
- AgentConversationEvent,
16
- AgentError,
17
- AgentErrorDomain,
18
- AgentLLMResponse,
19
- AgentReconnectOptions,
20
- AgentSemanticJudgeResult,
21
- AgentServerEvent,
22
- AgentSessionInfo,
23
- AgentState,
24
- XingyunAvatarAgentCallbacks,
25
- XingyunAvatarAgentInitParams,
26
- XingyunAvatarAgentOptions,
27
- } from "./types";
28
-
29
- interface AgentRuntimeOptions {
30
- e2eServer?: string;
31
- authToken?: string;
32
- audio?: XingyunAvatarAgentOptions["audio"];
33
- requestedEnableAec?: boolean;
34
- callbacks?: XingyunAvatarAgentCallbacks;
35
- webSocketCtor?: typeof WebSocket;
36
- debugAudioChunks?: boolean;
37
- }
38
-
39
- type AgentParentOptions = IAvatarOptions & {
40
- sessionRequestData: Record<string, unknown>;
41
- };
42
-
43
- interface InitialSessionAttempt {
44
- sessionInfo: ISessionResponse;
45
- e2ePromise: Promise<void>;
46
- ttsaReadyPromise: Promise<void>;
47
- resolveTtsaReady: () => void;
48
- }
49
-
50
- function hasSerializableConfig(config: Record<string, unknown> | undefined) {
51
- if (config === undefined) {
52
- return false;
53
- }
54
- const serializedConfig = JSON.stringify(config);
55
- return serializedConfig !== undefined && serializedConfig !== "{}";
56
- }
57
-
58
- const CLOSE_CODE_MESSAGES: Record<number, string> = {
59
- 1006: "E2E WebSocket 异常断开:未收到 Close frame,请检查 E2EMPServer 日志或中间网络",
60
- 4000: "E2E WebSocket 路径未知",
61
- 4001: "E2E Token 无效",
62
- 4002: "E2E Session 不存在",
63
- 4009: "E2E 配额超限",
64
- 4010: "E2E 服务端主动断开",
65
- };
66
-
67
- const RETRYABLE_CLOSE_CODES = new Set([1001, 1006, 1011, 1012, 1013, 1014]);
68
- const DEFAULT_RECONNECT_OPTIONS: Required<AgentReconnectOptions> = {
69
- enabled: true,
70
- maxAttempts: 6,
71
- initialDelayMs: 500,
72
- maxDelayMs: 8000,
73
- };
74
-
75
- function validateAgentConfigSource(
76
- label: "ASR" | "TTS" | "Brain",
77
- id: number | undefined,
78
- config: Record<string, unknown> | undefined,
79
- ) {
80
- if (config !== undefined && (!config || Array.isArray(config) || typeof config !== "object")) {
81
- throw new Error(`Agent ${label} JSON Config 必须是对象`);
82
- }
83
- if (id !== undefined && hasSerializableConfig(config)) {
84
- throw new Error(`Agent ${label} 配置不能同时提供 ID 和 JSON Config`);
85
- }
86
- }
87
-
88
- function normalizeBrainConfig(config: XingyunAvatarAgentOptions["brain_config"]) {
89
- if (!config || !hasSerializableConfig(config)) {
90
- return undefined;
91
- }
92
- const extraBody = config.extra_body;
93
- if (extraBody !== undefined && (!extraBody || Array.isArray(extraBody) || typeof extraBody !== "object")) {
94
- throw new Error("Agent Brain extra_body 必须是 JSON 对象");
95
- }
96
- return {
97
- ...config,
98
- };
99
- }
100
-
101
- function validateAgentObjectConfig(
102
- label: "features" | "extras",
103
- config: Record<string, unknown> | undefined,
104
- ) {
105
- if (config !== undefined && (!config || Array.isArray(config) || typeof config !== "object")) {
106
- throw new Error(`Agent ${label} 必须是对象`);
107
- }
108
- }
109
-
110
- export default class XingyunAvatarAgent extends XmovAvatar {
111
- private e2eClient: AgentE2EClient | null = null;
112
- private microphone: MicrophoneController | null = null;
113
- private stopASRPromise: Promise<void> | null = null;
114
- private initializationPromise: Promise<ISessionResponse | null | undefined> | null = null;
115
- private rejectInitialization: ((error: Error) => void) | null = null;
116
- private initialSessionAttempt: InitialSessionAttempt | null = null;
117
- private initialSessionGeneration = 0;
118
- private readonly initialTtsaReadySessionIds = new Set<string>();
119
- private initialTtsaReadyBeforeSession = false;
120
- private asrStartGeneration = 0;
121
- private agentState: AgentState = "idle";
122
- private asrState: AgentASRState = "idle";
123
- private agentDestroyed = false;
124
- private agentDestroying = false;
125
- private suppressNextVoiceEnd = false;
126
- private audioInputEnabled: boolean;
127
- private farendAudioEnabled = false;
128
- private readonly audioUplink: AgentAudioUplink;
129
- private originalOnAudioPlaybackData?: IAvatarOptions["onAudioPlaybackData"];
130
- private playbackDataHandler?: IAvatarOptions["onAudioPlaybackData"];
131
- private readonly agentOptions: AgentRuntimeOptions;
132
- private readonly reconnectOptions: Required<AgentReconnectOptions>;
133
- private reconnectTimer: number | null = null;
134
- private reconnectAttempt = 0;
135
- private reconnectInFlight = false;
136
- private sessionReloadFallback = false;
137
- private waitingForTtsaReload = false;
138
- private stateBeforeReconnect: AgentState = "ready";
139
- private reportingInitializationFailure = false;
140
- private readonly handleOnlineForReconnect = () => {
141
- if (this.agentState !== "reconnecting" || this.reconnectInFlight || this.sessionReloadFallback) {
142
- return;
143
- }
144
- this.clearReconnectTimer();
145
- this.scheduleE2EReconnect(true);
146
- };
147
-
148
- constructor(options: XingyunAvatarAgentOptions) {
149
- validateAgentConfigSource("ASR", options.asr_id, options.asr_config);
150
- validateAgentConfigSource("TTS", undefined, options.tts_config);
151
- validateAgentConfigSource("Brain", options.llm_id, options.brain_config);
152
- validateAgentObjectConfig("features", options.features);
153
- validateAgentObjectConfig("extras", options.extras);
154
- const {
155
- e2eServer,
156
- authToken,
157
- audio,
158
- reconnect,
159
- agentCallbacks,
160
- webSocketCtor,
161
- asr_id: asrId,
162
- asr_config: asrConfig,
163
- tts_config: ttsConfig,
164
- features,
165
- extras,
166
- llm_id: llmId,
167
- brain_config: brainConfig,
168
- session_speak_req_id: configuredSessionSpeakReqId,
169
- ...avatarOptions
170
- } = options;
171
- const originalOnMessage = avatarOptions.onMessage;
172
- const originalOnRenderChange = avatarOptions.onRenderChange;
173
- const originalOnNetworkInfo = avatarOptions.onNetworkInfo;
174
- const originalOnSpeakStateChange = avatarOptions.onSpeakStateChange;
175
- const originalOnVoiceStateChange = avatarOptions.onVoiceStateChange;
176
- const originalOnAudioPlaybackData = avatarOptions.onAudioPlaybackData;
177
- const playbackDataHandler = (data: IAudioPlaybackData) => {
178
- instance?.forwardFarendAudio(data);
179
- originalOnAudioPlaybackData?.(data);
180
- };
181
- const sessionSpeakReqId = configuredSessionSpeakReqId ?? 1;
182
- const normalizedBrainConfig = normalizeBrainConfig(brainConfig);
183
- let instance: XingyunAvatarAgent | undefined;
184
-
185
- const parentOptions: AgentParentOptions = {
186
- ...avatarOptions,
187
- session_speak_req_id: sessionSpeakReqId,
188
- sessionRequestData: {
189
- ...(asrId !== undefined ? { asr_id: asrId } : {}),
190
- ...(hasSerializableConfig(asrConfig) ? { asr_config: asrConfig } : {}),
191
- ...(hasSerializableConfig(ttsConfig) ? { tts_config: ttsConfig } : {}),
192
- ...(hasSerializableConfig(features) ? { features } : {}),
193
- ...(hasSerializableConfig(extras) ? { extras } : {}),
194
- ...(llmId !== undefined ? { llm_id: llmId } : {}),
195
- ...(normalizedBrainConfig !== undefined
196
- ? { brain_config: normalizedBrainConfig }
197
- : {}),
198
- session_speak_req_id: sessionSpeakReqId,
199
- },
200
- enableClientInterrupt: avatarOptions.enableClientInterrupt ?? true,
201
- onMessage: (error) => {
202
- originalOnMessage(error);
203
- if (!instance?.reportingInitializationFailure) {
204
- instance?.emitError("sdk", String(error.code || "AVATAR_ERROR"), error.message || "Avatar SDK 错误", true, error);
205
- }
206
- },
207
- onRenderChange: (state) => {
208
- originalOnRenderChange?.(state);
209
- instance?.safeEmit(() => agentCallbacks?.onRenderChange?.(state));
210
- },
211
- onNetworkInfo: (info) => {
212
- originalOnNetworkInfo?.(info);
213
- instance?.safeEmit(() => agentCallbacks?.onNetworkInfo?.(info));
214
- },
215
- onSpeakStateChange: (state, clientSpeakId) => {
216
- originalOnSpeakStateChange?.(state, clientSpeakId);
217
- instance?.safeEmit(() => agentCallbacks?.onSpeakStateChange?.({ state, clientSpeakId }));
218
- if (["speak_start", "start", "started"].includes(state)) {
219
- instance?.emitConversation({ state: "speaking" });
220
- }
221
- if (["speak_end", "end", "ended", "completed", "finish", "finished"].includes(state)) {
222
- instance?.emitConversation({ state: "completed" });
223
- }
224
- },
225
- onVoiceStateChange: (state, duration, clientSpeakId) => {
226
- if (state === "end") {
227
- instance?.forwardVoiceEnd();
228
- }
229
- originalOnVoiceStateChange?.(state, duration, clientSpeakId);
230
- },
231
- ...(originalOnAudioPlaybackData
232
- ? {
233
- onAudioPlaybackData: playbackDataHandler,
234
- }
235
- : {}),
236
- };
237
-
238
- super(parentOptions);
239
- instance = this;
240
- this.agentOptions = {
241
- e2eServer,
242
- authToken,
243
- audio,
244
- requestedEnableAec: typeof features?.speech_frontend?.enable_aec === "boolean"
245
- ? features.speech_frontend.enable_aec
246
- : undefined,
247
- callbacks: agentCallbacks,
248
- webSocketCtor,
249
- debugAudioChunks: Boolean(avatarOptions.enableDebugger),
250
- };
251
- this.audioInputEnabled = audio?.inputEnabled ?? false;
252
- this.originalOnAudioPlaybackData = originalOnAudioPlaybackData;
253
- this.playbackDataHandler = playbackDataHandler;
254
- this.reconnectOptions = this.normalizeReconnectOptions(reconnect);
255
- this.audioUplink = new AgentAudioUplink({
256
- sendBinary: (frame) => this.e2eClient?.sendBinary(frame),
257
- onError: (error) => this.handleAudioUplinkError(error),
258
- });
259
- }
260
-
261
- getAgentState() {
262
- return this.agentState;
263
- }
264
-
265
- // Kept for callers that adopted the first Agent preview.
266
- getState() {
267
- return this.getAgentState();
268
- }
269
-
270
- getASRState() {
271
- return this.asrState;
272
- }
273
-
274
- override init(params: XingyunAvatarAgentInitParams = {}): Promise<ISessionResponse | null | undefined> {
275
- if (this.initializationPromise) {
276
- return this.initializationPromise;
277
- }
278
- const initializationPromise = this.initialize(params).finally(() => {
279
- if (this.initializationPromise === initializationPromise) {
280
- this.initializationPromise = null;
281
- }
282
- });
283
- this.initializationPromise = initializationPromise;
284
- return initializationPromise;
285
- }
286
-
287
- private async initialize(params: XingyunAvatarAgentInitParams): Promise<ISessionResponse | null | undefined> {
288
- this.assertNotDestroyed();
289
- this.setAgentState("initializing");
290
- const initializationAbortPromise = new Promise<never>((_, reject) => {
291
- this.rejectInitialization = reject;
292
- });
293
- void initializationAbortPromise.catch(() => {});
294
-
295
- try {
296
- const initParams: IInitParams = {
297
- ...params,
298
- onDownloadProgress: params.onDownloadProgress || (() => {}),
299
- };
300
- const sessionInfo = await super.init(initParams);
301
- this.assertNotDestroyed();
302
- if (!sessionInfo) {
303
- throw new Error("XmovAvatar 初始化未返回 sessionInfo");
304
- }
305
- this.applySessionFeatures(sessionInfo);
306
-
307
- this.assertNotDestroyed();
308
- this.beginInitialSession(sessionInfo);
309
- while (true) {
310
- const attempt = this.initialSessionAttempt;
311
- if (!attempt) {
312
- throw new Error("Agent 初始化缺少会话连接任务");
313
- }
314
- await Promise.race([
315
- Promise.all([attempt.e2ePromise, attempt.ttsaReadyPromise]),
316
- initializationAbortPromise,
317
- ]);
318
- if (attempt === this.initialSessionAttempt) {
319
- break;
320
- }
321
- }
322
- this.assertNotDestroyed();
323
- this.assertE2EOpen();
324
- const initializedSessionInfo = this.initialSessionAttempt?.sessionInfo || sessionInfo;
325
- this.setAgentState("ready");
326
- this.start();
327
- return initializedSessionInfo;
328
- } catch (error) {
329
- if (!this.agentDestroyed && !this.agentDestroying) {
330
- this.e2eClient?.disconnect(1000, "agent_init_failed");
331
- this.e2eClient = null;
332
- await this.cleanupBeforeInitComplete("agent_init_failed");
333
- try {
334
- this.setAgentState("failed");
335
- } catch {
336
- // Initialization failure must preserve the internal terminal state.
337
- }
338
- this.reportingInitializationFailure = true;
339
- try {
340
- this.reportInitializationFailure("Agent 初始化失败", error);
341
- } catch {
342
- // A legacy callback must not replace the original initialization error.
343
- } finally {
344
- this.reportingInitializationFailure = false;
345
- }
346
- try {
347
- this.emitError("sdk", "AGENT_INIT_FAILED", "Agent 初始化失败", true, error);
348
- } catch {
349
- // A business callback must not interrupt initialization cleanup.
350
- }
351
- this.agentDestroyed = true;
352
- }
353
- throw error;
354
- } finally {
355
- this.rejectInitialization = null;
356
- this.initialSessionGeneration += 1;
357
- this.initialSessionAttempt?.resolveTtsaReady();
358
- this.initialSessionAttempt = null;
359
- this.initialTtsaReadySessionIds.clear();
360
- this.initialTtsaReadyBeforeSession = false;
361
- }
362
- }
363
-
364
- private beginInitialSession(sessionInfo: ISessionResponse) {
365
- const previousAttempt = this.initialSessionAttempt;
366
- const generation = ++this.initialSessionGeneration;
367
- previousAttempt?.resolveTtsaReady();
368
-
369
- let resolveTtsaReady = () => {};
370
- const ttsaReadyPromise = new Promise<void>((resolve) => {
371
- resolveTtsaReady = resolve;
372
- });
373
- const e2ePromise = this.replaceE2EConnection(sessionInfo.e2e_resp).catch((error) => {
374
- if (generation !== this.initialSessionGeneration) {
375
- return;
376
- }
377
- throw error;
378
- });
379
- const attempt: InitialSessionAttempt = {
380
- sessionInfo,
381
- e2ePromise,
382
- ttsaReadyPromise,
383
- resolveTtsaReady,
384
- };
385
- this.initialSessionAttempt = attempt;
386
-
387
- if (this.initialTtsaReadySessionIds.delete(sessionInfo.session_id)) {
388
- attempt.resolveTtsaReady();
389
- } else if (!previousAttempt && this.initialTtsaReadyBeforeSession) {
390
- this.initialTtsaReadyBeforeSession = false;
391
- attempt.resolveTtsaReady();
392
- }
393
-
394
- void e2ePromise.catch(() => {});
395
- return e2ePromise;
396
- }
397
-
398
- override start() {
399
- if (this.agentState === "initializing") {
400
- return;
401
- }
402
- if (this.agentState === "running") {
403
- return;
404
- }
405
- if (this.agentState === "reconnecting") {
406
- if (
407
- this.waitingForTtsaReload
408
- && this.e2eClient?.isOpen
409
- ) {
410
- if (this.stateBeforeReconnect === "running") {
411
- super.start();
412
- }
413
- this.finishReconnect();
414
- }
415
- return;
416
- }
417
- this.assertE2EOpen();
418
- super.start();
419
- this.setAgentState("running");
420
- }
421
-
422
- protected override onTtsaReady(sessionInfo?: ISessionResponse) {
423
- if (this.rejectInitialization) {
424
- const attempt = this.initialSessionAttempt;
425
- const sessionId = sessionInfo?.session_id;
426
- if (attempt && (!sessionId || attempt.sessionInfo.session_id === sessionId)) {
427
- attempt.resolveTtsaReady();
428
- } else if (sessionId) {
429
- this.initialTtsaReadySessionIds.add(sessionId);
430
- } else {
431
- this.initialTtsaReadyBeforeSession = true;
432
- }
433
- return;
434
- }
435
- if (this.agentState === "reconnecting") {
436
- this.start();
437
- return;
438
- }
439
- if (this.agentState === "running") {
440
- super.start();
441
- }
442
- }
443
-
444
- private abortInitialization(error: Error) {
445
- this.rejectInitialization?.(error);
446
- }
447
-
448
- override async stop() {
449
- if (this.agentDestroyed) {
450
- return;
451
- }
452
- const wasReconnecting = this.agentState === "reconnecting";
453
- if (wasReconnecting) {
454
- this.stateBeforeReconnect = "stopped";
455
- }
456
- await this.stopASR();
457
- await super.stop();
458
- if (!wasReconnecting) {
459
- this.setAgentState("stopped");
460
- }
461
- }
462
-
463
- override speak(...args: Parameters<XmovAvatar["speak"]>) {
464
- if (args[2] ?? true) {
465
- this.suppressNextVoiceEnd = true;
466
- }
467
- return super.speak(...args);
468
- }
469
-
470
- override async destroy(reason = "user") {
471
- if (this.agentDestroyed) {
472
- if (this.agentState !== "destroyed") {
473
- this.setAgentState("destroyed");
474
- }
475
- return;
476
- }
477
- if (this.agentDestroying) {
478
- return;
479
- }
480
- this.agentDestroying = true;
481
- this.abortInitialization(new Error("Agent 初始化已取消"));
482
- this.cancelReconnect();
483
- this.cancelSessionRestart();
484
- try {
485
- await this.stopASR();
486
- } catch (error) {
487
- this.emitError("sdk", "AGENT_ASR_STOP_FAILED", "Agent 销毁时停止 ASR 失败", false, error);
488
- }
489
-
490
- this.e2eClient?.disconnect(1000, reason);
491
- this.e2eClient = null;
492
- this.audioUplink.reset();
493
-
494
- try {
495
- await super.destroy(reason);
496
- } finally {
497
- this.agentDestroyed = true;
498
- this.agentDestroying = false;
499
- this.setAgentState("destroyed");
500
- }
501
- }
502
-
503
- async ask(text: string): Promise<void> {
504
- this.assertRunning();
505
- this.suppressNextVoiceEnd = false;
506
- this.emitConversation({ state: "asking", text });
507
- this.sendControl({
508
- type: "ask",
509
- message: { text },
510
- });
511
- }
512
-
513
- async speakByE2E(text: string): Promise<void> {
514
- this.assertRunning();
515
- this.suppressNextVoiceEnd = false;
516
- this.emitConversation({ state: "speaking-directly", text });
517
- this.sendControl({
518
- type: "speak",
519
- message: {
520
- text,
521
- is_start: true,
522
- is_end: true,
523
- },
524
- });
525
- }
526
-
527
- async startASR(options: AgentASRStartOptions = {}): Promise<void> {
528
- this.assertRunning();
529
- this.suppressNextVoiceEnd = false;
530
- if (this.stopASRPromise) {
531
- await this.stopASRPromise;
532
- this.assertRunning();
533
- }
534
- if (this.microphone?.isRecording) {
535
- return;
536
- }
537
- this.assertAudioChunkDuration();
538
- const inputStream = options.inputStream
539
- ? this.cloneASRInputStream(options.inputStream)
540
- : undefined;
541
-
542
- const asrStartGeneration = ++this.asrStartGeneration;
543
- const restoreDisabledStateOnFailure = !this.audioInputEnabled;
544
- const microphone = new MicrophoneController({
545
- audio: this.agentOptions.audio,
546
- debugAudioChunks: this.agentOptions.debugAudioChunks,
547
- inputStream,
548
- usePcmWebCodecs: this.farendAudioEnabled,
549
- onFrame: (data, timestamp) => {
550
- if (this.agentState === "running" && this.e2eClient?.isOpen) {
551
- try {
552
- this.sendAudioFrame({
553
- data,
554
- timestamp: timestamp ?? Date.now() - (
555
- this.agentOptions.audio?.chunkMs || DEFAULT_AGENT_AUDIO.chunkMs
556
- ),
557
- });
558
- } catch {
559
- // A close can race the isOpen check. Do not let the recorder
560
- // callback escape, and do not send control events on a broken stream.
561
- this.stopASRAfterSocketClose();
562
- }
563
- }
564
- },
565
- onError: (error) => this.handleMicrophoneError(microphone, error),
566
- });
567
- this.microphone = microphone;
568
-
569
- try {
570
- this.audioUplink.startInput();
571
- if (restoreDisabledStateOnFailure) {
572
- this.enableAudioInput();
573
- }
574
- this.emitConversation({ state: "asking" });
575
- this.assertASRStartActive(asrStartGeneration);
576
- this.setASRState("requesting-permission");
577
- this.assertASRStartActive(asrStartGeneration);
578
- this.setASRState("starting");
579
- this.assertASRStartActive(asrStartGeneration);
580
- await microphone.start();
581
- this.assertRunning();
582
- if (this.farendAudioEnabled) {
583
- this.restartAudioPlaybackCapture();
584
- }
585
- this.setASRState("listening");
586
- } catch (error) {
587
- await microphone.stop();
588
- const isCancelled = (error as { agentCode?: string })?.agentCode === "AUDIO_FIXED_TRACK_CANCELLED";
589
- const isCurrentStart = asrStartGeneration === this.asrStartGeneration;
590
- const connectionUsable = !this.agentDestroyed
591
- && !this.agentDestroying
592
- && this.agentState === "running"
593
- && Boolean(this.e2eClient?.isOpen);
594
- if (this.microphone === microphone) {
595
- this.microphone = null;
596
- }
597
- if (isCurrentStart && !isCancelled && restoreDisabledStateOnFailure && this.e2eClient?.isOpen) {
598
- try {
599
- this.disableAudioInput();
600
- } catch {
601
- // sendControl has already reported the transport failure.
602
- }
603
- }
604
- if (isCurrentStart && !this.agentDestroyed && !this.agentDestroying) {
605
- if (isCancelled && connectionUsable) {
606
- this.setASRState("idle");
607
- } else if (!isCancelled) {
608
- this.setASRState("failed");
609
- }
610
- }
611
- if (isCancelled && !connectionUsable) {
612
- this.assertRunning();
613
- }
614
- throw error;
615
- }
616
- }
617
-
618
- private cloneASRInputStream(inputStream: MediaStream) {
619
- const track = inputStream.getAudioTracks().find((candidate) => candidate.readyState !== "ended");
620
- if (!track) {
621
- throw new Error("ASR 输入流不包含有效音频轨道");
622
- }
623
- return new MediaStream([track.clone()]);
624
- }
625
-
626
- private assertASRStartActive(asrStartGeneration: number) {
627
- if (asrStartGeneration === this.asrStartGeneration) {
628
- return;
629
- }
630
- const error = new Error("麦克风启动已取消") as Error & {
631
- agentCode?: string;
632
- retryable?: boolean;
633
- };
634
- error.name = "AbortError";
635
- error.agentCode = "AUDIO_FIXED_TRACK_CANCELLED";
636
- error.retryable = true;
637
- throw error;
638
- }
639
-
640
- stopASR(): Promise<void> {
641
- this.asrStartGeneration += 1;
642
- if (this.stopASRPromise) {
643
- return this.stopASRPromise;
644
- }
645
-
646
- const microphone = this.microphone;
647
- if (!microphone?.isRecording) {
648
- if (this.e2eClient?.isOpen) {
649
- this.disableAudioInput();
650
- }
651
- return Promise.resolve();
652
- }
653
-
654
- this.microphone = null;
655
- this.stopASRPromise = this.finishStopASR(microphone).finally(() => {
656
- this.stopASRPromise = null;
657
- });
658
- return this.stopASRPromise;
659
- }
660
-
661
- private async finishStopASR(microphone: MicrophoneController, notifyServer = true) {
662
- this.setASRState("stopping");
663
- await microphone.stop();
664
- await this.audioUplink.drain();
665
- if (notifyServer) {
666
- this.disableAudioInput();
667
- this.setASRState("idle");
668
- } else {
669
- this.setASRState("failed");
670
- }
671
- }
672
-
673
- async interruptConversation(reason = "user") {
674
- if (this.agentDestroyed) {
675
- return;
676
- }
677
-
678
- void reason;
679
-
680
- this.sendControl({
681
- type: "interrupt",
682
- });
683
- super.interrupt("speak");
684
- this.emitConversation({ state: "interrupted" });
685
- }
686
-
687
- protected override async onSessionReloaded(sessionInfo: ISessionResponse): Promise<void> {
688
- if (this.agentDestroyed || this.agentDestroying) {
689
- return;
690
- }
691
- if (this.initializationPromise && this.agentState === "initializing") {
692
- this.applySessionFeatures(sessionInfo);
693
- await this.beginInitialSession(sessionInfo);
694
- return;
695
- }
696
- if (this.agentState !== "reconnecting") {
697
- this.stateBeforeReconnect = this.readRestorableState();
698
- this.setAgentState("reconnecting");
699
- }
700
- this.clearReconnectTimer();
701
- this.sessionReloadFallback = true;
702
- this.applySessionFeatures(sessionInfo);
703
- await this.replaceE2EConnection(sessionInfo.e2e_resp);
704
- this.waitingForTtsaReload = true;
705
- this.sessionReloadFallback = false;
706
- }
707
-
708
- protected override onSessionReloadExhausted(error?: unknown): void {
709
- if (this.rejectInitialization) {
710
- this.abortInitialization(
711
- error instanceof Error ? error : new Error("Agent 初始化期间 TTSA Session 恢复失败"),
712
- );
713
- return;
714
- }
715
- if (this.agentState === "reconnecting") {
716
- this.failReconnect(error);
717
- }
718
- }
719
-
720
- override reloadSuccess() {
721
- super.reloadSuccess();
722
- }
723
-
724
- private async replaceE2EConnection(sessionInfo?: AgentSessionInfo) {
725
- const { wsUrl, token } = this.resolveE2EConnection(sessionInfo);
726
- const previousClient = this.e2eClient;
727
- this.audioUplink.reset();
728
- let client: AgentE2EClient;
729
- client = new AgentE2EClient({
730
- wsUrl,
731
- token,
732
- audioInputEnabled: this.audioInputEnabled,
733
- WebSocketCtor: this.agentOptions.webSocketCtor,
734
- onStateChange: (state) => {
735
- if (this.e2eClient === client) {
736
- if (state === "open") {
737
- this.audioUplink.reset(Date.now());
738
- }
739
- this.safeEmit(() => this.agentOptions.callbacks?.onSocketStateChange?.(state));
740
- }
741
- },
742
- onEvent: (event) => {
743
- if (this.e2eClient === client) {
744
- this.handleServerEvent(event);
745
- }
746
- },
747
- onError: (error) => {
748
- if (
749
- this.e2eClient === client
750
- && this.agentState !== "initializing"
751
- && this.agentState !== "reconnecting"
752
- ) {
753
- this.emitError("network", "E2E_SOCKET_ERROR", error.message, true, error);
754
- }
755
- },
756
- onClose: (event) => {
757
- if (this.e2eClient === client) {
758
- this.handleSocketClose(event);
759
- }
760
- },
761
- });
762
- this.e2eClient = client;
763
- previousClient?.disconnect(1000, "e2e_connection_replaced");
764
- await client.connect();
765
- }
766
-
767
- private resolveE2EConnection(sessionInfo?: AgentSessionInfo) {
768
- const hasResponseWsUrl = Boolean(sessionInfo?.ws_url);
769
- const hasResponseToken = Boolean(sessionInfo?.e2e_token);
770
- if (hasResponseWsUrl !== hasResponseToken) {
771
- throw new Error("统一 session 响应的 e2e_resp 必须同时返回 ws_url 和 e2e_token");
772
- }
773
- if (hasResponseWsUrl && hasResponseToken) {
774
- return { wsUrl: sessionInfo?.ws_url as string, token: sessionInfo?.e2e_token as string };
775
- }
776
-
777
- const hasConfiguredWsUrl = Boolean(this.agentOptions.e2eServer);
778
- const hasConfiguredToken = Boolean(this.agentOptions.authToken);
779
- if (hasConfiguredWsUrl !== hasConfiguredToken) {
780
- throw new Error("e2eServer 和 authToken 必须同时配置");
781
- }
782
- if (!hasConfiguredWsUrl) {
783
- throw new Error("统一 session 响应未返回 e2e_resp.ws_url,且未配置 e2eServer");
784
- }
785
- return {
786
- wsUrl: this.agentOptions.e2eServer as string,
787
- token: this.agentOptions.authToken as string,
788
- };
789
- }
790
-
791
- private sendControl(message: AgentClientMessage) {
792
- try {
793
- this.e2eClient?.send(message);
794
- } catch (error) {
795
- this.emitError("network", "E2E_SEND_FAILED", "E2E WebSocket 发送失败", true, error);
796
- throw error;
797
- }
798
- }
799
-
800
- private assertAudioChunkDuration() {
801
- const chunkMs = this.agentOptions.audio?.chunkMs || DEFAULT_AGENT_AUDIO.chunkMs;
802
- if (!this.farendAudioEnabled || chunkMs === DEFAULT_AGENT_AUDIO.chunkMs) {
803
- return;
804
- }
805
- const error = new Error("AU v1 音频分片固定为 100ms") as Error & {
806
- agentCode?: string;
807
- retryable?: boolean;
808
- };
809
- error.name = "NotSupportedError";
810
- error.agentCode = "AUDIO_CHUNK_DURATION_UNSUPPORTED";
811
- error.retryable = false;
812
- this.emitError("sdk", error.agentCode, error.message, error.retryable, error);
813
- throw error;
814
- }
815
-
816
- private sendAudioFrame(frame: AgentAudioBlock) {
817
- if (!this.farendAudioEnabled) {
818
- if (this.agentOptions.debugAudioChunks) {
819
- void logAudioChunkContent("[Agent][E2E] nearend raw WebM/Opus chunk", frame.data, {
820
- timestamp: frame.timestamp,
821
- });
822
- }
823
- try {
824
- this.e2eClient?.sendBinary(frame.data);
825
- } catch (error) {
826
- this.handleAudioUplinkError(
827
- error instanceof Error ? error : new Error("E2E nearend 音频帧发送失败"),
828
- );
829
- }
830
- return;
831
- }
832
-
833
- if (this.agentOptions.debugAudioChunks) {
834
- void logAudioChunkContent("[Agent][E2E] nearend AU chunk", frame.data, {
835
- timestamp: frame.timestamp,
836
- stream_id: 1,
837
- });
838
- }
839
- this.audioUplink.offerNearend(frame);
840
- }
841
-
842
- private handleAudioUplinkError(error: Error) {
843
- this.emitError(
844
- "network",
845
- "E2E_AUDIO_SEND_FAILED",
846
- this.farendAudioEnabled ? "E2E AU 音频帧发送失败" : "E2E 音频帧发送失败",
847
- true,
848
- error,
849
- );
850
- const microphone = this.microphone;
851
- if (!microphone?.isRecording || this.stopASRPromise) {
852
- return;
853
- }
854
-
855
- this.setASRState("failed");
856
- this.microphone = null;
857
- const cleanupPromise = this.finishFailedASR(microphone).finally(() => {
858
- if (this.stopASRPromise === cleanupPromise) {
859
- this.stopASRPromise = null;
860
- }
861
- });
862
- this.stopASRPromise = cleanupPromise;
863
- }
864
-
865
- private applySessionFeatures(sessionInfo: ISessionResponse) {
866
- const returnedEnableAec = sessionInfo.e2e_resp?.features?.speech_frontend?.enable_aec
867
- ?? sessionInfo.features?.speech_frontend?.enable_aec;
868
- this.farendAudioEnabled = typeof returnedEnableAec === "boolean"
869
- ? returnedEnableAec
870
- : this.agentOptions.requestedEnableAec
871
- ?? this.agentOptions.audio?.echoCancellationEnabled
872
- ?? false;
873
-
874
- if (!this.farendAudioEnabled) {
875
- this.audioUplink.clearFarend();
876
- }
877
-
878
- if (this.farendAudioEnabled || this.originalOnAudioPlaybackData) {
879
- this.setAudioPlaybackDataHandler(this.playbackDataHandler);
880
- if (this.farendAudioEnabled) {
881
- void this.enableAudioPlaybackCapture();
882
- }
883
- return;
884
- }
885
- this.setAudioPlaybackDataHandler(undefined);
886
- }
887
-
888
- private forwardFarendAudio(data: IAudioPlaybackData) {
889
- const microphoneRecording = Boolean(this.microphone?.isRecording);
890
- const e2eOpen = Boolean(this.e2eClient?.isOpen);
891
- if (!this.farendAudioEnabled || !microphoneRecording || !e2eOpen) {
892
- (globalThis as typeof globalThis & {
893
- avatarSDKLogger?: { log?: (...args: unknown[]) => void };
894
- }).avatarSDKLogger?.log?.("[Agent][AudioUplink]", "farend.drop", {
895
- reason: !this.farendAudioEnabled
896
- ? "aec_disabled"
897
- : !microphoneRecording
898
- ? "microphone_not_recording"
899
- : "e2e_not_open",
900
- timestamp: data.timestamp,
901
- isFirstChunk: data.isFirstChunk,
902
- });
903
- return;
904
- }
905
-
906
- if (this.agentOptions.debugAudioChunks) {
907
- void logAudioChunkContent("[Agent][E2E] farend AU chunk", data.data, {
908
- timestamp: data.timestamp,
909
- stream_id: 2,
910
- codec: data.codec,
911
- sampleRate: data.sampleRate,
912
- channels: 1,
913
- samples: data.samples,
914
- speech_id: data.speech_id,
915
- isFirstChunk: data.isFirstChunk,
916
- });
917
- }
918
- this.audioUplink.offerFarend({
919
- data: data.data,
920
- timestamp: data.timestamp,
921
- isFirstChunk: data.isFirstChunk,
922
- });
923
- }
924
-
925
- private handleMicrophoneError(microphone: MicrophoneController, error: Error) {
926
- const codedError = error as Error & { agentCode?: string; retryable?: boolean };
927
- const isRecorderError = error.name === "MediaRecorderError";
928
- const isEncoderError = error.name === "AudioEncoderError";
929
- const isAudioPipelineError = isRecorderError || isEncoderError;
930
- const isCapabilityError = error.name === "NotSupportedError";
931
- const isSDKAudioError = Boolean(codedError.agentCode);
932
- const wasStopping = this.asrState === "stopping";
933
- const shouldCleanup = isAudioPipelineError && this.microphone === microphone && !wasStopping;
934
-
935
- if (shouldCleanup) {
936
- this.microphone = null;
937
- const cleanupPromise = this.finishFailedASR(microphone).finally(() => {
938
- if (this.stopASRPromise === cleanupPromise) {
939
- this.stopASRPromise = null;
940
- }
941
- });
942
- this.stopASRPromise = cleanupPromise;
943
- }
944
-
945
- this.emitError(
946
- isAudioPipelineError ? "asr" : isSDKAudioError || isCapabilityError ? "sdk" : "permission",
947
- codedError.agentCode || (
948
- isRecorderError
949
- ? "MEDIA_RECORDER_ERROR"
950
- : isEncoderError
951
- ? "AUDIO_ENCODER_ERROR"
952
- : "MICROPHONE_ERROR"
953
- ),
954
- error.message,
955
- codedError.retryable ?? !isCapabilityError,
956
- error,
957
- );
958
- }
959
-
960
- private async finishFailedASR(microphone: MicrophoneController) {
961
- try {
962
- await microphone.stop();
963
- await this.audioUplink.drain();
964
- } catch (stopError) {
965
- this.emitError("sdk", "MICROPHONE_CLEANUP_FAILED", "麦克风异常后的资源清理失败", false, stopError);
966
- }
967
-
968
- try {
969
- this.disableAudioInput();
970
- } catch {
971
- // sendControl has already reported the transport failure.
972
- }
973
- }
974
-
975
- private enableAudioInput() {
976
- if (this.audioInputEnabled) {
977
- return;
978
- }
979
- this.sendControl({
980
- type: "event",
981
- message: "audio_input_on",
982
- });
983
- this.audioInputEnabled = true;
984
- this.e2eClient?.setAudioInputEnabled(true);
985
- }
986
-
987
- private disableAudioInput() {
988
- if (!this.audioInputEnabled) {
989
- return;
990
- }
991
- this.sendControl({
992
- type: "event",
993
- message: "audio_input_off",
994
- });
995
- this.audioInputEnabled = false;
996
- this.e2eClient?.setAudioInputEnabled(false);
997
- }
998
-
999
- private handleServerEvent(event: AgentServerEvent) {
1000
- if (this.agentDestroyed || this.agentDestroying) {
1001
- return;
1002
- }
1003
-
1004
- switch (event.type) {
1005
- case "pong":
1006
- return;
1007
- case "asr_result":
1008
- this.handleASRResult(event);
1009
- return;
1010
- case "llm_response":
1011
- this.handleLLMResponse(event);
1012
- return;
1013
- case "semantic_judge_round_result":
1014
- this.handleSemanticJudgeResult(event);
1015
- return;
1016
- case "error":
1017
- this.handleBackendError(event);
1018
- return;
1019
- default:
1020
- return;
1021
- }
1022
- }
1023
-
1024
- private handleASRResult(event: AgentServerEvent) {
1025
- const result: AgentASRResult = {
1026
- text: typeof event.text === "string" ? event.text : "",
1027
- isFinal: event.is_final === true,
1028
- raw: event,
1029
- };
1030
- this.safeEmit(() => this.agentOptions.callbacks?.onASRResult?.(result));
1031
- }
1032
-
1033
- private handleLLMResponse(event: AgentServerEvent) {
1034
- if (event.event !== "chunk" && event.event !== "done") {
1035
- return;
1036
- }
1037
-
1038
- const usage = event.usage;
1039
- const response: AgentLLMResponse = {
1040
- event: event.event,
1041
- ...(typeof event.text === "string" ? { text: event.text } : {}),
1042
- ...(event.is_first === true ? { isFirst: true } : {}),
1043
- ...(usage
1044
- ? {
1045
- usage: {
1046
- promptTokens: this.readFiniteNumber(usage.prompt_tokens),
1047
- completionTokens: this.readFiniteNumber(usage.completion_tokens),
1048
- totalTokens: this.readFiniteNumber(usage.total_tokens),
1049
- cachedTokens: this.readFiniteNumber(usage.cached_tokens),
1050
- },
1051
- }
1052
- : {}),
1053
- raw: event,
1054
- };
1055
- this.safeEmit(() => this.agentOptions.callbacks?.onLLMResponse?.(response));
1056
- }
1057
-
1058
- private handleSemanticJudgeResult(event: AgentServerEvent) {
1059
- if (!event.message || typeof event.message !== "object") {
1060
- return;
1061
- }
1062
-
1063
- const message = event.message as Record<string, unknown>;
1064
- const result: AgentSemanticJudgeResult = {
1065
- query: typeof message.query === "string" ? message.query : "",
1066
- meaningful: message.meaningful === true,
1067
- action: typeof message.action === "string" ? message.action : "",
1068
- raw: event,
1069
- };
1070
- this.safeEmit(() => this.agentOptions.callbacks?.onSemanticJudgeResult?.(result));
1071
- }
1072
-
1073
- private forwardVoiceEnd() {
1074
- if (this.suppressNextVoiceEnd) {
1075
- this.suppressNextVoiceEnd = false;
1076
- return;
1077
- }
1078
- if (
1079
- this.agentDestroyed ||
1080
- this.agentDestroying ||
1081
- this.agentState !== "running" ||
1082
- !this.e2eClient?.isOpen
1083
- ) {
1084
- return;
1085
- }
1086
-
1087
- try {
1088
- this.sendControl({ type: "event", message: "voice_end" });
1089
- } catch {
1090
- // sendControl has already reported the transport failure. A render
1091
- // callback must not throw back into the TTSA frame processing chain.
1092
- }
1093
- }
1094
-
1095
- private readFiniteNumber(value: unknown) {
1096
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
1097
- }
1098
-
1099
- private handleBackendError(event: AgentServerEvent) {
1100
- const supportedDomains: AgentErrorDomain[] = ["permission", "network", "asr", "brain", "ttsa", "quota", "sdk"];
1101
- const domain = supportedDomains.includes(event.domain as AgentErrorDomain)
1102
- ? event.domain as AgentErrorDomain
1103
- : "sdk";
1104
- const code = String(event.code ?? "BACKEND_ERROR");
1105
- const message = typeof event.message === "string" ? event.message : "E2E 后端错误";
1106
- if (domain === "asr") {
1107
- this.stopASRAfterSocketClose();
1108
- }
1109
- this.emitConversation({ state: "failed" });
1110
- this.emitError(domain, code, message, domain !== "quota", event);
1111
- }
1112
-
1113
- private handleSocketClose(event: CloseEvent) {
1114
- if (this.agentDestroyed || this.agentDestroying) {
1115
- return;
1116
- }
1117
- if (this.rejectInitialization) {
1118
- this.abortInitialization(
1119
- new Error(`Agent 初始化期间 E2E WebSocket 已关闭: ${event.code} ${event.reason || ""}`.trim()),
1120
- );
1121
- return;
1122
- }
1123
- this.audioUplink.reset();
1124
- this.stopASRAfterSocketClose();
1125
- if (this.shouldReconnect(event)) {
1126
- this.beginReconnect(event);
1127
- return;
1128
- }
1129
- this.setAgentState("failed");
1130
- const domain: AgentErrorDomain = event.code === 4009 ? "quota" : "network";
1131
- const message = event.reason || CLOSE_CODE_MESSAGES[event.code] || "E2E WebSocket 已关闭";
1132
- this.emitError(domain, `E2E_CLOSE_${event.code}`, message, domain !== "quota", {
1133
- code: event.code,
1134
- reason: event.reason,
1135
- wasClean: event.wasClean,
1136
- });
1137
- }
1138
-
1139
- private shouldReconnect(event: CloseEvent) {
1140
- return this.reconnectOptions.enabled
1141
- && this.agentState !== "idle"
1142
- && this.agentState !== "initializing"
1143
- && this.agentState !== "destroyed"
1144
- && RETRYABLE_CLOSE_CODES.has(event.code);
1145
- }
1146
-
1147
- private beginReconnect(event: CloseEvent) {
1148
- if (this.agentState !== "reconnecting") {
1149
- this.stateBeforeReconnect = this.readRestorableState();
1150
- this.reconnectAttempt = 0;
1151
- this.sessionReloadFallback = false;
1152
- this.setAgentState("reconnecting");
1153
- globalThis.addEventListener?.("online", this.handleOnlineForReconnect);
1154
- this.emitError("network", "E2E_RECONNECTING", "E2E WebSocket 已断开,正在重连", true, {
1155
- code: event.code,
1156
- reason: event.reason,
1157
- wasClean: event.wasClean,
1158
- });
1159
- }
1160
- this.audioInputEnabled = false;
1161
- this.e2eClient?.setAudioInputEnabled(false);
1162
- this.scheduleE2EReconnect(false);
1163
- }
1164
-
1165
- private scheduleE2EReconnect(immediate: boolean) {
1166
- if (
1167
- this.agentDestroyed
1168
- || this.agentDestroying
1169
- || this.agentState !== "reconnecting"
1170
- || this.reconnectTimer !== null
1171
- || this.reconnectInFlight
1172
- || this.sessionReloadFallback
1173
- ) {
1174
- return;
1175
- }
1176
- if (this.reconnectAttempt >= this.reconnectOptions.maxAttempts) {
1177
- void this.fallbackToSessionReload();
1178
- return;
1179
- }
1180
-
1181
- const delay = immediate ? 0 : this.getReconnectDelay(this.reconnectAttempt);
1182
- // @ts-ignore
1183
- this.reconnectTimer = globalThis.setTimeout(() => {
1184
- this.reconnectTimer = null;
1185
- void this.attemptE2EReconnect();
1186
- }, delay);
1187
- }
1188
-
1189
- private async attemptE2EReconnect() {
1190
- const client = this.e2eClient;
1191
- if (!client || this.agentDestroyed || this.agentDestroying || this.agentState !== "reconnecting") {
1192
- return;
1193
- }
1194
-
1195
- this.reconnectInFlight = true;
1196
- this.reconnectAttempt += 1;
1197
- try {
1198
- await client.connect();
1199
- if (this.e2eClient === client && !this.agentDestroyed && !this.agentDestroying) {
1200
- if (!this.waitingForTtsaReload) {
1201
- this.finishReconnect();
1202
- }
1203
- }
1204
- } catch {
1205
- if (this.e2eClient === client && !this.agentDestroyed && !this.agentDestroying) {
1206
- this.scheduleE2EReconnect(false);
1207
- }
1208
- } finally {
1209
- this.reconnectInFlight = false;
1210
- if (
1211
- this.e2eClient === client
1212
- && this.agentState === "reconnecting"
1213
- && this.reconnectTimer === null
1214
- && !this.sessionReloadFallback
1215
- ) {
1216
- this.scheduleE2EReconnect(false);
1217
- }
1218
- }
1219
- }
1220
-
1221
- private async fallbackToSessionReload() {
1222
- if (this.sessionReloadFallback || this.agentDestroyed || this.agentDestroying) {
1223
- return;
1224
- }
1225
- this.sessionReloadFallback = true;
1226
- this.clearReconnectTimer();
1227
- try {
1228
- await this.restartSessionForTransport("e2e_reconnect_exhausted");
1229
- } catch (error) {
1230
- this.failReconnect(error);
1231
- }
1232
- }
1233
-
1234
- private finishReconnect() {
1235
- const nextState = this.stateBeforeReconnect;
1236
- this.cancelReconnect();
1237
- if (this.asrState !== "idle") {
1238
- this.setASRState("idle");
1239
- }
1240
- this.setAgentState(nextState);
1241
- }
1242
-
1243
- private failReconnect(cause?: unknown) {
1244
- this.cancelReconnect();
1245
- this.setAgentState("failed");
1246
- this.emitConversation({ state: "failed" });
1247
- this.emitError(
1248
- "network",
1249
- "E2E_RECONNECT_EXHAUSTED",
1250
- "E2E WebSocket 重连失败,请重新初始化 Agent",
1251
- false,
1252
- cause,
1253
- );
1254
- }
1255
-
1256
- private cancelReconnect() {
1257
- this.clearReconnectTimer();
1258
- globalThis.removeEventListener?.("online", this.handleOnlineForReconnect);
1259
- this.reconnectAttempt = 0;
1260
- this.reconnectInFlight = false;
1261
- this.sessionReloadFallback = false;
1262
- this.waitingForTtsaReload = false;
1263
- }
1264
-
1265
- private clearReconnectTimer() {
1266
- if (this.reconnectTimer !== null) {
1267
- globalThis.clearTimeout(this.reconnectTimer);
1268
- this.reconnectTimer = null;
1269
- }
1270
- }
1271
-
1272
- private getReconnectDelay(attempt: number) {
1273
- const baseDelay = Math.min(
1274
- this.reconnectOptions.maxDelayMs,
1275
- this.reconnectOptions.initialDelayMs * (2 ** attempt),
1276
- );
1277
- if (baseDelay === 0) {
1278
- return 0;
1279
- }
1280
- return Math.round(baseDelay * (0.8 + Math.random() * 0.4));
1281
- }
1282
-
1283
- private readRestorableState(): AgentState {
1284
- if (this.agentState === "running" || this.agentState === "stopped" || this.agentState === "ready") {
1285
- return this.agentState;
1286
- }
1287
- return "ready";
1288
- }
1289
-
1290
- private normalizeReconnectOptions(options?: AgentReconnectOptions): Required<AgentReconnectOptions> {
1291
- const initialDelayMs = this.readNonNegativeNumber(
1292
- options?.initialDelayMs,
1293
- DEFAULT_RECONNECT_OPTIONS.initialDelayMs,
1294
- );
1295
- return {
1296
- enabled: options?.enabled ?? DEFAULT_RECONNECT_OPTIONS.enabled,
1297
- maxAttempts: Math.max(1, Math.floor(this.readNonNegativeNumber(
1298
- options?.maxAttempts,
1299
- DEFAULT_RECONNECT_OPTIONS.maxAttempts,
1300
- ))),
1301
- initialDelayMs,
1302
- maxDelayMs: Math.max(initialDelayMs, this.readNonNegativeNumber(
1303
- options?.maxDelayMs,
1304
- DEFAULT_RECONNECT_OPTIONS.maxDelayMs,
1305
- )),
1306
- };
1307
- }
1308
-
1309
- private readNonNegativeNumber(value: unknown, fallback: number) {
1310
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
1311
- }
1312
-
1313
- private stopASRAfterSocketClose() {
1314
- this.asrStartGeneration += 1;
1315
- if (this.stopASRPromise) {
1316
- return;
1317
- }
1318
- const microphone = this.microphone;
1319
- if (!microphone?.isRecording) {
1320
- return;
1321
- }
1322
-
1323
- this.microphone = null;
1324
- this.stopASRPromise = this.finishStopASR(microphone, false)
1325
- .catch((error) => {
1326
- this.emitError("sdk", "MICROPHONE_CLEANUP_FAILED", "WebSocket 断开后清理麦克风失败", false, error);
1327
- })
1328
- .finally(() => {
1329
- this.stopASRPromise = null;
1330
- });
1331
- }
1332
-
1333
- private setAgentState(state: AgentState) {
1334
- this.agentState = state;
1335
- if (!this.agentDestroyed || state === "destroyed") {
1336
- this.agentOptions.callbacks?.onAgentStateChange?.(state);
1337
- }
1338
- }
1339
-
1340
- private setASRState(state: AgentASRState) {
1341
- this.asrState = state;
1342
- this.safeEmit(() => this.agentOptions.callbacks?.onASRStateChange?.(state));
1343
- }
1344
-
1345
- private emitConversation(event: AgentConversationEvent) {
1346
- this.safeEmit(() => this.agentOptions.callbacks?.onConversationChange?.(event));
1347
- }
1348
-
1349
- private emitError(
1350
- domain: AgentErrorDomain,
1351
- code: string,
1352
- message: string,
1353
- retryable?: boolean,
1354
- cause?: unknown,
1355
- ) {
1356
- const error: AgentError = { domain, code, message, retryable, cause };
1357
- if (domain === "asr") {
1358
- this.setASRState("failed");
1359
- }
1360
- this.safeEmit(() => this.agentOptions.callbacks?.onError?.(error));
1361
- }
1362
-
1363
- private safeEmit(callback: () => void) {
1364
- if (!this.agentDestroyed && !this.agentDestroying) {
1365
- callback();
1366
- }
1367
- }
1368
-
1369
- private assertRunning() {
1370
- this.assertE2EOpen();
1371
- if (this.agentState !== "running") {
1372
- throw new Error("Agent 尚未启动,请先调用 start()");
1373
- }
1374
- }
1375
-
1376
- private assertE2EOpen() {
1377
- this.assertNotDestroyed();
1378
- if (!this.e2eClient?.isOpen) {
1379
- throw new Error("Agent 尚未连接 E2E WebSocket");
1380
- }
1381
- }
1382
-
1383
- private assertNotDestroyed() {
1384
- if (this.agentDestroyed || this.agentDestroying) {
1385
- throw new Error("Agent 已销毁");
1386
- }
1387
- }
1388
-
1389
- }
1390
-
1391
- export * from "./types";
1392
- export { AgentE2EClient } from "./e2e-client";
1393
- export { MicrophoneController } from "./microphone";