@henjicc/ai-sdk 0.6.0 → 0.6.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.
Files changed (28) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +16 -3
  3. package/dist/capabilities/client.js +103 -86
  4. package/dist/capabilities/speech-recognition/bailian/realtime/module.js +42 -7
  5. package/dist/capabilities/speech-recognition/bailian/realtime/protocol.js +61 -19
  6. package/dist/capabilities/speech-recognition/volcengine/realtime/module.js +42 -10
  7. package/dist/capabilities/translation/bailian/module.js +41 -13
  8. package/dist/capabilities/translation/bailian/sse.d.ts +1 -1
  9. package/dist/capabilities/translation/bailian/sse.js +82 -54
  10. package/dist/capabilities/types.d.ts +4 -0
  11. package/dist/llm/chat.js +6 -1
  12. package/dist/llm/streaming.js +131 -91
  13. package/dist/protocols/sse-events.d.ts +4 -0
  14. package/dist/protocols/sse-events.js +31 -0
  15. package/docs/consumers.md +102 -86
  16. package/docs/llm-adaptation/README.md +26 -1
  17. package/docs/llm-adaptation//344/276/233/345/272/224/345/225/206//346/231/272/350/260/261GLM.md +3 -1
  18. package/docs/model-adaptation/Fun-ASR-Realtime/Fun-ASR-Realtime_/347/231/276/347/202/274.md +22 -2
  19. package/docs/model-adaptation/Qwen-MT-Flash/Qwen-MT-Flash_/347/231/276/347/202/274.md +24 -3
  20. package/docs/model-adaptation/Qwen-MT-Lite/Qwen-MT-Lite_/347/231/276/347/202/274.md +3 -2
  21. package/docs/model-adaptation/Qwen-MT-Plus/Qwen-MT-Plus_/347/231/276/347/202/274.md +3 -2
  22. package/docs/model-adaptation/Qwen3-ASR-Flash-Realtime/Qwen3-ASR-Flash-Realtime_/347/231/276/347/202/274.md +25 -2
  23. package/docs/model-adaptation/SeedASR-2.0-Realtime/SeedASR-2.0-Realtime_/347/201/253/345/261/261/345/274/225/346/223/216.md +7 -3
  24. package/docs/model-adaptation//344/276/233/345/272/224/345/225/206//347/231/276/347/202/274.md +12 -0
  25. package/examples/llm-chat/README.md +1 -1
  26. package/examples/llm-chat/index.ts +1 -1
  27. package/examples/llm-chat/package.json +1 -1
  28. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.2 - 2026-09-24
4
+
5
+ - 完善聊天与翻译 SSE 的事件边界和完成状态校验:保留合法空中间帧、用量与状态通知,提前结束、服务端错误和无效终态不再伪装成功;保留翻译增量/累积协议差异。
6
+ - 修复百炼实时 ASR 字段校验、Qwen 中间文字拼接和状态通知处理;保留 0.6.1 空中间帧兼容,诊断包含实际模型、协议及失败阶段。
7
+ - 收口共享实时会话的取消、超时、后台故障通知和资源释放;SDK 会话提供可选 result promise,旧自定义会话仍兼容。握手清理失败保留首个故障,火山服务错误默认脱敏。
8
+ - 阻止聊天及火山实时识别在取消后继续派发同一帧的后续结果;明确智谱推理网络错误与上下文截断,扩展通知不冒充完成证据。
9
+ - 增加脱敏事件序列回归并执行修复撤销验证,更新聊天示例的合法结束帧。未改变供应商配额、网络稳定性或模型识别质量。
10
+
11
+ ## 0.6.1 - 2026-09-24
12
+
13
+ - 修复百炼 Fun Duplex 实时识别把无句首标记的空文本中间帧误判为无效响应的问题;覆盖 Qwen Audio 3.1 streaming 与 Fun-ASR 共享协议。
14
+ - 保持缺失文字字段、空最终结果、全程无有效 final 的严格校验及连接释放。
15
+
3
16
  ## 0.6.0 - 2026-09-24
4
17
 
5
18
  - 新增 MiMo 2.6 Pro / Flash / Pro-UltraSpeed 能力;默认推荐 Pro / Flash,保留 2.5 ID(官方计划 10 月 21 日下线),不自动替换用户选择。
