@xmov/avatar 2.3.2-beta.1 → 2.4.0-alpha.2

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.
@@ -99,6 +99,8 @@ export default class ResourceManager {
99
99
  options: TOptions;
100
100
  sdk: XmovAvatar;
101
101
  session_id?: string;
102
+ /** 单次 init 生命周期内的稳定 request_id,由 generateRequestId 生成;session_id 未返回时也能用于 stopSession 兜底销毁 */
103
+ session_request_id?: string;
102
104
  mouthShapeLib: any;
103
105
  offlineIdle: IOfflineIdle[];
104
106
  offlineCache: Map<string, {
@@ -0,0 +1,58 @@
1
+ import type { SDKError } from 'types/error';
2
+ export interface SentryReporterOptions {
3
+ /** Traces 采样率,默认 1.0(100% 全量) */
4
+ tracesSampleRate?: number;
5
+ }
6
+ /** span 附加属性(兼容 Sentry SpanAttributeValue 的标量子集) */
7
+ type SpanData = Record<string, string | number | boolean | undefined>;
8
+ /**
9
+ * Sentry 错误上报器(隔离实例)
10
+ *
11
+ * 使用独立的 BrowserClient + Scope,不污染宿主应用的全局 Sentry 状态。
12
+ * 此文件仅在公有云构建(ENABLE_SENTRY=true)时被打包,
13
+ * 私有化构建时通过编译时常量 + 死代码消除完全移除。
14
+ */
15
+ export declare class SentryReporter {
16
+ private client;
17
+ private scope;
18
+ private tracesSampleRate;
19
+ constructor(dsn: string, options?: SentryReporterOptions);
20
+ /**
21
+ * 上报 SDK 错误
22
+ */
23
+ captureError(error: SDKError): void;
24
+ /**
25
+ * 在隔离 scope 上启动一个 span 并执行回调,回调返回时自动结束。
26
+ * 异常时 span 自动标记 error(由 startSpan 内部处理),span 数据发往本实例的 DSN。
27
+ */
28
+ startSpan<T>(name: string, ctx: {
29
+ op?: string;
30
+ data?: SpanData;
31
+ }, fn: () => T): T;
32
+ /**
33
+ * 启动一个手动管理生命周期的 span(跨回调/异步时用),
34
+ * 返回的句柄在操作完成时调用 end() 结束 span。
35
+ */
36
+ startInactiveSpan(name: string, ctx: {
37
+ op?: string;
38
+ data?: SpanData;
39
+ }): {
40
+ end(): void;
41
+ };
42
+ /**
43
+ * 设置全局标签
44
+ */
45
+ setTag(key: string, value: string): void;
46
+ /**
47
+ * 设置用户信息
48
+ */
49
+ setUser(user: {
50
+ id?: string;
51
+ ip_address?: string;
52
+ }): void;
53
+ /**
54
+ * 销毁 Sentry 客户端,flush 未发送的事件
55
+ */
56
+ destroy(): Promise<void>;
57
+ }
58
+ export {};
@@ -96,9 +96,18 @@ export interface IAvatarOptions {
96
96
  */
97
97
  onAudioPlaybackData?(data: IAudioPlaybackData): void;
98
98
  /**
99
- * 每帧数据回调
100
- * 返回当前帧的 face + body 原始数据(帧号、body_id 等)
99
+ * Sentry 错误监控配置
100
+ * 仅在公有云构建(ENABLE_SENTRY=true)时生效,私有化构建会忽略此配置
101
+ * 设为 { enabled: false } 可禁用 Sentry 上报
101
102
  */
103
+ sentry?: {
104
+ enabled?: boolean;
105
+ /**
106
+ * 性能 Traces 采样率(0-1),默认 1.0(100% 全量)
107
+ * 容量受限时可下调,如 0.1(10%)
108
+ */
109
+ tracesSampleRate?: number;
110
+ };
102
111
  onFrameDataUpdate?(data: IFrameDataUpdate): void;
103
112
  }
104
113
  /**
@@ -1,3 +1,10 @@
1
+ /**
2
+ * 生成 request_id:用于 startSession / stopSession 的幂等与兜底销毁。
3
+ * 组成:md5(机器指纹 + 毫秒时间戳 + appId)
4
+ * 同一次 init → destroy 生命周期内始终不变,即使 session_id 尚未返回
5
+ * 也能用 request_id 让服务端销毁已占用的房间资源。
6
+ */
7
+ export declare function generateRequestId(appId: string): string;
1
8
  export interface Headers {
2
9
  [key: string]: string;
3
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmov/avatar",
3
- "version": "2.3.2-beta.1",
3
+ "version": "2.4.0-alpha.2",
4
4
  "description": "魔珐数字人 JS-SDK",
5
5
  "type": "module",
6
6
  "source": "src/index.ts",
@@ -43,13 +43,14 @@
43
43
  "!dist/agent/microphone.d.ts"
44
44
  ],
45
45
  "scripts": {
46
- "build": "pnpm run build:core && pnpm run build:agent && pnpm run build:e2e:cdn && pnpm run test:e2e:cdn && node upload-sdk.js build",
47
- "build:core": "microbundle --workers --define ENV=production,VERSION=$npm_package_version --external none --tsconfig tsconfig.json",
48
- "build:agent": "microbundle --entry src/agent/index.ts --output dist/agent --format modern,esm,cjs,umd --external @xmov/avatar --alias ../index=@xmov/avatar --globals @xmov/avatar=XmovAvatar --name XingyunAvatarAgentModule --tsconfig tsconfig.agent.json --no-sourcemap && node scripts/build-agent-entry.mjs",
49
- "build:e2e:cdn": "microbundle --entry src/agent/cdn-entry.ts --output cdn-dist/xmovAvatar_e2e.js --format iife --external none --workers --define ENV=production,VERSION=$npm_package_version --tsconfig tsconfig.agent.json --no-pkg-main --no-sourcemap",
46
+ "build": "pnpm run build:core && pnpm run build:agent && pnpm run build:e2e:cdn && pnpm run test:e2e:cdn && node scripts/upload-sourcemaps.mjs && node upload-sdk.js build",
47
+ "build:core": "microbundle --workers --define ENV=production,VERSION=$npm_package_version,ENABLE_SENTRY=true,SENTRY_DSN='https://aabed165326fc7a2a276b49aa4043be3@xmovsentry.xmov.ai/32' --external none --tsconfig tsconfig.json",
48
+ "build:agent": "microbundle --entry src/agent/index.ts --output dist/agent --format modern,esm,cjs,umd --external @xmov/avatar --alias ../index=@xmov/avatar --globals @xmov/avatar=XmovAvatar --name XingyunAvatarAgentModule --tsconfig tsconfig.agent.json --no-sourcemap --define ENV=production,VERSION=$npm_package_version,ENABLE_SENTRY=true,SENTRY_DSN='https://aabed165326fc7a2a276b49aa4043be3@xmovsentry.xmov.ai/32' && node scripts/build-agent-entry.mjs",
49
+ "build:private": "microbundle --workers --define ENV=production,VERSION=$npm_package_version,ENABLE_SENTRY=false,SENTRY_DSN='' --external @sentry/browser --tsconfig tsconfig.json && node scripts/strip-sentry-imports.mjs && pnpm run build:agent",
50
+ "build:e2e:cdn": "microbundle --entry src/agent/cdn-entry.ts --output cdn-dist/xmovAvatar_e2e.js --format iife --external none --workers --define ENV=production,VERSION=$npm_package_version,ENABLE_SENTRY=true,SENTRY_DSN='https://aabed165326fc7a2a276b49aa4043be3@xmovsentry.xmov.ai/32' --tsconfig tsconfig.agent.json --no-pkg-main --no-sourcemap",
50
51
  "sdk": "pnpm --filter @xmov/avatar run --parallel --no-bail \"/^sdk:.+$/\"",
51
- "sdk:core": "microbundle watch --workers --define ENV=development,VERSION=$npm_package_version --external none --tsconfig tsconfig.json",
52
- "sdk:agent": "node scripts/build-agent-entry.mjs && microbundle watch --entry src/agent/index.ts --output dist/agent --format modern,esm,cjs,umd --external @xmov/avatar --alias ../index=@xmov/avatar --globals @xmov/avatar=XmovAvatar --name XingyunAvatarAgentModule --tsconfig tsconfig.agent.json",
52
+ "sdk:core": "microbundle watch --workers --define ENV=development,VERSION=$npm_package_version,ENABLE_SENTRY=true,SENTRY_DSN='https://aabed165326fc7a2a276b49aa4043be3@xmovsentry.xmov.ai/32' --external none --tsconfig tsconfig.json",
53
+ "sdk:agent": "node scripts/build-agent-entry.mjs && microbundle watch --entry src/agent/index.ts --output dist/agent --format modern,esm,cjs,umd --external @xmov/avatar --alias ../index=@xmov/avatar --globals @xmov/avatar=XmovAvatar --name XingyunAvatarAgentModule --tsconfig tsconfig.agent.json --define ENV=development,VERSION=$npm_package_version,ENABLE_SENTRY=true,SENTRY_DSN='https://aabed165326fc7a2a276b49aa4043be3@xmovsentry.xmov.ai/32'",
53
54
  "app": "cd app/ && npm run dev",
54
55
  "app:lan": "pnpm --dir app dev:lan",
55
56
  "test:agent": "node scripts/run-agent-tests.mjs",
@@ -95,6 +96,7 @@
95
96
  "ws": "^8.21.1"
96
97
  },
97
98
  "dependencies": {
99
+ "@sentry/browser": "^10.69.0",
98
100
  "blueimp-md5": "^2.19.0",
99
101
  "protobufjs": "^7.5.4"
100
102
  },
@@ -24,6 +24,7 @@ import '../proto/protobuf.min';
24
24
  import '../proto/face_data_pb';
25
25
 
26
26
  const proxyProtobuf = protobuf.roots.default;
27
+ const CLIENT_QUIT_ACK_TIMEOUT_MS = 3000;
27
28
 
28
29
  const pointNameObj = {
29
30
  'connect_sdk': '连接SDK',
@@ -63,6 +64,9 @@ interface ITtsaOptions {
63
64
  export default class Ttsa {
64
65
  private TAG = "[TTSA]";
65
66
  private ws: Socket;
67
+ private connectedOnce = false;
68
+ private quitRequested = false;
69
+ private quitPromise: Promise<void> | null = null;
66
70
  private room: string;
67
71
  private session_id: string;
68
72
  private token: string;
@@ -182,6 +186,11 @@ export default class Ttsa {
182
186
  });
183
187
 
184
188
  ws.on("connect", () => {
189
+ if (this.quitRequested) {
190
+ ws.disconnect();
191
+ return;
192
+ }
193
+ this.connectedOnce = true;
185
194
  // 连接成功时清理重连定时器
186
195
  this.clearReconnectTimer();
187
196
  this.initWsTimeoutTimer();
@@ -204,6 +213,9 @@ export default class Ttsa {
204
213
  }
205
214
  });
206
215
  ws.on("first_start_timestamp", (e: any) => {
216
+ if (this.quitRequested) {
217
+ return;
218
+ }
207
219
  (window as any).avatarSDKLogger.log(this.TAG, "first_start_timestamp", e);
208
220
  // 前端在发送first_start_timestamp后运行帧索引
209
221
  this.firstStartTimestamp(e.server_time);
@@ -342,7 +354,7 @@ export default class Ttsa {
342
354
  this.clearOldWsTimeoutTimer();
343
355
  // 停止客户端心跳
344
356
  this.stopHeartbeat();
345
- if(e === 'io client disconnect') {
357
+ if (this.quitRequested || e === 'io client disconnect') {
346
358
  return;
347
359
  }
348
360
  // 增加状态检查,避免重复处理断开事件
@@ -393,6 +405,10 @@ export default class Ttsa {
393
405
  }) as any;
394
406
  ws.on("client_quit", (e) => {
395
407
  (window as any).avatarSDKLogger.warn(this.TAG, "client_quit", "断开原因", e);
408
+ // A quit broadcast may precede its ACK. Let the local quit flow own teardown.
409
+ if (this.quitRequested) {
410
+ return;
411
+ }
396
412
  ws.disconnect();
397
413
  this.session_speak_req_id = 0;
398
414
  if (Object.keys(e).length) {
@@ -442,6 +458,9 @@ export default class Ttsa {
442
458
  };
443
459
  initWsTimeoutTimer = () => {
444
460
  this.clearOldWsTimeoutTimer();
461
+ if (this.quitRequested) {
462
+ return;
463
+ }
445
464
 
446
465
  this.wsConnectTimer = window.setTimeout(async () => {
447
466
 
@@ -658,6 +677,10 @@ export default class Ttsa {
658
677
  return this.ws.connected;
659
678
  }
660
679
 
680
+ get hasConnected(): boolean {
681
+ return this.connectedOnce;
682
+ }
683
+
661
684
  _setResumeInfoCallback(lastSessionId: string, _getResumeInfo: () => IResumeParams) {
662
685
  this._isResume = true
663
686
  this._lastSessionId = lastSessionId
@@ -682,6 +705,26 @@ export default class Ttsa {
682
705
  return `${this._uniqueSpeakId}-${this.session_id}`
683
706
  }
684
707
 
708
+ quit(stopReason: string, timeoutMs = CLIENT_QUIT_ACK_TIMEOUT_MS): Promise<void> {
709
+ if (this.quitPromise) {
710
+ return this.quitPromise;
711
+ }
712
+ this.quitRequested = true;
713
+ this.clearReconnectTimer();
714
+ this.clearOldWsTimeoutTimer();
715
+ this.stopHeartbeat();
716
+ this.quitPromise = this.sendClientQuit(stopReason, timeoutMs);
717
+ return this.quitPromise;
718
+ }
719
+
720
+ private async sendClientQuit(stopReason: string, timeoutMs: number): Promise<void> {
721
+ if (!this.ws.connected) {
722
+ return;
723
+ }
724
+ // ACK confirms receipt; no application-level ACK payload is assumed here.
725
+ await this.ws.timeout(timeoutMs).emitWithAck("client_quit", { stop_reason: stopReason });
726
+ }
727
+
685
728
  close() {
686
729
  // 使用 disconnect() 而不是 close(),disconnect() 会停止所有重连尝试
687
730
  if (this.ws) {
@@ -690,10 +733,7 @@ export default class Ttsa {
690
733
  this.clearOldWsTimeoutTimer();
691
734
  this.stopHeartbeat();
692
735
  this.session_speak_req_id = 0;
693
- if (this.reconnectTimer !== -1) {
694
- clearTimeout(this.reconnectTimer);
695
- this.reconnectTimer = -1;
696
- }
736
+ this.clearReconnectTimer();
697
737
  if (this.syncTimestampInterval) {
698
738
  clearInterval(this.syncTimestampInterval);
699
739
  this.syncTimestampInterval = 0; // 重置为初始值 0,保持状态统一
package/src/global.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  // src/global.d.ts
2
+
3
+ // 编译时常量声明(通过 microbundle --define 注入)
4
+ declare const VERSION: string;
5
+ declare const ENV: string;
6
+ declare const ENABLE_SENTRY: boolean;
7
+ declare const SENTRY_DSN: string;
8
+
2
9
  // 1. 声明全局 protobuf 对象(适配 protobufjs UMD 格式)
3
10
  declare namespace globalThis {
4
11
  const protobuf: {
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import Ttsa from "./control/ttsa";
8
8
  import { DebugOverlay } from "./view/DebugOverlay";
9
9
  import NetworkMonitor from './modules/network';
10
10
  import Errors from './modules/error-handle';
11
+ import { SentryReporter } from './modules/sentry-reporter';
11
12
 
12
13
  import { performanceConstant } from "./utils/perfermance";
13
14
  import './utils/logger.js';
@@ -47,6 +48,9 @@ export default class XmovAvatar {
47
48
  private ttsa: Ttsa | null = null;
48
49
 
49
50
  private debugOverlay: DebugOverlay | null = null;
51
+ private sentryReporter: SentryReporter | null = null;
52
+ /** 首帧 span 句柄(init 时启动,首次 BODY 帧到达时结束) */
53
+ private firstFrameSpan: { end(): void } | null = null;
50
54
 
51
55
  private _offlineTimer = -1
52
56
  private readonly _offlineInterval = 300
@@ -570,6 +574,25 @@ export default class XmovAvatar {
570
574
  return;
571
575
  }
572
576
 
577
+ // 初始化 Sentry 错误监控(编译时常量 + 运行时配置双重控制)
578
+ // 编译时 ENABLE_SENTRY 决定代码是否打包,运行时 sentry.enabled 决定是否启用
579
+ // 提前到资源加载之前创建,使 init / resource_load span 可覆盖整个初始化过程
580
+ if (ENABLE_SENTRY && this.options.sentry?.enabled) {
581
+ try {
582
+ this.sentryReporter = new SentryReporter(SENTRY_DSN, {
583
+ tracesSampleRate: this.options.sentry?.tracesSampleRate,
584
+ });
585
+ this.sentryReporter.setTag('app_id', this.options.appId || '');
586
+ this.sentryReporter.setTag('env', this._env);
587
+ } catch (e) {
588
+ (window as any).avatarSDKLogger.warn(this.TAG, 'Sentry 初始化失败', e);
589
+ }
590
+ }
591
+ // sdk.init:初始化总耗时(含资源加载、连接就绪)
592
+ const initSpan = this.sentryReporter?.startInactiveSpan('sdk.init', { op: 'init' });
593
+ // sdk.first_frame:从初始化到首个 body 帧到达(handleMessage 首次 BODY 帧时结束)
594
+ this.firstFrameSpan = this.sentryReporter?.startInactiveSpan('sdk.first_frame', { op: 'render' }) ?? null;
595
+
573
596
  const { onDownloadProgress, initModel } = params;
574
597
  this.onDownloadProgress = (progress: number) => {
575
598
  if(progress === 15) {
@@ -586,17 +609,24 @@ export default class XmovAvatar {
586
609
  this.pendingInvisibleMode = true;
587
610
  }
588
611
 
589
- // 加载资源
612
+ // 加载资源(sdk.resource_load:资源包下载/解析耗时)
590
613
  (window as any).performanceTracker.markStart(performanceConstant.load_resource);
591
614
  (window as any).performanceTracker.markStart(performanceConstant.first_avatar_render);
592
615
  (window as any).performanceTracker.markStart(performanceConstant.first_webgl_render);
593
- const sessionInfo = await this.resourceManager.load(this.onDownloadProgress.bind(this));
616
+ const downloadProgress = this.onDownloadProgress;
617
+ const sessionInfo = this.sentryReporter
618
+ ? await this.sentryReporter.startSpan('sdk.resource_load', { op: 'resource.load' }, () =>
619
+ this.resourceManager.load(downloadProgress.bind(this))
620
+ )
621
+ : await this.resourceManager.load(downloadProgress.bind(this));
594
622
  // 如果在load过程中被销毁了,直接返回,避免继续初始化
595
623
  if(this.destroyed) {
624
+ initSpan?.end();
596
625
  (window as any).avatarSDKLogger.warn(this.TAG, "sdk destroyed during load, aborting after resourceManager load");
597
626
  return
598
627
  };
599
628
  if(!sessionInfo?.socket_io_url || !sessionInfo?.token) {
629
+ initSpan?.end();
600
630
  (window as any).avatarSDKLogger.error(this.TAG, "init error, socket_io_url or token is empty reload");
601
631
  return
602
632
  };
@@ -613,6 +643,7 @@ export default class XmovAvatar {
613
643
 
614
644
  window.addEventListener('beforeunload', this.handleBeforeUnload);
615
645
  document.addEventListener('visibilitychange', this.boundVisibilityChange);
646
+ initSpan?.end();
616
647
  return sessionInfo;
617
648
  }
618
649
 
@@ -1140,6 +1171,9 @@ export default class XmovAvatar {
1140
1171
  timestamp: Date.now(),
1141
1172
  });
1142
1173
  this.isfirstReceive = false;
1174
+ // 结束 sdk.first_frame span(首帧数据到达)
1175
+ this.firstFrameSpan?.end();
1176
+ this.firstFrameSpan = null;
1143
1177
  }
1144
1178
  // 处理下行数据
1145
1179
  this.renderScheduler.handleData(type, data);
@@ -1373,6 +1407,14 @@ export default class XmovAvatar {
1373
1407
  window.removeEventListener("beforeunload", this.handleBeforeUnload);
1374
1408
  document.removeEventListener("visibilitychange", this.boundVisibilityChange);
1375
1409
 
1410
+ // 清理 Sentry 客户端
1411
+ if (ENABLE_SENTRY && this.sentryReporter) {
1412
+ try {
1413
+ this.sentryReporter.destroy();
1414
+ } catch {}
1415
+ this.sentryReporter = null;
1416
+ }
1417
+
1376
1418
  (window as any).avatarSDKLogger.log(this.TAG, "SDK 已销毁");
1377
1419
  }
1378
1420
 
@@ -1381,9 +1423,7 @@ export default class XmovAvatar {
1381
1423
 
1382
1424
  // 薄客户端模式:通知 server 端销毁,然后清理本地资源
1383
1425
  if (this._isClientMode) {
1384
- this._apiForwarder?.destroy(stop_reason);
1385
- await this.destroyClient();
1386
- this.onStatusChange(AvatarStatus.close);
1426
+ await this.disconnectClientForDestroy(stop_reason);
1387
1427
  return;
1388
1428
  }
1389
1429
 
@@ -1398,6 +1438,30 @@ export default class XmovAvatar {
1398
1438
  return res
1399
1439
  }
1400
1440
 
1441
+ protected async disconnectClientForDestroy(stopReason: string): Promise<void> {
1442
+ this.replayData = null;
1443
+ try {
1444
+ if (this._isClientMode) {
1445
+ this._apiForwarder?.destroy(stopReason);
1446
+ } else if (!this.ttsa?.hasConnected) {
1447
+ // Initialization can own a session/request before TTSA first connects.
1448
+ // Mark an in-flight connection as quitting before waiting for DELETE.
1449
+ await this.ttsa?.quit(stopReason);
1450
+ await this.resourceManager.stopSession(stopReason);
1451
+ } else {
1452
+ this.ttsa.sendSdkPoint('close_session', {
1453
+ reason: stopReason,
1454
+ });
1455
+ await this.ttsa.quit(stopReason);
1456
+ }
1457
+ } catch (error) {
1458
+ (window as any).avatarSDKLogger.warn(this.TAG, "TTSA client_quit 未确认,继续释放本地资源", error);
1459
+ } finally {
1460
+ await this.destroyClient();
1461
+ this.onStatusChange(AvatarStatus.close);
1462
+ }
1463
+ }
1464
+
1401
1465
  idle() {
1402
1466
  if (this._isClientMode) { this._apiForwarder?.idle(); return; }
1403
1467
  if(this.enableClientInterrupt) {
@@ -1431,6 +1495,16 @@ export default class XmovAvatar {
1431
1495
 
1432
1496
  speak(ssml: string, is_start: boolean = true, is_end: boolean = true, extra = {client_speak_id: '', client_frame: 0}): string | undefined{
1433
1497
  if (this._isClientMode) { this._apiForwarder?.speak(ssml, is_start, is_end, extra); return; }
1498
+ if(this.sentryReporter) {
1499
+ return this.sentryReporter.startSpan('sdk.speak', {
1500
+ op: 'speak',
1501
+ data: { client_speak_id: extra.client_speak_id },
1502
+ }, () => this.speakInternal(ssml, is_start, is_end, extra));
1503
+ }
1504
+ return this.speakInternal(ssml, is_start, is_end, extra);
1505
+ }
1506
+
1507
+ private speakInternal(ssml: string, is_start: boolean = true, is_end: boolean = true, extra = {client_speak_id: '', client_frame: 0}): string | undefined{
1434
1508
  if(this.enableClientInterrupt && is_start) {
1435
1509
  this.renderScheduler.interrupt("speak");
1436
1510
  }
@@ -1576,7 +1650,16 @@ export default class XmovAvatar {
1576
1650
  const e = Errors(params)
1577
1651
  if(params.code !== EErrorCode.RENDER_BODY_ERROR && params.code !== EErrorCode.RENDER_FACE_ERROR && params.code !== EErrorCode.BODY_DATA_EXPIRED) {
1578
1652
  this.options.onMessage(e)
1653
+ // Sentry 错误上报(仅公有云构建时生效)
1654
+ if (ENABLE_SENTRY && this.sentryReporter) {
1655
+ try {
1656
+ this.sentryReporter.captureError(e);
1657
+ } catch (err) {
1658
+ (window as any).avatarSDKLogger.warn(this.TAG, 'Sentry 上报失败', err);
1659
+ }
1660
+ }
1579
1661
  }
1662
+
1580
1663
  this.ttsa?.send("client_error", params);
1581
1664
  this.debugOverlay?.addError(e)
1582
1665
  }
@@ -1593,7 +1676,6 @@ export default class XmovAvatar {
1593
1676
  * 切换隐身模式(暂停/恢复音视频实时渲染)
1594
1677
  */
1595
1678
  public switchInvisibleMode(): void {
1596
- console.log("")
1597
1679
  if (this._isClientMode) { this._apiForwarder?.switchInvisibleMode(); return; }
1598
1680
  if (!this.renderScheduler) {
1599
1681
  (window as any).avatarSDKLogger?.warn(
@@ -1691,7 +1773,13 @@ export default class XmovAvatar {
1691
1773
  return;
1692
1774
  }
1693
1775
  try {
1694
- const result = await this._reload();
1776
+ // sdk.reconnect:单次重连耗时,带轮次/次数 tag
1777
+ const result = this.sentryReporter
1778
+ ? await this.sentryReporter.startSpan('sdk.reconnect', {
1779
+ op: 'reconnect',
1780
+ data: { round: this.retryRound, count: this.retryCount },
1781
+ }, () => this._reload())
1782
+ : await this._reload();
1695
1783
  if (!this.isRetrying || this.destroyed) {
1696
1784
  return;
1697
1785
  }
@@ -6,7 +6,7 @@ import {
6
6
  } from "../utils/DataInterface";
7
7
  import XmovAvatar from "../index";
8
8
  import { EErrorCode } from "../types/error";
9
- import { headersNeedSign } from "../utils/encodeToken";
9
+ import { headersNeedSign, generateRequestId } from "../utils/encodeToken";
10
10
  // import { isDEV } from "../utils";
11
11
  import CacheManager from './cache-manager';
12
12
 
@@ -121,6 +121,8 @@ export default class ResourceManager {
121
121
  options: TOptions;
122
122
  sdk: XmovAvatar;
123
123
  session_id?: string;
124
+ /** 单次 init 生命周期内的稳定 request_id,由 generateRequestId 生成;session_id 未返回时也能用于 stopSession 兜底销毁 */
125
+ session_request_id?: string;
124
126
  mouthShapeLib: any;
125
127
  offlineIdle: IOfflineIdle[] = []
126
128
  offlineCache = new Map<string, {
@@ -304,6 +306,7 @@ export default class ResourceManager {
304
306
  onDownloadProgress?.(this.progress);
305
307
  }, 100);
306
308
  // http 请求算 10 进度
309
+ this.session_request_id = generateRequestId(this.options.appId);
307
310
  const result = await this.startSession();
308
311
  const res = result as unknown as ISessionResponse;
309
312
  // 先记录 session_id,确保后续 stopSession 能正确释放房间
@@ -365,6 +368,7 @@ export default class ResourceManager {
365
368
 
366
369
 
367
370
  const data = {
371
+ request_id: this.session_request_id,
368
372
  tag: this.options.tag,
369
373
  gateway_type: this.options.gateway_type,
370
374
  ...(this.options.asr_id !== undefined ? { asr_id: this.options.asr_id } : {}),
@@ -404,12 +408,19 @@ export default class ResourceManager {
404
408
  async stopSession(stop_reason: string) {
405
409
  try {
406
410
  const url = `${this.options.gatewayServer}`;
407
- if (!this.session_id) {
411
+ // session_id 尚未返回时(如 init 未完成就 destroy),只要有 request_id 仍发请求,
412
+ // 让服务端用 request_id 匹配并销毁房间占用,避免 init 失败后的资源泄漏。
413
+ if (!this.session_id && !this.session_request_id) {
408
414
  return;
409
415
  }
410
- const data = {
411
- session_id: this.session_id,
416
+ const data: Record<string, any> = {
412
417
  stop_reason,
418
+ };
419
+ if (this.session_id) {
420
+ data.session_id = this.session_id;
421
+ }
422
+ if (this.session_request_id) {
423
+ data.request_id = this.session_request_id;
413
424
  }
414
425
  const { headers, data: signedData } = headersNeedSign(this.options.appId, this.options.appSecret, "DELETE", url, data);
415
426
  const response = await request(url, { method: "DELETE", data: signedData, headers: { ...headers, ...this.options.headers } });
@@ -908,5 +919,7 @@ export default class ResourceManager {
908
919
  this.bg = null;
909
920
  }
910
921
  this.progress = 0
922
+ this.session_id = undefined;
923
+ this.session_request_id = undefined;
911
924
  }
912
925
  }