@spacex110/core 0.1.15 → 0.1.17

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/src/strategies.ts CHANGED
@@ -1,4 +1,15 @@
1
- import type { Provider, Model, ModelCapability } from './types';
1
+ import type {
2
+ Provider,
3
+ Model,
4
+ ModelCapability,
5
+ NormalizedMessage,
6
+ ResponseMessage,
7
+ MultimodalChatOptions,
8
+ MultimodalChatStreamOptions,
9
+ MultimodalChatResult,
10
+ MultimodalStreamDelta,
11
+ } from './types';
12
+ import { normalizeMessages } from './multimodal-normalize';
2
13
 
3
14
  export interface ProviderStrategy {
4
15
  format: string;
@@ -13,6 +24,21 @@ export interface ProviderStrategy {
13
24
  parseStreamChunk?: (data: any) => string;
14
25
  // Headers
15
26
  buildHeaders: (apiKey: string, baseUrl?: string) => Record<string, string>;
27
+ // ============ Multimodal (可选) ============
28
+ /** 是否支持图片/音频等多模态输入。不支持时调用方传入多模态消息应直接报错。 */
29
+ supportsMultimodalInput?: boolean;
30
+ /** 是否支持多模态输出(如文生图)。不支持时调用方传入 outputModalities 含 image 应直接报错。 */
31
+ supportsMultimodalOutput?: boolean;
32
+ /** 构造多模态聊天请求体 */
33
+ buildMultimodalChatPayload?: (
34
+ model: string,
35
+ messages: NormalizedMessage[],
36
+ options: MultimodalChatOptions
37
+ ) => Record<string, unknown>;
38
+ /** 解析多模态聊天响应为统一的 ResponseMessage */
39
+ parseMultimodalChatResponse?: (data: any) => ResponseMessage;
40
+ /** 解析 SSE 流式 chunk 为增量状态 */
41
+ parseMultimodalStreamChunk?: (data: any) => MultimodalStreamDelta;
16
42
  }
17
43
 
18
44
  /**
@@ -23,6 +49,21 @@ function prefersCompletionTokens(model: string): boolean {
23
49
  return /^o\d/.test(model) || /reasoner|thinking|o1|o3|o4/i.test(model);
24
50
  }
25
51
 
52
+ /**
53
+ * 从 data URI 字符串中猜测 MIME 类型。
54
+ * 用于图片/音频响应在缺少 mimeType 字段时的回退解析。
55
+ *
56
+ * @example
57
+ * guessMimeFromDataUri('data:image/png;base64,xxx') // => 'image/png'
58
+ * guessMimeFromDataUri('data:audio/mp3;base64,xxx') // => 'audio/mp3'
59
+ * guessMimeFromDataUri('https://example.com/a.jpg') // => null
60
+ */
61
+ export function guessMimeFromDataUri(uri: string): string | null {
62
+ if (!uri) return null;
63
+ const m = /^data:([^;,]+)/.exec(uri);
64
+ return m ? m[1] : null;
65
+ }
66
+
26
67
  /**
27
68
  * 从 API 响应中提取模型能力
28
69
  * 支持多种格式:
@@ -150,6 +191,155 @@ const openaiStrategy: ProviderStrategy = {
150
191
  // OpenAI SSE: { choices: [{ delta: { content } }] },[DONE] 由调用方过滤
151
192
  return data.choices?.[0]?.delta?.content || '';
152
193
  },
194
+ // ============ Multimodal ============
195
+ supportsMultimodalInput: true,
196
+ supportsMultimodalOutput: true, // 实际能否输出由 model + modalities 决定
197
+ buildMultimodalChatPayload: (model, messages, options) => {
198
+ const converted = messages.map((m) => ({
199
+ role: m.role,
200
+ content: m.parts.map((p) => {
201
+ if (p.type === 'text') return { type: 'text', text: p.data };
202
+ if (p.type === 'image') {
203
+ const detail = (p.meta?.detail as string) || 'auto';
204
+ return {
205
+ type: 'image_url',
206
+ image_url: { url: p.data, detail },
207
+ };
208
+ }
209
+ if (p.type === 'audio') {
210
+ const fmt = p.mimeType?.includes('mp3') ? 'mp3' : 'wav';
211
+ return {
212
+ type: 'input_audio',
213
+ input_audio: { data: p.data, format: fmt },
214
+ };
215
+ }
216
+ return { type: 'text', text: '' };
217
+ }),
218
+ }));
219
+ const payload: Record<string, unknown> = { model, messages: converted };
220
+ if (options.maxTokens) {
221
+ payload[prefersCompletionTokens(model) ? 'max_completion_tokens' : 'max_tokens'] = options.maxTokens;
222
+ }
223
+ if (options.temperature !== undefined) {
224
+ payload.temperature = options.temperature;
225
+ }
226
+ // OpenRouter 专属:modalities 字段(启用文生图)
227
+ if (
228
+ options.outputModalities?.length &&
229
+ /openrouter\.ai/i.test(options.baseUrl)
230
+ ) {
231
+ payload.modalities = options.outputModalities;
232
+ }
233
+ return payload;
234
+ },
235
+ parseMultimodalChatResponse: (data) => {
236
+ const msg = data.choices?.[0]?.message;
237
+ if (!msg) {
238
+ return { role: 'assistant', content: '', raw: data };
239
+ }
240
+ let text = '';
241
+ if (typeof msg.content === 'string') {
242
+ text = msg.content;
243
+ } else if (Array.isArray(msg.content)) {
244
+ text = msg.content
245
+ .filter((p: { type: string }) => p.type === 'text')
246
+ .map((p: { text?: string }) => p.text || '')
247
+ .join('');
248
+ }
249
+ const images: import('./types').ContentResponsePart[] = [];
250
+ if (Array.isArray(msg.images)) {
251
+ for (const img of msg.images) {
252
+ const url: string | undefined = img.image_url?.url;
253
+ if (url) {
254
+ images.push({
255
+ type: 'image',
256
+ url,
257
+ mimeType: img.mimeType || guessMimeFromDataUri(url) || 'image/png',
258
+ });
259
+ } else if (img.b64_json) {
260
+ images.push({
261
+ type: 'image',
262
+ b64_json: img.b64_json,
263
+ mimeType: img.mimeType || 'image/png',
264
+ });
265
+ }
266
+ }
267
+ }
268
+ const u = data.usage;
269
+ const content: string | import('./types').ContentResponsePart[] =
270
+ images.length
271
+ ? [{ type: 'text', text }, ...images]
272
+ : (text || '');
273
+ return {
274
+ role: 'assistant',
275
+ content,
276
+ usage: u
277
+ ? {
278
+ inputTokens: u.prompt_tokens,
279
+ outputTokens: u.completion_tokens,
280
+ totalTokens: u.total_tokens,
281
+ }
282
+ : undefined,
283
+ raw: data,
284
+ finishReason: data.choices?.[0]?.finish_reason,
285
+ };
286
+ },
287
+ parseMultimodalStreamChunk: (data) => {
288
+ const delta = data.choices?.[0]?.delta;
289
+ if (!delta) return {};
290
+ const out: import('./types').MultimodalStreamDelta = {};
291
+ if (typeof delta.content === 'string') {
292
+ out.text = delta.content;
293
+ } else if (Array.isArray(delta.content)) {
294
+ out.text = delta.content
295
+ .filter((p: { type: string }) => p.type === 'text')
296
+ .map((p: { text?: string }) => p.text || '')
297
+ .join('');
298
+ const imgs = delta.content.filter(
299
+ (p: { type: string }) => p.type === 'image_url'
300
+ );
301
+ if (imgs.length) {
302
+ out.images = imgs.map(
303
+ (p: { image_url?: { url?: string } }) => ({
304
+ type: 'image' as const,
305
+ url: p.image_url?.url,
306
+ mimeType:
307
+ guessMimeFromDataUri(p.image_url?.url || '') ||
308
+ 'image/png',
309
+ })
310
+ );
311
+ }
312
+ }
313
+ if (Array.isArray(delta.images)) {
314
+ const extra = delta.images.map(
315
+ (img: {
316
+ image_url?: { url?: string };
317
+ b64_json?: string;
318
+ mimeType?: string;
319
+ }) => ({
320
+ type: 'image' as const,
321
+ url: img.image_url?.url,
322
+ b64_json: img.b64_json,
323
+ mimeType:
324
+ img.mimeType ||
325
+ guessMimeFromDataUri(img.image_url?.url || '') ||
326
+ 'image/png',
327
+ })
328
+ );
329
+ out.images = out.images ? out.images.concat(extra) : extra;
330
+ }
331
+ if (data.choices?.[0]?.finish_reason) {
332
+ out.finishReason = data.choices[0].finish_reason;
333
+ }
334
+ if (data.usage) {
335
+ out.usage = {
336
+ inputTokens: data.usage.prompt_tokens,
337
+ outputTokens: data.usage.completion_tokens,
338
+ totalTokens: data.usage.total_tokens,
339
+ };
340
+ }
341
+ return out;
342
+ },
153
343
  };
154
344
 
155
345
  const anthropicStrategy: ProviderStrategy = {
@@ -195,6 +385,119 @@ const anthropicStrategy: ProviderStrategy = {
195
385
  }
196
386
  return [];
197
387
  },
388
+ // ============ Multimodal ============
389
+ supportsMultimodalInput: true,
390
+ supportsMultimodalOutput: false, // Anthropic 当前不支持图片输出
391
+ buildMultimodalChatPayload: (model, messages, options) => {
392
+ // 抽出 system 消息(Anthropic 顶层 system)
393
+ let systemText: string | undefined;
394
+ const contents = messages
395
+ .filter((m) => {
396
+ if (m.role === 'system') {
397
+ const t = m.parts
398
+ .filter((p) => p.type === 'text')
399
+ .map((p) => p.data)
400
+ .join('\n');
401
+ systemText = (systemText ? systemText + '\n' : '') + t;
402
+ return false;
403
+ }
404
+ return true;
405
+ })
406
+ .map((m) => ({
407
+ role: m.role === 'assistant' ? 'assistant' : 'user',
408
+ content: m.parts.map((p) => {
409
+ if (p.type === 'text') return { type: 'text', text: p.data };
410
+ if (p.type === 'image') {
411
+ // 已是 data URI
412
+ const m = /^data:([^;]+);base64,(.*)$/.exec(p.data);
413
+ if (m) {
414
+ return {
415
+ type: 'image',
416
+ source: {
417
+ type: 'base64',
418
+ media_type: m[1],
419
+ data: m[2],
420
+ },
421
+ };
422
+ }
423
+ // 远程 URL:直接传给 Anthropic(它支持 URL)
424
+ return {
425
+ type: 'image',
426
+ source: { type: 'url', url: p.data },
427
+ };
428
+ }
429
+ // audio: Anthropic 不支持音频输入,跳过(避免报错)
430
+ return { type: 'text', text: '[audio content not supported]' };
431
+ }),
432
+ }));
433
+ const payload: Record<string, unknown> = {
434
+ model,
435
+ messages: contents,
436
+ max_tokens: options.maxTokens || 1024,
437
+ };
438
+ if (systemText) payload.system = systemText;
439
+ if (options.temperature !== undefined) payload.temperature = options.temperature;
440
+ return payload;
441
+ },
442
+ parseMultimodalChatResponse: (data) => {
443
+ // Anthropic 响应: { content: [{ type: 'text', text }, { type: 'image', source: {...} }] }
444
+ const blocks: import('./types').ContentResponsePart[] = [];
445
+ let text = '';
446
+ if (Array.isArray(data.content)) {
447
+ for (const b of data.content) {
448
+ if (b.type === 'text') {
449
+ text += b.text || '';
450
+ } else if (b.type === 'image') {
451
+ const url = b.source?.url;
452
+ if (url) {
453
+ blocks.push({
454
+ type: 'image',
455
+ url,
456
+ mimeType: guessMimeFromDataUri(url) || 'image/png',
457
+ });
458
+ } else if (b.source?.type === 'base64') {
459
+ blocks.push({
460
+ type: 'image',
461
+ url: `data:${b.source.media_type};base64,${b.source.data}`,
462
+ mimeType: b.source.media_type,
463
+ });
464
+ }
465
+ }
466
+ }
467
+ }
468
+ const u = data.usage;
469
+ const content: string | import('./types').ContentResponsePart[] = blocks.length
470
+ ? [{ type: 'text', text }, ...blocks]
471
+ : text;
472
+ return {
473
+ role: 'assistant',
474
+ content,
475
+ usage: u
476
+ ? {
477
+ inputTokens: u.input_tokens,
478
+ outputTokens: u.output_tokens,
479
+ }
480
+ : undefined,
481
+ raw: data,
482
+ finishReason: data.stop_reason,
483
+ };
484
+ },
485
+ parseMultimodalStreamChunk: (data) => {
486
+ const out: import('./types').MultimodalStreamDelta = {};
487
+ if (data.type === 'content_block_delta' && data.delta?.type === 'text_delta') {
488
+ out.text = data.delta.text || '';
489
+ }
490
+ if (data.type === 'message_delta' && data.usage) {
491
+ out.usage = {
492
+ inputTokens: data.usage.input_tokens,
493
+ outputTokens: data.usage.output_tokens,
494
+ };
495
+ }
496
+ if (data.type === 'message_stop') {
497
+ out.finishReason = 'stop';
498
+ }
499
+ return out;
500
+ },
198
501
  };
199
502
 
200
503
  const geminiStrategy: ProviderStrategy = {
@@ -238,6 +541,143 @@ const geminiStrategy: ProviderStrategy = {
238
541
  }
239
542
  return [];
240
543
  },
544
+ // ============ Multimodal ============
545
+ supportsMultimodalInput: true,
546
+ supportsMultimodalOutput: true, // Gemini 支持生图(responseModalities)
547
+ buildMultimodalChatPayload: (_model, messages, options) => {
548
+ // 抽出 system → systemInstruction
549
+ const systemText = messages
550
+ .filter((m) => m.role === 'system')
551
+ .flatMap((m) => m.parts.filter((p) => p.type === 'text').map((p) => ({ text: p.data })));
552
+ const contents = messages
553
+ .filter((m) => m.role !== 'system')
554
+ .map((m) => ({
555
+ role: m.role === 'assistant' ? 'model' : 'user',
556
+ parts: m.parts.map((p) => {
557
+ if (p.type === 'text') return { text: p.data };
558
+ if (p.type === 'image') {
559
+ // 已是 data URI
560
+ const m = /^data:([^;]+);base64,(.*)$/.exec(p.data);
561
+ if (m) {
562
+ return {
563
+ inline_data: { mime_type: m[1], data: m[2] },
564
+ };
565
+ }
566
+ // 远程 URL:使用 file_data
567
+ return {
568
+ file_data: { file_uri: p.data, mime_type: p.mimeType || 'image/jpeg' },
569
+ };
570
+ }
571
+ if (p.type === 'audio') {
572
+ const m = /^data:([^;]+);base64,(.*)$/.exec(p.data);
573
+ if (m) {
574
+ return { inline_data: { mime_type: m[1], data: m[2] } };
575
+ }
576
+ }
577
+ return { text: '' };
578
+ }),
579
+ }));
580
+ const payload: Record<string, unknown> = { contents };
581
+ if (systemText.length) {
582
+ payload.systemInstruction = { parts: systemText };
583
+ }
584
+ const generationConfig: Record<string, unknown> = {};
585
+ if (options.maxTokens) generationConfig.maxOutputTokens = options.maxTokens;
586
+ if (options.temperature !== undefined) generationConfig.temperature = options.temperature;
587
+ // Gemini 生图:responseModalities 必须含 IMAGE
588
+ if (options.outputModalities?.includes('image')) {
589
+ generationConfig.responseModalities = ['TEXT', 'IMAGE'];
590
+ } else if (options.outputModalities?.length) {
591
+ generationConfig.responseModalities = options.outputModalities.map((m) =>
592
+ m.toUpperCase()
593
+ );
594
+ }
595
+ if (Object.keys(generationConfig).length) {
596
+ payload.generationConfig = generationConfig;
597
+ }
598
+ return payload;
599
+ },
600
+ parseMultimodalChatResponse: (data) => {
601
+ const candidate = data.candidates?.[0];
602
+ if (!candidate) {
603
+ return { role: 'assistant', content: '', raw: data };
604
+ }
605
+ const blocks: import('./types').ContentResponsePart[] = [];
606
+ let text = '';
607
+ const parts = candidate.content?.parts || [];
608
+ for (const part of parts) {
609
+ if (typeof part.text === 'string') {
610
+ text += part.text;
611
+ } else if (part.inline_data) {
612
+ const mime = part.inline_data.mime_type || 'image/png';
613
+ blocks.push({
614
+ type: 'image',
615
+ url: `data:${mime};base64,${part.inline_data.data}`,
616
+ mimeType: mime,
617
+ });
618
+ } else if (part.file_data) {
619
+ blocks.push({
620
+ type: 'image',
621
+ url: part.file_data.file_uri,
622
+ mimeType: part.file_data.mime_type,
623
+ });
624
+ }
625
+ }
626
+ const u = data.usageMetadata;
627
+ const content: string | import('./types').ContentResponsePart[] = blocks.length
628
+ ? [{ type: 'text', text }, ...blocks]
629
+ : text;
630
+ return {
631
+ role: 'assistant',
632
+ content,
633
+ usage: u
634
+ ? {
635
+ inputTokens: u.promptTokenCount,
636
+ outputTokens: u.candidatesTokenCount,
637
+ totalTokens: u.totalTokenCount,
638
+ }
639
+ : undefined,
640
+ raw: data,
641
+ finishReason: candidate.finishReason,
642
+ };
643
+ },
644
+ parseMultimodalStreamChunk: (data) => {
645
+ const out: import('./types').MultimodalStreamDelta = {};
646
+ const candidate = data.candidates?.[0];
647
+ if (!candidate) return out;
648
+ let text = '';
649
+ const blocks: NonNullable<import('./types').MultimodalStreamDelta['images']> = [];
650
+ const parts = candidate.content?.parts || [];
651
+ for (const part of parts) {
652
+ if (typeof part.text === 'string') {
653
+ text += part.text;
654
+ } else if (part.inline_data) {
655
+ const mime = part.inline_data.mime_type || 'image/png';
656
+ blocks.push({
657
+ type: 'image',
658
+ url: `data:${mime};base64,${part.inline_data.data}`,
659
+ mimeType: mime,
660
+ });
661
+ } else if (part.file_data) {
662
+ blocks.push({
663
+ type: 'image',
664
+ url: part.file_data.file_uri,
665
+ mimeType: part.file_data.mime_type,
666
+ });
667
+ }
668
+ }
669
+ if (text) out.text = text;
670
+ if (blocks.length) out.images = blocks;
671
+ if (candidate.finishReason) out.finishReason = candidate.finishReason;
672
+ if (data.usageMetadata) {
673
+ out.usage = {
674
+ inputTokens: data.usageMetadata.promptTokenCount,
675
+ outputTokens: data.usageMetadata.candidatesTokenCount,
676
+ totalTokens: data.usageMetadata.totalTokenCount,
677
+ };
678
+ }
679
+ return out;
680
+ },
241
681
  };
242
682
 
243
683
  const cohereStrategy: ProviderStrategy = {
@@ -270,6 +710,74 @@ const cohereStrategy: ProviderStrategy = {
270
710
  }
271
711
  return '';
272
712
  },
713
+ // ============ Multimodal ============
714
+ supportsMultimodalInput: true,
715
+ supportsMultimodalOutput: false, // Cohere v2 当前不支持图片输出
716
+ buildMultimodalChatPayload: (model, messages, options) => {
717
+ const converted = messages.map((m) => ({
718
+ role: m.role,
719
+ content: m.parts.map((p) => {
720
+ if (p.type === 'text') return { type: 'text', text: p.data };
721
+ if (p.type === 'image') {
722
+ // Cohere v2 image 输入:URL 或 base64
723
+ return {
724
+ type: 'image_url',
725
+ image_url: { url: p.data },
726
+ };
727
+ }
728
+ // audio:跳过
729
+ return { type: 'text', text: '[audio content not supported]' };
730
+ }),
731
+ }));
732
+ const payload: Record<string, unknown> = { model, messages: converted };
733
+ if (options.maxTokens) payload.max_tokens = options.maxTokens;
734
+ if (options.temperature !== undefined) payload.temperature = options.temperature;
735
+ return payload;
736
+ },
737
+ parseMultimodalChatResponse: (data) => {
738
+ // Cohere v2 响应: { message: { content: [{ type, text }] } }
739
+ const blocks: import('./types').ContentResponsePart[] = [];
740
+ let text = '';
741
+ const parts = data.message?.content || [];
742
+ for (const p of parts) {
743
+ if (p.type === 'text') {
744
+ text += p.text || '';
745
+ } else if (p.type === 'image_url' || p.type === 'image') {
746
+ const url = p.image_url?.url || p.url;
747
+ if (url) {
748
+ blocks.push({
749
+ type: 'image',
750
+ url,
751
+ mimeType: guessMimeFromDataUri(url) || 'image/png',
752
+ });
753
+ }
754
+ }
755
+ }
756
+ const u = data.usage;
757
+ const content: string | import('./types').ContentResponsePart[] = blocks.length
758
+ ? [{ type: 'text', text }, ...blocks]
759
+ : text;
760
+ return {
761
+ role: 'assistant',
762
+ content,
763
+ usage: u
764
+ ? {
765
+ inputTokens: u.tokens?.input_tokens,
766
+ outputTokens: u.tokens?.output_tokens,
767
+ }
768
+ : undefined,
769
+ raw: data,
770
+ finishReason: data.finish_reason,
771
+ };
772
+ },
773
+ parseMultimodalStreamChunk: (data) => {
774
+ const out: import('./types').MultimodalStreamDelta = {};
775
+ if (data.type === 'content-delta') {
776
+ const text = data.delta?.message?.content?.[0]?.text;
777
+ if (text) out.text = text;
778
+ }
779
+ return out;
780
+ },
273
781
  };
274
782
 
275
783
  export const strategyRegistry: Record<string, ProviderStrategy> = {
@@ -472,3 +980,229 @@ export async function testDirectConnection(options: TestConnectionOptions): Prom
472
980
  message: result.success ? undefined : result.message,
473
981
  };
474
982
  }
983
+
984
+ // ============ Multimodal Chat ============
985
+
986
+ /**
987
+ * 把策略解析后的 ResponseMessage 转换为统一对外的 MultimodalChatResult。
988
+ * 提取 text 与 images,统一错误信息。
989
+ */
990
+ function normalizeParsedToResult(
991
+ parsed: ResponseMessage,
992
+ latencyMs: number,
993
+ fallbackRaw?: unknown
994
+ ): MultimodalChatResult {
995
+ if (typeof parsed.content === 'string') {
996
+ return {
997
+ success: true,
998
+ text: parsed.content,
999
+ usage: parsed.usage,
1000
+ latencyMs,
1001
+ finishReason: parsed.finishReason,
1002
+ raw: parsed.raw ?? fallbackRaw,
1003
+ };
1004
+ }
1005
+ // 多模态数组:拆出 images
1006
+ const images: NonNullable<MultimodalChatResult['images']> = [];
1007
+ let text = '';
1008
+ for (const part of parsed.content) {
1009
+ if (part.type === 'text') {
1010
+ text += part.text;
1011
+ } else if (part.type === 'image') {
1012
+ images.push({
1013
+ type: 'image',
1014
+ url: part.url,
1015
+ b64_json: part.b64_json,
1016
+ mimeType: part.mimeType,
1017
+ });
1018
+ }
1019
+ }
1020
+ return {
1021
+ success: true,
1022
+ text: text || undefined,
1023
+ images: images.length ? images : undefined,
1024
+ usage: parsed.usage,
1025
+ latencyMs,
1026
+ finishReason: parsed.finishReason,
1027
+ raw: parsed.raw ?? fallbackRaw,
1028
+ };
1029
+ }
1030
+
1031
+ /**
1032
+ * 发送多模态聊天请求(输入支持文本/图片/音频;输出可选图片)。
1033
+ * 与 sendDirectChat 并存,是独立 API,不影响纯文本路径。
1034
+ */
1035
+ export async function sendMultimodalChat(
1036
+ options: MultimodalChatOptions
1037
+ ): Promise<MultimodalChatResult> {
1038
+ const strategy = getStrategy(options.apiFormat);
1039
+ if (!strategy.buildMultimodalChatPayload || !strategy.parseMultimodalChatResponse) {
1040
+ return {
1041
+ success: false,
1042
+ message: `协议 ${options.apiFormat} 不支持多模态聊天`,
1043
+ };
1044
+ }
1045
+ if (
1046
+ options.outputModalities?.includes('image') &&
1047
+ !strategy.supportsMultimodalOutput
1048
+ ) {
1049
+ return {
1050
+ success: false,
1051
+ message: `协议 ${options.apiFormat} 不支持图片输出`,
1052
+ };
1053
+ }
1054
+ try {
1055
+ const normalized = await normalizeMessages(options.messages);
1056
+ const endpoint = strategy.getChatEndpoint(options.baseUrl, options.apiKey, options.model);
1057
+ const headers = { ...strategy.buildHeaders(options.apiKey, options.baseUrl), ...(options.extraHeaders || {}) };
1058
+ const payload = strategy.buildMultimodalChatPayload(options.model, normalized, options);
1059
+ const start = performance.now();
1060
+ const controller = new AbortController();
1061
+ const timer = options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : null;
1062
+ try {
1063
+ const resp = await fetch(endpoint, {
1064
+ method: 'POST',
1065
+ headers,
1066
+ body: JSON.stringify(payload),
1067
+ signal: controller.signal,
1068
+ });
1069
+ const latencyMs = Math.round(performance.now() - start);
1070
+ if (!resp.ok) {
1071
+ const err = await resp.json().catch(() => ({}));
1072
+ return {
1073
+ success: false,
1074
+ message: err?.error?.message || `HTTP ${resp.status}`,
1075
+ latencyMs,
1076
+ raw: err,
1077
+ };
1078
+ }
1079
+ const data = await resp.json();
1080
+ const parsed = strategy.parseMultimodalChatResponse(data);
1081
+ return normalizeParsedToResult(parsed, latencyMs, data);
1082
+ } finally {
1083
+ if (timer) clearTimeout(timer);
1084
+ }
1085
+ } catch (e: unknown) {
1086
+ const msg = e instanceof Error ? e.message : '网络错误';
1087
+ return { success: false, message: msg };
1088
+ }
1089
+ }
1090
+
1091
+ /**
1092
+ * 发送多模态聊天请求(SSE 流式)。
1093
+ * 每次收到协议 chunk 都会触发 onDelta 回调:
1094
+ * - state.text 累计完整文本
1095
+ * - state.images 累计完整图片数组(浅拷贝)
1096
+ */
1097
+ export async function sendMultimodalChatStream(
1098
+ options: MultimodalChatStreamOptions
1099
+ ): Promise<MultimodalChatResult> {
1100
+ const strategy = getStrategy(options.apiFormat);
1101
+ if (!strategy.buildMultimodalChatPayload || !strategy.parseMultimodalStreamChunk) {
1102
+ return {
1103
+ success: false,
1104
+ message: `协议 ${options.apiFormat} 不支持多模态流式聊天`,
1105
+ };
1106
+ }
1107
+ if (
1108
+ options.outputModalities?.includes('image') &&
1109
+ !strategy.supportsMultimodalOutput
1110
+ ) {
1111
+ return {
1112
+ success: false,
1113
+ message: `协议 ${options.apiFormat} 不支持图片输出`,
1114
+ };
1115
+ }
1116
+ try {
1117
+ const normalized = await normalizeMessages(options.messages);
1118
+ // 流式:OpenAI 加 stream:true;Anthropic 走 stream endpoint;Gemini 走 streamGenerateContent
1119
+ let endpoint = strategy.getChatEndpoint(options.baseUrl, options.apiKey, options.model);
1120
+ const headers = { ...strategy.buildHeaders(options.apiKey, options.baseUrl), ...(options.extraHeaders || {}) };
1121
+ const payload = strategy.buildMultimodalChatPayload(options.model, normalized, options);
1122
+ (payload as Record<string, unknown>).stream = true;
1123
+ // Gemini 流式端点特殊
1124
+ if (options.apiFormat === 'gemini') {
1125
+ endpoint = endpoint.replace(':generateContent', ':streamGenerateContent');
1126
+ }
1127
+ const start = performance.now();
1128
+ const controller = new AbortController();
1129
+ const timer = options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : null;
1130
+ let accText = '';
1131
+ const accImages: NonNullable<MultimodalChatResult['images']> = [];
1132
+ let finishReason: string | undefined;
1133
+ let usage: MultimodalChatResult['usage'];
1134
+ try {
1135
+ const resp = await fetch(endpoint, {
1136
+ method: 'POST',
1137
+ headers,
1138
+ body: JSON.stringify(payload),
1139
+ signal: controller.signal,
1140
+ });
1141
+ if (!resp.ok) {
1142
+ const err = await resp.json().catch(() => ({}));
1143
+ return {
1144
+ success: false,
1145
+ message: err?.error?.message || `HTTP ${resp.status}`,
1146
+ latencyMs: Math.round(performance.now() - start),
1147
+ raw: err,
1148
+ };
1149
+ }
1150
+ const reader = resp.body?.getReader();
1151
+ if (!reader) {
1152
+ return { success: false, message: '响应无 body', latencyMs: Math.round(performance.now() - start) };
1153
+ }
1154
+ const decoder = new TextDecoder();
1155
+ let buffer = '';
1156
+ while (true) {
1157
+ const { value, done } = await reader.read();
1158
+ if (done) break;
1159
+ buffer += decoder.decode(value, { stream: true });
1160
+ // SSE 帧分割
1161
+ const lines = buffer.split('\n');
1162
+ buffer = lines.pop() || '';
1163
+ for (const line of lines) {
1164
+ const trimmed = line.trim();
1165
+ if (!trimmed || !trimmed.startsWith('data:')) continue;
1166
+ const data = trimmed.slice(5).trim();
1167
+ if (data === '[DONE]') continue;
1168
+ try {
1169
+ const parsed = JSON.parse(data);
1170
+ const chunk = strategy.parseMultimodalStreamChunk!(parsed);
1171
+ if (chunk.text !== undefined) {
1172
+ accText += chunk.text;
1173
+ }
1174
+ if (chunk.images?.length) {
1175
+ // 替换为最新累计数组(按协议通常整块出现)
1176
+ accImages.length = 0;
1177
+ accImages.push(...chunk.images);
1178
+ }
1179
+ if (chunk.finishReason) finishReason = chunk.finishReason;
1180
+ if (chunk.usage) usage = chunk.usage;
1181
+ // 触发回调(深拷贝 images 防止外部修改)
1182
+ options.onDelta({
1183
+ text: accText,
1184
+ images: accImages.length ? accImages.slice() : undefined,
1185
+ finishReason,
1186
+ usage,
1187
+ });
1188
+ } catch {
1189
+ // 忽略解析错误的帧(心跳等)
1190
+ }
1191
+ }
1192
+ }
1193
+ return {
1194
+ success: true,
1195
+ text: accText || undefined,
1196
+ images: accImages.length ? accImages.slice() : undefined,
1197
+ usage,
1198
+ finishReason,
1199
+ latencyMs: Math.round(performance.now() - start),
1200
+ };
1201
+ } finally {
1202
+ if (timer) clearTimeout(timer);
1203
+ }
1204
+ } catch (e: unknown) {
1205
+ const msg = e instanceof Error ? e.message : '网络错误';
1206
+ return { success: false, message: msg };
1207
+ }
1208
+ }