package/README.md CHANGED
@@ -48,10 +48,10 @@ try {
48
48
 
49
49
  百炼工厂必须传 `baseUrl` 为实际地域/工作空间的 API 根地址,不包含端点路径。其他供应商可以覆盖根地址与 `credentialId`;SDK 不自动切换地域或账号。不同模型的向量不能混用;切换模型通常需要重建向量索引。Rerank 分数仅在本次请求内比较。
50
50
 
51
- SDK `0.6.0` 的正式分发渠道为公共 npm,无需配置 registry 或访问令牌:
51
+ SDK `0.6.2` 的正式分发渠道为公共 npm,无需配置 registry 或访问令牌:
52
52
 
53
53
  ```bash
54
- npm install @henjicc/ai-sdk@0.6.0
54
+ npm install @henjicc/ai-sdk@0.6.2
55
55
  ```
56
56
 
57
57
  然后提供 4 个宿主能力(`Transport` / `CredentialStore` / `MediaReader` / `Logger`),创建客户端:
@@ -493,7 +493,20 @@ await capabilities.dispose()
493
493
  - `llm/bigmodel`:智谱同一 provider family 下的中国大陆/Global 端点 profile、独立凭据槽与 GLM-5.3-Flash 能力。
494
494
  - `llm/modules`:外部包、插件与内置 LLM 共用的注册、执行、发现、取消和 namespace 卸载边界。
495
495
 
496
- ASR module ID 固定为 `<providerId>.speech-recognition.<modelId>`,翻译固定为
496
+ 实时会话仍使用 `openSession → send → finish/close`。由 client 创建的会话额外提供 `result` Promise:
497
+ 它不主动发送 finish;在最终结果到达或后台失败、取消、超时、主动关闭,并完成资源收尾后结算。
498
+ 调用方可以观察 `session.result` 以获知停止发送音频期间的后台失败,不必等到下一次 `send/finish`。
499
+ `finish()` 幂等且返回同一结果;正常关闭但没有最终结果时 `result` 拒绝 `realtime_session_closed`,
500
+ 取消是 `cancelled`,超时是 `timeout`,供应商失败保留原错误。未订阅该 Promise 不产生未处理拒绝,
501
+ SDK 仍按现有日志契约记录失败。
502
+
503
+ 外部实时驱动可以提供可选 `result` Promise,致命后台/发送错误必须拒绝它,成功则返回有效最终结果。
504
+ 输入校验等可恢复的单次 send 错误只拒绝该调用,不应结束 result。旧驱动无需新增字段即可继续使用;
505
+ 如果旧驱动没有提供结果通道,SDK 无法自行发现它的后台失败。公共类型中的可选属性用于兼容旧会话实现。
506
+ 完成、失败、取消与关闭竞争时只结算一次;清理失败不能覆盖首个失败,但成功路径清理失败不能报成功。
507
+ 连接、监听器与定时器回收后,原 requestId 可用于新会话;不会自动重连或重放已可能计费的音频。
508
+
509
+ ASR module ID 固定为 `<providerId>.speech-recognition.<modelId>`,翻译固定为
497
510
  `bailian.translation.<modelId>`;供应商 ID 使用 `bailian` / `volcengine` / `siliconflow` / `groq`,
498
511
  没有 `funasr` 兼容供应商别名。火山文件标准版不会读取本地 bytes/media-ref;宿主必须先通过自己受控的
499
512
  对象存储发布成供应商可访问 URL。SDK 不提供公共 URL 文本框或冒充上传成功的回退路径。
@@ -193,117 +193,134 @@ export function createCapabilityClient(config) {
193
193
  ? new AiRuntimeError('timeout', `Capability session timed out: ${requestId}`)
194
194
  : cancelledError(requestId);
195
195
  }
196
- let ended = false;
197
196
  let closePromise;
198
197
  let finishPromise;
198
+ let outcome;
199
+ let settlement;
200
+ let cleanupError;
201
+ const pendingSends = new Set();
202
+ let resolveResult;
203
+ let rejectResult;
204
+ const result = new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject; });
205
+ // Observing is optional; the original promise remains rejected for callers.
206
+ void result.catch(() => undefined);
199
207
  const closeDriver = () => {
200
208
  closePromise ??= Promise.resolve().then(async () => await driver.close?.());
201
209
  return closePromise;
202
210
  };
