@vivix-ai/ivi-frontend-sdk 0.3.6 → 0.3.7

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.
package/README.md CHANGED
@@ -380,13 +380,15 @@ function useIviSubtitles(
380
380
  ): IviSubtitleItem[];
381
381
  ```
382
382
 
383
- 基于 runtime 的 conversation / response 事件维护字幕队列,不负责渲染,也不按时间自动消失。默认从 conversation 条目聚合字幕;当 `roles` 包含 `"model"` 且开启 `useModelStreamingTranscript` 时,model 字幕改为使用 `response.output_audio_transcript.*` 事件,`response.output_audio_transcript.done` 只更新文本,只有 `response.done` 才表示这一轮 model 字幕结束。
383
+ 基于 runtime 的 conversation / response 事件维护字幕队列,不负责渲染。字幕进入完成态后默认 3000ms 自动移除,可通过 `hideCompletedAfterMs={false}` 关闭,或通过 `onSubtitleCompleted` 自定义保留/删除策略。默认从 conversation 条目聚合字幕;当 `roles` 包含 `"model"` 且开启 `useModelStreamingTranscript` 时,model 字幕改为使用 `response.output_audio_transcript.*` 事件,`response.output_audio_transcript.done` 只更新文本,只有 `response.done` 才表示这一轮 model 字幕结束。
384
384
 
385
385
  | Option | 类型 | 默认值 | 说明 |
386
386
  |--------|------|--------|------|
387
387
  | `roles` | `IviSubtitleRole \| IviSubtitleRole[]` | `"user"` | 要收集的发言人角色 |
388
388
  | `maxItems` | `number` | `2` | 最多保留的字幕条数,超过后清理最旧条目 |
389
389
  | `useModelStreamingTranscript` | `boolean` | `false` | 当 `roles` 包含 `"model"` 时,model 字幕是否改用 `response.output_audio_transcript.*` 事件 |
390
+ | `hideCompletedAfterMs` | `number \| false` | `3000` | 字幕进入完成态后多久自动移除;传 `false` 保留到被 `maxItems` 裁剪 |
391
+ | `onSubtitleCompleted` | `(item, context) => "keep" \| "remove" \| { removeAfterMs } \| void \| Promise<...>` | — | 自定义完成态清理策略,优先于 `hideCompletedAfterMs` |
390
392
 
391
393
  `IviSubtitleItem` 包含 `id`、`role`、`lifecycle`、`status`、`text`、`transcript`、`displayText`、`timestamp`、`updatedAt`、`content`、`item`、`source`、`responseId`、`itemId`。
392
394
 
@@ -395,7 +397,10 @@ function CustomSubtitles({ runtime }: { runtime: IviRuntimeCoordinator | null })
395
397
  const subtitles = useIviSubtitles(runtime, {
396
398
  roles: ["user", "model"],
397
399
  maxItems: 5,
398
- useModelStreamingTranscript: true
400
+ useModelStreamingTranscript: true,
401
+ onSubtitleCompleted: (item) => (
402
+ item.role === "model" ? { removeAfterMs: 4000 } : { removeAfterMs: 2500 }
403
+ )
399
404
  });
400
405
 
401
406
  return (
@@ -466,7 +471,7 @@ function useApplyVolumeToSlot(
466
471
  | `showVolumeControl` | `boolean` | — | 是否显示音量控制浮层 |
467
472
  | `volumeControlProps` | — | — | 音量控制自定义配置 |
468
473
  | `showSubtitle` | `boolean` | — | 是否显示字幕浮层 |
469
- | `subtitleProps` | `Omit<IVISubtitleOverlayProps, "runtime">` | — | 字幕自定义配置,可传 `roles`、`maxItems`、`useModelStreamingTranscript` 等 |
474
+ | `subtitleProps` | `Omit<IVISubtitleOverlayProps, "runtime">` | — | 字幕自定义配置,可传 `roles`、`maxItems`、`useModelStreamingTranscript`、`hideCompletedAfterMs`、`onSubtitleCompleted` 等 |
470
475
  | `trtcPlayerProps` | — | — | TRTC 播放器自定义配置 |
471
476
  | `livekitPlayerProps` | — | — | LiveKit 播放器自定义配置 |
472
477
  | `videoProps` / `imageProps` | — | — | 透传给原生 `<video>` / `<img>` |
@@ -577,6 +582,8 @@ npm install xgplayer xgplayer-flv
577
582
  | `maxItems` | `number` | `2` | 最多保留的字幕条数,超过后清理最旧条目 |
578
583
  | `maxVisible` | `number` | — | 已废弃,兼容旧字段;请使用 `maxItems` |
579
584
  | `useModelStreamingTranscript` | `boolean` | `false` | 当 `roles` 包含 `"model"` 时,model 字幕是否改用 `response.output_audio_transcript.*` 事件 |
585
+ | `hideCompletedAfterMs` | `number \| false` | `3000` | 字幕进入完成态后多久自动移除;传 `false` 关闭自动移除 |
586
+ | `onSubtitleCompleted` | `(item, context) => "keep" \| "remove" \| { removeAfterMs } \| void \| Promise<...>` | — | 自定义完成态清理策略,优先于 `hideCompletedAfterMs` |
580
587
  | `subtitleStyle` | `IVISubtitleOverlayStyle` | — | 样式配置 |
581
588
  | `className` / `style` | — | — | 样式 |
582
589
 
package/dist/index.cjs CHANGED
@@ -3080,9 +3080,12 @@ function useApplyVolumeToSlot(containerRef, volume, enabled, activeSourceId) {
3080
3080
  return () => observer.disconnect();
3081
3081
  }, [containerRef, volume, enabled, activeSourceId]);
3082
3082
  }
3083
+ var DEFAULT_HIDE_COMPLETED_SUBTITLE_AFTER_MS = 3e3;
3083
3084
  function useIviSubtitles(runtime, options = {}) {
3084
3085
  const roles = options.roles ?? "user";
3085
3086
  const maxItems = normalizeMaxItems(options.maxItems);
3087
+ const hideCompletedAfterMs = normalizeHideCompletedAfterMs(options.hideCompletedAfterMs);
3088
+ const onSubtitleCompleted = options.onSubtitleCompleted;
3086
3089
  const roleKey = Array.isArray(roles) ? roles.join("\0") : roles;
3087
3090
  const roleSet = react.useMemo(
3088
3091
  () => new Set(roleKey.split("\0")),
@@ -3092,9 +3095,14 @@ function useIviSubtitles(runtime, options = {}) {
3092
3095
  const [subtitles, setSubtitles] = react.useState([]);
3093
3096
  const seenIdsRef = react.useRef(/* @__PURE__ */ new Set());
3094
3097
  const initializedRef = react.useRef(false);
3098
+ const completedSubtitleIdsRef = react.useRef(/* @__PURE__ */ new Set());
3099
+ const completionControllersRef = react.useRef(/* @__PURE__ */ new Map());
3100
+ const completionPolicyRef = react.useRef(null);
3095
3101
  react.useEffect(() => {
3096
3102
  seenIdsRef.current = /* @__PURE__ */ new Set();
3097
3103
  initializedRef.current = false;
3104
+ completedSubtitleIdsRef.current = /* @__PURE__ */ new Set();
3105
+ abortCompletedSubtitleTasks(completionControllersRef.current);
3098
3106
  setSubtitles([]);
3099
3107
  if (!runtime) {
3100
3108
  return;
@@ -3150,6 +3158,8 @@ function useIviSubtitles(runtime, options = {}) {
3150
3158
  if (event.type === "session.ended") {
3151
3159
  seenIdsRef.current = /* @__PURE__ */ new Set();
3152
3160
  initializedRef.current = false;
3161
+ completedSubtitleIdsRef.current = /* @__PURE__ */ new Set();
3162
+ abortCompletedSubtitleTasks(completionControllersRef.current);
3153
3163
  setSubtitles([]);
3154
3164
  return;
3155
3165
  }
@@ -3167,6 +3177,78 @@ function useIviSubtitles(runtime, options = {}) {
3167
3177
  syncConversations(state.conversations);
3168
3178
  });
3169
3179
  }, [runtime, roleSet, maxItems, useModelStreamingTranscript]);
3180
+ react.useEffect(() => {
3181
+ return () => {
3182
+ completedSubtitleIdsRef.current = /* @__PURE__ */ new Set();
3183
+ abortCompletedSubtitleTasks(completionControllersRef.current);
3184
+ };
3185
+ }, []);
3186
+ react.useEffect(() => {
3187
+ const previousPolicy = completionPolicyRef.current;
3188
+ const policyChanged = Boolean(
3189
+ previousPolicy && (previousPolicy.hideCompletedAfterMs !== hideCompletedAfterMs || previousPolicy.onSubtitleCompleted !== onSubtitleCompleted)
3190
+ );
3191
+ completionPolicyRef.current = { hideCompletedAfterMs, onSubtitleCompleted };
3192
+ if (policyChanged) {
3193
+ completedSubtitleIdsRef.current = /* @__PURE__ */ new Set();
3194
+ abortCompletedSubtitleTasks(completionControllersRef.current);
3195
+ }
3196
+ const subtitleById = new Map(subtitles.map((item) => [item.id, item]));
3197
+ const completedIds = completedSubtitleIdsRef.current;
3198
+ const controllers = completionControllersRef.current;
3199
+ for (const [id, controller] of Array.from(controllers)) {
3200
+ const item = subtitleById.get(id);
3201
+ if (!item || !isCompletedSubtitle(item)) {
3202
+ controller.abort();
3203
+ controllers.delete(id);
3204
+ completedIds.delete(id);
3205
+ }
3206
+ }
3207
+ for (const id of Array.from(completedIds)) {
3208
+ const item = subtitleById.get(id);
3209
+ if (!item || !isCompletedSubtitle(item)) {
3210
+ completedIds.delete(id);
3211
+ }
3212
+ }
3213
+ for (const item of subtitles) {
3214
+ if (!isCompletedSubtitle(item) || completedIds.has(item.id)) {
3215
+ continue;
3216
+ }
3217
+ completedIds.add(item.id);
3218
+ const controller = new AbortController();
3219
+ controllers.set(item.id, controller);
3220
+ const context = {
3221
+ entries: subtitles,
3222
+ reason: getSubtitleCompletedReason(item),
3223
+ signal: controller.signal
3224
+ };
3225
+ let decisionResult;
3226
+ try {
3227
+ decisionResult = onSubtitleCompleted ? onSubtitleCompleted(item, context) : getDefaultCompletedSubtitleDecision(hideCompletedAfterMs);
3228
+ } catch {
3229
+ decisionResult = "keep";
3230
+ }
3231
+ void Promise.resolve(decisionResult).then((decision) => {
3232
+ applyCompletedSubtitleDecision(
3233
+ item.id,
3234
+ decision,
3235
+ controller,
3236
+ controllers,
3237
+ completedIds,
3238
+ (id) => setSubtitles((previous) => removeSubtitleById(previous, id))
3239
+ );
3240
+ }).catch(() => {
3241
+ applyCompletedSubtitleDecision(
3242
+ item.id,
3243
+ "keep",
3244
+ controller,
3245
+ controllers,
3246
+ completedIds,
3247
+ (id) => setSubtitles((previous) => removeSubtitleById(previous, id))
3248
+ );
3249
+ });
3250
+ }
3251
+ }, [subtitles, hideCompletedAfterMs, onSubtitleCompleted]);
3170
3252
  return subtitles;
3171
3253
  }
3172
3254
  function normalizeMaxItems(maxItems) {
@@ -3178,6 +3260,35 @@ function normalizeMaxItems(maxItems) {
3178
3260
  }
3179
3261
  return Math.max(0, Math.floor(maxItems));
3180
3262
  }
3263
+ function normalizeHideCompletedAfterMs(hideCompletedAfterMs) {
3264
+ if (hideCompletedAfterMs === false) {
3265
+ return false;
3266
+ }
3267
+ if (hideCompletedAfterMs === void 0 || !Number.isFinite(hideCompletedAfterMs)) {
3268
+ return DEFAULT_HIDE_COMPLETED_SUBTITLE_AFTER_MS;
3269
+ }
3270
+ return Math.max(0, Math.floor(hideCompletedAfterMs));
3271
+ }
3272
+ function getDefaultCompletedSubtitleDecision(hideCompletedAfterMs) {
3273
+ if (hideCompletedAfterMs === false) {
3274
+ return "keep";
3275
+ }
3276
+ return hideCompletedAfterMs === 0 ? "remove" : { removeAfterMs: hideCompletedAfterMs };
3277
+ }
3278
+ function normalizeCompletedSubtitleDecision(decision) {
3279
+ if (decision === "keep" || decision === "remove") {
3280
+ return decision;
3281
+ }
3282
+ if (decision && typeof decision === "object" && "removeAfterMs" in decision) {
3283
+ const removeAfterMs = Number(decision.removeAfterMs);
3284
+ if (!Number.isFinite(removeAfterMs)) {
3285
+ return "keep";
3286
+ }
3287
+ const normalized = Math.max(0, Math.floor(removeAfterMs));
3288
+ return normalized === 0 ? "remove" : { removeAfterMs: normalized };
3289
+ }
3290
+ return "keep";
3291
+ }
3181
3292
  function shouldUseConversationRole(role, roleSet, useModelStreamingTranscript) {
3182
3293
  if (!roleSet.has(role)) {
3183
3294
  return false;
@@ -3191,6 +3302,54 @@ function trimSubtitleItems(items, maxItems) {
3191
3302
  const sorted = [...items].sort((a, b) => a.timestamp - b.timestamp);
3192
3303
  return sorted.length > maxItems ? sorted.slice(sorted.length - maxItems) : sorted;
3193
3304
  }
3305
+ function isCompletedSubtitle(item) {
3306
+ return item.lifecycle === "done" || item.status === "completed" || item.status === "incomplete";
3307
+ }
3308
+ function getSubtitleCompletedReason(item) {
3309
+ return item.source === "response_audio_transcript" || item.role === "model" ? "response_done" : "conversation_done";
3310
+ }
3311
+ function abortCompletedSubtitleTasks(controllers) {
3312
+ for (const controller of controllers.values()) {
3313
+ controller.abort();
3314
+ }
3315
+ controllers.clear();
3316
+ }
3317
+ function applyCompletedSubtitleDecision(itemId, decision, controller, controllers, completedIds, removeSubtitle) {
3318
+ if (controller.signal.aborted) {
3319
+ return;
3320
+ }
3321
+ const normalizedDecision = normalizeCompletedSubtitleDecision(decision);
3322
+ if (normalizedDecision === "keep") {
3323
+ controllers.delete(itemId);
3324
+ return;
3325
+ }
3326
+ const remove = () => {
3327
+ if (controller.signal.aborted) {
3328
+ return;
3329
+ }
3330
+ controllers.delete(itemId);
3331
+ completedIds.delete(itemId);
3332
+ removeSubtitle(itemId);
3333
+ };
3334
+ if (normalizedDecision === "remove") {
3335
+ remove();
3336
+ return;
3337
+ }
3338
+ const timeout = setTimeout(remove, normalizedDecision.removeAfterMs);
3339
+ controller.signal.addEventListener(
3340
+ "abort",
3341
+ () => {
3342
+ clearTimeout(timeout);
3343
+ },
3344
+ { once: true }
3345
+ );
3346
+ }
3347
+ function removeSubtitleById(items, itemId) {
3348
+ if (!items.some((item) => item.id === itemId)) {
3349
+ return items;
3350
+ }
3351
+ return items.filter((item) => item.id !== itemId);
3352
+ }
3194
3353
  function getDisplayText(item) {
3195
3354
  return item.text || item.transcript;
3196
3355
  }
@@ -3316,6 +3475,8 @@ function IVISubtitleOverlay(props) {
3316
3475
  maxItems,
3317
3476
  maxVisible,
3318
3477
  useModelStreamingTranscript,
3478
+ hideCompletedAfterMs,
3479
+ onSubtitleCompleted,
3319
3480
  subtitleStyle,
3320
3481
  className,
3321
3482
  style
@@ -3323,7 +3484,9 @@ function IVISubtitleOverlay(props) {
3323
3484
  const entries = useIviSubtitles(runtime, {
3324
3485
  roles,
3325
3486
  maxItems: maxItems ?? maxVisible,
3326
- useModelStreamingTranscript
3487
+ useModelStreamingTranscript,
3488
+ hideCompletedAfterMs,
3489
+ onSubtitleCompleted
3327
3490
  });
3328
3491
  if (entries.length === 0) return null;
3329
3492
  const fontFamily = subtitleStyle?.fontFamily ?? "system-ui, -apple-system, sans-serif";
@@ -4414,6 +4577,7 @@ function getClientLogTag(category) {
4414
4577
  return "[IVI-CLIENT]";
4415
4578
  }
4416
4579
 
4580
+ exports.DEFAULT_HIDE_COMPLETED_SUBTITLE_AFTER_MS = DEFAULT_HIDE_COMPLETED_SUBTITLE_AFTER_MS;
4417
4581
  exports.EMPTY_RUNTIME_STATE = EMPTY_RUNTIME_STATE;
4418
4582
  exports.IVILivekitPlayer = IVILivekitPlayer;
4419
4583
  exports.IVIStageView = IVIStageView;