203
- const end = (error) => {
204
- if (ended)
205
- return;
206
- ended = true;
207
- options.signal?.removeEventListener('abort', forwardAbort);
208
- if (timeout !== undefined)
209
- clearTimeout(timeout);
210
- active.delete(requestId);
211
- markFinished();
212
- span.end(error);
213
- };
214
- const closeAfterAbort = async () => {
215
- if (ended)
216
- return;
217
- const error = timedOut
218
- ? new AiRuntimeError('timeout', `Capability session timed out: ${requestId}`)
219
- : cancelledError(requestId);
220
- try {
221
- await closeDriver();
222
- }
223
- finally {
224
- runtime.logger.error('能力实时会话失败', {
225
- event: 'capability.session.failed', requestId, context: { capabilityId: id }, error,
226
- });
227
- end(error);
228
- }
229
- };
230
- controller.signal.addEventListener('abort', () => { void closeAfterAbort(); }, { once: true });
231
- const sessionInactiveError = () => timedOut
211
+ const abortError = () => timedOut
232
212
  ? new AiRuntimeError('timeout', `Capability session timed out: ${requestId}`)
233
213
  : cancelledError(requestId);
214
+ const normalize = (error) => controller.signal.aborted
215
+ ? abortError() : normalizeExecutionError(id, error);
216
+ const inactiveError = () => outcome && outcome.kind !== 'completed'
217
+ ? outcome.error : new AiRuntimeError('realtime_session_inactive', `Capability session is inactive: ${requestId}`);
218
+ const settle = (next) => {
219
+ if (settlement)
220
+ return settlement;
221
+ outcome = next;
222
+ for (const reject of pendingSends)
223
+ reject(inactiveError());
224
+ pendingSends.clear();
225
+ settlement = (async () => {
226
+ let terminal = next;
227
+ try {
228
+ await closeDriver();
229
+ }
230
+ catch (error) {
231
+ cleanupError = normalizeExecutionError(id, error);
232
+ // Keep the first failure/cancellation; failed cleanup cannot make
233
+ // a successful result or explicit close look successful.
234
+ if (terminal.kind !== 'failed')
235
+ terminal = { kind: 'failed', error: cleanupError };
236
+ }
237
+ outcome = terminal;
238
+ controller.signal.removeEventListener('abort', onAbort);
239
+ options.signal?.removeEventListener('abort', forwardAbort);
240
+ if (timeout !== undefined)
241
+ clearTimeout(timeout);
242
+ active.delete(requestId);
243
+ markFinished();
244
+ if (terminal.kind === 'failed') {
245
+ runtime.logger.error('能力实时会话失败', {
246
+ event: 'capability.session.failed', requestId,
247
+ context: { capabilityId: id, ...(cleanupError ? { cleanupErrorCode: cleanupError.code } : {}) },
248
+ error: terminal.error,
249
+ });
250
+ }
251
+ else {
252
+ runtime.logger.info(terminal.kind === 'completed' ? '能力实时会话完成' : '能力实时会话关闭', {
253
+ event: `capability.session.${terminal.kind}`, requestId, context: { capabilityId: id },
254
+ });
255
+ }
256
+ span.end(terminal.kind === 'failed' ? terminal.error : undefined);
257
+ if (terminal.kind === 'completed')
258
+ resolveResult(terminal.output);
259
+ else
260
+ rejectResult(terminal.error);
261
+ })();
262
+ return settlement;
263
+ };
264
+ const onAbort = () => { void settle({ kind: 'failed', error: abortError() }); };
265
+ controller.signal.addEventListener('abort', onAbort, { once: true });
266
+ if (driver.result) {
267
+ void driver.result.then(output => settle({ kind: 'completed', output }), error => settle({ kind: 'failed', error: normalize(error) }));
268
+ }
234
269
  return {
235
270
  requestId,
236
271
  descriptor: registered.module.descriptor,
272
+ result,
237
273
  send: async (value) => {
238
- if (ended || controller.signal.aborted)
239
- throw sessionInactiveError();
240
- await driver.send(value);
274
+ if (outcome || finishPromise)
275
+ throw inactiveError();
276
+ if (controller.signal.aborted)
277
+ throw abortError();
278
+ let rejectSend;
279
+ const pending = new Promise((resolve, reject) => {
280
+ rejectSend = reject;
281
+ pendingSends.add(reject);
282
+ void Promise.resolve().then(async () => {
283
+ if (outcome)
284
+ throw inactiveError();
285
+ await driver.send(value);
286
+ }).then(resolve, reject);
287
+ });
288
+ try {
289
+ await pending;
290
+ }
291
+ finally {
292
+ pendingSends.delete(rejectSend);
293
+ }
241
294
  },
242
295
  finish: () => {
243
296
  if (finishPromise)
244
297
  return finishPromise;
245
- if (ended || controller.signal.aborted)
246
- return Promise.reject(sessionInactiveError());
247
- finishPromise = (async () => {
248
- let output;
249
- try {
250
- output = await driver.finish();
251
- if (controller.signal.aborted)
252
- throw cancelledError(requestId);
253
- }
254
- catch (error) {
255
- const normalized = controller.signal.aborted
256
- ? cancelledError(requestId)
257
- : normalizeExecutionError(id, error);
298
+ finishPromise = result;
299
+ if (!outcome) {
300
+ void Promise.resolve().then(async () => {
301
+ if (outcome)
302
+ return;
258
303
  try {
259
- await closeDriver();
304
+ const output = await driver.finish();
305
+ if (controller.signal.aborted)
306
+ throw abortError();
307
+ await settle({ kind: 'completed', output });
260
308
  }
261
- catch {
262
- // 保留 finish 的首个失败;close 仅负责尽力释放连接。
309
+ catch (error) {
310
+ await settle({ kind: 'failed', error: normalize(error) });
263
311
  }
264
- runtime.logger.error('能力实时会话失败', {
265
- event: 'capability.session.failed', requestId, context: { capabilityId: id }, error: normalized,
266
- });
267
- end(normalized);
268
- throw normalized;
269
- }
270
- try {
271
- await closeDriver();
272
- }
273
- catch (error) {
274
- const normalized = normalizeExecutionError(id, error);
275
- runtime.logger.error('能力实时会话失败', {
276
- event: 'capability.session.failed', requestId, context: { capabilityId: id }, error: normalized,
277
- });
278
- end(normalized);
279
- throw normalized;
280
- }
281
- runtime.logger.info('能力实时会话完成', {
282
- event: 'capability.session.completed', requestId, context: { capabilityId: id },
283
312
  });
284
- end();
285
- return output;
286
- })();
313
+ }
287
314
  return finishPromise;
288
315
  },
289
316
  close: async () => {
290
- if (ended)
317
+ if (outcome) {
318
+ await settlement;
291
319
  return;
292
- try {
293
- await closeDriver();
294
- runtime.logger.info('能力实时会话关闭', {
295
- event: 'capability.session.closed', requestId, context: { capabilityId: id },
296
- });
297
- end();
298
- }
299
- catch (error) {
300
- const normalized = normalizeExecutionError(id, error);
301
- runtime.logger.error('能力实时会话失败', {
302
- event: 'capability.session.failed', requestId, context: { capabilityId: id }, error: normalized,
303
- });
304
- end(normalized);
305
- throw normalized;
306
320
  }
321
+ await settle({ kind: 'closed', error: new AiRuntimeError('realtime_session_closed', `Capability session was closed: ${requestId}`) });
322
+ if (cleanupError)
323
+ throw cleanupError;
307
324
  },
308
325
  };
309
326
  }
@@ -84,6 +84,8 @@ export function createBailianRealtimeAsrModule(preset, options = {}) {
84
84
  if (context.signal.aborted)
85
85
  throw cancelledError(context.requestId);
86
86
  const credential = await apiKey(context);
87
+ if (context.signal.aborted)
88
+ throw cancelledError(context.requestId);
87
89
  const transport = context.runtime.realtime;
88
90
  if (!transport)
89
91
  throw new AiRuntimeError('realtime_transport_missing', 'Host did not provide realtime transport');
@@ -118,21 +120,32 @@ async function openDriver(preset, input, taskId, connection, context) {
118
120
  let lastPartial = '';
119
121
  let durationMs;
120
122
  let terminalError;
123
+ let lastEventKind;
121
124
  const segments = [];
122
125
  const created = deferred();
123
126
  const ready = deferred();
124
127
  const finished = deferred();
128
+ const onAbort = () => fail(cancelledError(context.requestId));
125
129
  const closeConnection = () => {
130
+ context.signal.removeEventListener('abort', onAbort);
126
131
  closePromise ??= Promise.resolve().then(async () => await connection.close(1000, 'session complete'));
127
132
  return closePromise;
128
133
  };
129
134
  const fail = (error) => {
130
135
  if (phase === 'finished' || phase === 'closed' || phase === 'failed')
131
136
  return;
137
+ const failedPhase = phase;
132
138
  phase = 'failed';
133
- const normalized = error instanceof AiRuntimeError
139
+ const original = error instanceof AiRuntimeError
134
140
  ? error
135
- : new AiRuntimeError('provider_realtime_error', error instanceof Error ? error.message : 'Bailian realtime failed');
141
+ : new AiRuntimeError('provider_realtime_error', 'Realtime transport or event handler failed');
142
+ const message = original.message.replace(`[${original.code}] `, '');
143
+ const normalized = new AiRuntimeError(original.code, original.details?.modelId === preset.modelId ? message : `${preset.modelId} (${preset.protocol}): ${message}`, {
144
+ providerId: 'bailian', modelId: preset.modelId, protocol: preset.protocol,
145
+ stage: failedPhase, operation: original.details?.stage,
146
+ eventType: original.details?.eventType ?? lastEventKind,
147
+ providerCode: original.details?.providerCode,
148
+ });
136
149
  terminalError = normalized;
137
150
  created.reject(normalized);
138
151
  ready.reject(normalized);
@@ -159,6 +172,7 @@ async function openDriver(preset, input, taskId, connection, context) {
159
172
  await closeConnection();
160
173
  };
161
174
  const handle = async (event) => {
175
+ lastEventKind = event.kind;
162
176
  if (event.kind === 'created') {
163
177
  if (preset.protocol !== 'qwen-realtime' || phase !== 'opening') {
164
178
  throw new AiRuntimeError('protocol_event_out_of_order', 'Unexpected Bailian realtime created event');
@@ -206,7 +220,10 @@ async function openDriver(preset, input, taskId, connection, context) {
206
220
  return;
207
221
  }
208
222
  if (event.kind === 'error') {
209
- throw new AiRuntimeError('provider_task_failed', `${event.code ? `${event.code}: ` : ''}${event.message}`);
223
+ // Provider messages can echo request content. Retain the diagnostic code,
224
+ // never the full upstream message in the default error/logging path.
225
+ const providerCode = event.code && /^[\w.-]{1,80}$/.test(event.code) ? event.code : undefined;
226
+ throw new AiRuntimeError('provider_task_failed', 'Server reported a realtime task failure', { providerCode });
210
227
  }
211
228
  if (event.kind === 'ignored')
212
229
  return;
@@ -238,9 +255,10 @@ async function openDriver(preset, input, taskId, connection, context) {
238
255
  }
239
256
  };
240
257
  void consume();
241
- const onAbort = () => fail(cancelledError(context.requestId));
242
258
  context.signal.addEventListener('abort', onAbort, { once: true });
243
259
  try {
260
+ if (context.signal.aborted)
261
+ throw cancelledError(context.requestId);
244
262
  if (preset.protocol === 'fun-duplex') {
245
263
  await connection.send(buildFunStart(preset, input, taskId));
246
264
  }
@@ -252,10 +270,19 @@ async function openDriver(preset, input, taskId, connection, context) {
252
270
  }
253
271
  catch (error) {
254
272
  fail(error);
255
- await closeConnection();
256
- throw error;
273
+ try {
274
+ await closeConnection();
275
+ }
276
+ catch {
277
+ const primary = terminalError;
278
+ throw new AiRuntimeError(primary.code, primary.message.replace(`[${primary.code}] `, ''), {
279
+ ...primary.details, cleanupFailed: true,
280
+ });
281
+ }
282
+ throw terminalError ?? error;
257
283
  }
258
284
  return {
285
+ result: finished.promise,
259
286
  send: async (chunk) => {
260
287
  if (terminalError)
261
288
  throw terminalError;
@@ -263,7 +290,14 @@ async function openDriver(preset, input, taskId, connection, context) {
263
290
  throw new AiRuntimeError('realtime_session_inactive', 'Bailian realtime session is not active');
264
291
  if (chunk.bytes.byteLength === 0)
265
292
  return;
266
- await connection.send(preset.protocol === 'fun-duplex' ? chunk.bytes : buildQwenAudio(chunk.bytes));
293
+ const data = preset.protocol === 'fun-duplex' ? chunk.bytes : buildQwenAudio(chunk.bytes);
294
+ try {
295
+ await connection.send(data);
296
+ }
297
+ catch (error) {
298
+ fail(error);
299
+ throw terminalError ?? error;
300
+ }
267
301
  },
268
302
  finish: () => {
269
303
  if (finishPromise)
@@ -286,6 +320,7 @@ async function openDriver(preset, input, taskId, connection, context) {
286
320
  }
287
321
  return await finished.promise;
288
322
  })();
323
+ void finishPromise.catch(error => fail(error));
289
324
  return finishPromise;
290
325
  },
291
326
  close: async () => {
@@ -116,17 +116,33 @@ export function qwenUsesManualCommit(input) {
116
116
  return inputOptions(input).turnDetection === 'manual';
117
117
  }
118
118
  export function parseRealtimeMessage(preset, payload) {
119
- let parsed;
119
+ let eventType;
120
120
  try {
121
- parsed = JSON.parse(payload);
121
+ let parsed;
122
+ try {
123
+ parsed = JSON.parse(payload);
124
+ }
125
+ catch {
126
+ throw new AiRuntimeError('invalid_response', 'Server returned invalid JSON');
127
+ }
128
+ const root = record(parsed);
129
+ if (!root)
130
+ throw new AiRuntimeError('invalid_response', 'Server returned a non-object event');
131
+ const discriminator = preset.protocol === 'fun-duplex' ? record(root.header)?.event : root.type;
132
+ if (typeof discriminator !== 'string' || !discriminator.trim()) {
133
+ throw new AiRuntimeError('invalid_response', 'Server event has no event type');
134
+ }
135
+ eventType = /^[a-zA-Z][\w.-]{0,127}$/.test(discriminator) ? discriminator : 'unrecognized-event';
136
+ return preset.protocol === 'fun-duplex' ? parseFunEvent(root) : parseQwenEvent(root);
122
137
  }
123
- catch {
124
- throw new AiRuntimeError('invalid_response', 'Bailian realtime server returned invalid JSON');
138
+ catch (error) {
139
+ if (!(error instanceof AiRuntimeError))
140
+ throw error;
141
+ throw new AiRuntimeError(error.code, `${preset.modelId} (${preset.protocol}): ${error.message.replace(`[${error.code}] `, '')}`, {
142
+ providerId: 'bailian', modelId: preset.modelId, protocol: preset.protocol,
143
+ stage: 'parse', eventType,
144
+ });
125
145
  }
126
- const root = record(parsed);
127
- if (!root)
128
- throw new AiRuntimeError('invalid_response', 'Bailian realtime server returned a non-object event');
129
- return preset.protocol === 'fun-duplex' ? parseFunEvent(root) : parseQwenEvent(root);
130
146
  }
131
147
  function parseFunEvent(root) {
132
148
  const header = record(root.header);
@@ -147,25 +163,28 @@ function parseFunEvent(root) {
147
163
  const sentence = record(record(record(root.payload)?.output)?.sentence);
148
164
  if (sentence?.heartbeat === true)
149
165
  return { kind: 'ignored', eventType: 'heartbeat' };
166
+ if (typeof sentence?.text !== 'string' || typeof sentence.sentence_end !== 'boolean') {
167
+ throw new AiRuntimeError('invalid_response', 'Result requires text and boolean sentence_end');
168
+ }
150
169
  const segment = parseBailianSentence(sentence);
151
170
  const transcript = segment?.text ?? string(sentence?.text);
152
171
  if (!transcript) {
153
- // 官方会用 text="" + sentence_begin=true/sentence_end=false 宣告新句开始;
154
- // 这是合法的中间生命周期事件,不是识别结果。最终句仍必须带有效文本。
155
- if (sentence?.sentence_begin === true && sentence?.sentence_end === false) {
156
- return { kind: 'ignored', eventType: 'empty-sentence-begin' };
172
+ // 句首及后续中间帧均可能尚无文字;只有首帧带 sentence_begin。
173
+ // 必须有 text 字符串与明确的非终态,不能把缺字段的异常响应当作空帧。
174
+ if (typeof sentence?.text === 'string' && sentence.sentence_end === false) {
175
+ return { kind: 'ignored', eventType: sentence.sentence_begin === true ? 'empty-sentence-begin' : 'empty-intermediate' };
157
176
  }
158
177
  if (sentence?.sentence_end === true) {
159
- throw new AiRuntimeError('invalid_response', 'Bailian Fun-ASR final result has no text');
178
+ throw new AiRuntimeError('invalid_response', 'Final result has no text');
160
179
  }
161
- throw new AiRuntimeError('invalid_response', 'Bailian Fun-ASR result has no text or sentence state');
180
+ throw new AiRuntimeError('invalid_response', 'Result has no text or sentence state');
162
181
  }
163
182
  const duration = number((record(root.usage) ?? record(record(root.payload)?.usage))?.duration);
164
183
  return sentence?.sentence_end === true
165
184
  ? { kind: 'final', text: transcript, segment, durationMs: duration === undefined ? undefined : duration * 1_000 }
166
185
  : { kind: 'partial', text: transcript, segment };
167
186
  }
168
- return { kind: 'unknown', eventType: eventType || 'missing-event' };
187
+ return { kind: 'unknown', eventType: /^[a-zA-Z][\w.-]{0,127}$/.test(eventType) ? eventType : 'unrecognized-event' };
169
188
  }
170
189
  function parseQwenEvent(root) {
171
190
  const eventType = string(root.type) ?? '';
@@ -176,19 +195,42 @@ function parseQwenEvent(root) {
176
195
  return { kind: 'ready', sessionId: string(record(root.session)?.id) ?? string(root.session_id) };
177
196
  }
178
197
  if (eventType === 'conversation.item.input_audio_transcription.text') {
179
- const transcript = `${string(root.text) ?? ''}${string(root.stash) ?? ''}`.trim();
180
- if (!transcript)
198
+ if (typeof root.text !== 'string' || typeof root.stash !== 'string') {
199
+ throw new AiRuntimeError('invalid_response', 'Partial event requires string text and stash');
200
+ }
201
+ const transcript = root.text + root.stash;
202
+ if (!transcript.trim())
181
203
  throw new AiRuntimeError('invalid_response', 'Bailian Qwen realtime partial event has no text');
182
204
  return { kind: 'partial', text: transcript };
183
205
  }
184
206
  if (eventType === 'conversation.item.input_audio_transcription.completed') {
185
- const transcript = string(root.transcript) ?? string(root.text);
207
+ const transcript = string(root.transcript);
186
208
  if (!transcript)
187
209
  throw new AiRuntimeError('invalid_response', 'Bailian Qwen realtime final event has no transcript');
188
210
  return { kind: 'final', text: transcript };
189
211
  }
190
212
  if (eventType === 'session.finished')
191
213
  return { kind: 'finished' };
214
+ if (eventType === 'input_audio_buffer.speech_started' || eventType === 'input_audio_buffer.speech_stopped') {
215
+ const timestamp = root[eventType.endsWith('speech_started') ? 'audio_start_ms' : 'audio_end_ms'];
216
+ if (!string(root.item_id) || typeof timestamp !== 'number' || !Number.isInteger(timestamp) || timestamp < 0) {
217
+ throw new AiRuntimeError('invalid_response', 'Invalid speech activity notification');
218
+ }
219
+ return { kind: 'ignored', eventType };
220
+ }
221
+ if (eventType === 'input_audio_buffer.committed') {
222
+ if (!string(root.item_id))
223
+ throw new AiRuntimeError('invalid_response', 'Commit notification has no item_id');
224
+ return { kind: 'ignored', eventType };
225
+ }
226
+ if (eventType === 'conversation.item.created') {
227
+ const item = record(root.item);
228
+ if (!string(item?.id) || !Array.isArray(item?.content)
229
+ || !item.content.some(value => record(value)?.type === 'input_audio' && record(value)?.transcript === null)) {
230
+ throw new AiRuntimeError('invalid_response', 'Invalid audio item notification');
231
+ }
232
+ return { kind: 'ignored', eventType };
233
+ }
192
234
  if (eventType === 'error' || eventType === 'conversation.item.input_audio_transcription.failed') {
193
235
  const error = record(root.error);
194
236
  return {
@@ -196,5 +238,5 @@ function parseQwenEvent(root) {
196
238
  message: string(error?.message) ?? string(root.message) ?? 'Bailian Qwen realtime task failed',
197
239
  };
198
240
  }
199
- return { kind: 'unknown', eventType: eventType || 'missing-type' };
241
+ return { kind: 'unknown', eventType: /^[a-zA-Z][\w.-]{0,127}$/.test(eventType) ? eventType : 'unrecognized-event' };
200
242
  }