@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spacex110/core",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Framework-agnostic AI Provider Selector core utilities",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,10 +15,28 @@
15
15
  * 10. 事件系统:on / off / emit
16
16
  */
17
17
 
18
- import type { AIConfig, Provider, Model, ModelCapability, StorageAdapter, ApiFormat } from './types';
18
+ import type {
19
+ AIConfig,
20
+ Provider,
21
+ Model,
22
+ ModelCapability,
23
+ StorageAdapter,
24
+ ApiFormat,
25
+ ChatMessage,
26
+ MultimodalChatOptions,
27
+ MultimodalChatStreamOptions,
28
+ MultimodalChatResult,
29
+ MultimodalStreamDelta,
30
+ } from './types';
19
31
  import { getProvider, getAllProviders, getProvidersByFormat } from './providers';
20
32
  import { defaultStorageAdapter } from './storage';
21
- import { sendDirectChat, sendDirectChatStream, testDirectConnection } from './strategies';
33
+ import {
34
+ sendDirectChat,
35
+ sendDirectChatStream,
36
+ sendMultimodalChat,
37
+ sendMultimodalChatStream,
38
+ testDirectConnection,
39
+ } from './strategies';
22
40
  import { fetchModels } from './api';
23
41
  import { getStaticModels } from './models';
24
42
 
@@ -64,6 +82,17 @@ export interface ChannelManagerOptions {
64
82
  proxyUrl?: string;
65
83
  }
66
84
 
85
+ /**
86
+ * 多模态聊天结果(通道管理器层)。
87
+ * 在底层 MultimodalChatResult 上附加 channelId 与 model,便于上层定位调用方。
88
+ */
89
+ export interface MultimodalChatManagerResult extends MultimodalChatResult {
90
+ /** 调用方通道 ID */
91
+ channelId: string;
92
+ /** 实际请求的模型 ID */
93
+ model: string;
94
+ }
95
+
67
96
  /** 通道查询过滤条件 */
68
97
  export interface ChannelFilter {
69
98
  /** 按 Provider ID 过滤 */
@@ -775,6 +804,165 @@ export class AIChannelManager {
775
804
  return chatResult;
776
805
  }
777
806
 
807
+ // ============ Multimodal Chat ============
808
+
809
+ /**
810
+ * 私有:根据聊天结果更新通道状态。复用 chatWith / chatWithStream 的逻辑。
811
+ */
812
+ private updateChannelStatusAfterChat(
813
+ channelId: string,
814
+ success: boolean,
815
+ message: string | undefined
816
+ ): void {
817
+ const index = this.channels.findIndex(c => c.id === channelId);
818
+ if (index < 0) return;
819
+ if (success) {
820
+ this.channels[index].lastUsedAt = Date.now();
821
+ this.channels[index].status = 'connected';
822
+ this.channels[index].lastError = undefined;
823
+ } else {
824
+ this.channels[index].status = 'error';
825
+ this.channels[index].lastError = message;
826
+ }
827
+ this.saveChannel(this.channels[index]);
828
+ }
829
+
830
+ /**
831
+ * 使用当前激活通道发送多模态聊天。
832
+ * messages 支持 string(纯文本)或 ContentPart[](多模态)。
833
+ */
834
+ async chatMultimodal(
835
+ message: ChatMessage | ChatMessage[],
836
+ options?: {
837
+ messages?: ChatMessage[];
838
+ maxTokens?: number;
839
+ outputModalities?: Array<'text' | 'image'>;
840
+ temperature?: number;
841
+ timeoutMs?: number;
842
+ }
843
+ ): Promise<MultimodalChatManagerResult> {
844
+ const channel = this.getActiveChannel();
845
+ if (!channel) {
846
+ throw new Error('No active channel. Please set an active channel first.');
847
+ }
848
+ return this.chatMultimodalWith(channel.id, message, options);
849
+ }
850
+
851
+ /**
852
+ * 使用指定通道发送多模态聊天。
853
+ * 与 chatMultimodal 区别:可显式指定 channelId。
854
+ */
855
+ async chatMultimodalWith(
856
+ channelId: string,
857
+ message: ChatMessage | ChatMessage[],
858
+ options?: {
859
+ messages?: ChatMessage[];
860
+ maxTokens?: number;
861
+ outputModalities?: Array<'text' | 'image'>;
862
+ temperature?: number;
863
+ timeoutMs?: number;
864
+ }
865
+ ): Promise<MultimodalChatManagerResult> {
866
+ const channel = this.getChannel(channelId);
867
+ if (!channel) {
868
+ throw new Error(`Channel not found: ${channelId}`);
869
+ }
870
+ const provider = getProvider(channel.providerId);
871
+ if (!provider) {
872
+ throw new Error(`Provider not found: ${channel.providerId}`);
873
+ }
874
+ const messages: ChatMessage[] = options?.messages
875
+ || (Array.isArray(message) ? message : [message]);
876
+ const result = await sendMultimodalChat({
877
+ apiFormat: channel.apiFormat,
878
+ baseUrl: channel.baseUrl || provider.baseUrl,
879
+ apiKey: channel.apiKey,
880
+ model: channel.model,
881
+ messages,
882
+ maxTokens: options?.maxTokens,
883
+ outputModalities: options?.outputModalities,
884
+ temperature: options?.temperature,
885
+ timeoutMs: options?.timeoutMs,
886
+ });
887
+ const out: MultimodalChatManagerResult = {
888
+ ...result,
889
+ channelId: channel.id,
890
+ model: channel.model,
891
+ };
892
+ this.updateChannelStatusAfterChat(channel.id, result.success, result.message);
893
+ this.emit('chat', out);
894
+ return out;
895
+ }
896
+
897
+ /**
898
+ * 使用当前激活通道发送多模态流式聊天。
899
+ * onDelta 每次收到「累计完整状态」({ text, images })。
900
+ */
901
+ async chatMultimodalStream(
902
+ message: ChatMessage | ChatMessage[],
903
+ options: {
904
+ messages?: ChatMessage[];
905
+ maxTokens?: number;
906
+ outputModalities?: Array<'text' | 'image'>;
907
+ temperature?: number;
908
+ timeoutMs?: number;
909
+ onDelta: (state: MultimodalStreamDelta) => void;
910
+ }
911
+ ): Promise<MultimodalChatManagerResult> {
912
+ const channel = this.getActiveChannel();
913
+ if (!channel) {
914
+ throw new Error('No active channel. Please set an active channel first.');
915
+ }
916
+ return this.chatMultimodalStreamWith(channel.id, message, options);
917
+ }
918
+
919
+ /**
920
+ * 使用指定通道发送多模态流式聊天。
921
+ */
922
+ async chatMultimodalStreamWith(
923
+ channelId: string,
924
+ message: ChatMessage | ChatMessage[],
925
+ options: {
926
+ messages?: ChatMessage[];
927
+ maxTokens?: number;
928
+ outputModalities?: Array<'text' | 'image'>;
929
+ temperature?: number;
930
+ timeoutMs?: number;
931
+ onDelta: (state: MultimodalStreamDelta) => void;
932
+ }
933
+ ): Promise<MultimodalChatManagerResult> {
934
+ const channel = this.getChannel(channelId);
935
+ if (!channel) {
936
+ throw new Error(`Channel not found: ${channelId}`);
937
+ }
938
+ const provider = getProvider(channel.providerId);
939
+ if (!provider) {
940
+ throw new Error(`Provider not found: ${channel.providerId}`);
941
+ }
942
+ const messages: ChatMessage[] = options.messages
943
+ || (Array.isArray(message) ? message : [message]);
944
+ const result = await sendMultimodalChatStream({
945
+ apiFormat: channel.apiFormat,
946
+ baseUrl: channel.baseUrl || provider.baseUrl,
947
+ apiKey: channel.apiKey,
948
+ model: channel.model,
949
+ messages,
950
+ maxTokens: options.maxTokens,
951
+ outputModalities: options.outputModalities,
952
+ temperature: options.temperature,
953
+ timeoutMs: options.timeoutMs,
954
+ onDelta: options.onDelta,
955
+ });
956
+ const out: MultimodalChatManagerResult = {
957
+ ...result,
958
+ channelId: channel.id,
959
+ model: channel.model,
960
+ };
961
+ this.updateChannelStatusAfterChat(channel.id, result.success, result.message);
962
+ this.emit('chat', out);
963
+ return out;
964
+ }
965
+
778
966
  /** 测试指定通道的连接 */
779
967
  async testChannel(id: string): Promise<{ success: boolean; latencyMs?: number; message?: string }> {
780
968
  const channel = this.getChannel(id);
package/src/index.ts CHANGED
@@ -27,6 +27,17 @@ export type {
27
27
  FetcherActionType,
28
28
  // Model capability
29
29
  ModelCapability,
30
+ // Multimodal types
31
+ ContentPart,
32
+ ChatMessage,
33
+ NormalizedPart,
34
+ NormalizedMessage,
35
+ ContentResponsePart,
36
+ ResponseMessage,
37
+ MultimodalChatOptions,
38
+ MultimodalChatStreamOptions,
39
+ MultimodalChatResult,
40
+ MultimodalStreamDelta,
30
41
  } from './types';
31
42
 
32
43
  export {
@@ -78,14 +89,20 @@ export {
78
89
  type ProviderStrategy,
79
90
  strategyRegistry,
80
91
  getStrategy,
92
+ guessMimeFromDataUri,
81
93
  sendDirectChat,
82
94
  sendDirectChatStream,
95
+ sendMultimodalChat,
96
+ sendMultimodalChatStream,
83
97
  testDirectConnection,
84
98
  type DirectChatOptions,
85
99
  type DirectChatStreamOptions,
86
100
  type DirectChatResult,
87
101
  } from './strategies';
88
102
 
103
+ // Multimodal Normalize
104
+ export { normalizeMessages, normalizePart, fetchRemoteToDataUri } from './multimodal-normalize';
105
+
89
106
  // Channel Manager (Headless)
90
107
  export {
91
108
  AIChannelManager,
@@ -93,5 +110,6 @@ export {
93
110
  type ChannelManagerOptions,
94
111
  type ChannelFilter,
95
112
  type ChatResult,
113
+ type MultimodalChatManagerResult,
96
114
  type ChannelEventType,
97
115
  } from './channel-manager';
@@ -0,0 +1,170 @@
1
+ /**
2
+ * 多模态消息标准化层
3
+ * 把调用方传入的 ChatMessage[](content: string | ContentPart[])转换为各协议策略都能消费的 NormalizedMessage[]。
4
+ *
5
+ * 关键设计:
6
+ * - 文本直接透传
7
+ * - 图片/音频统一转换为 data URI(含 MIME 推断)
8
+ * - HTTP/HTTPS URL 远程拉取并转为 base64 data URI(失败时保留 URL 透传并在 meta 中标记)
9
+ * - 裸 base64 视为缺少 MIME,按输入块的 type 推测
10
+ */
11
+
12
+ import type {
13
+ ChatMessage,
14
+ ContentPart,
15
+ NormalizedMessage,
16
+ NormalizedPart,
17
+ } from './types';
18
+
19
+ /**
20
+ * 把一个 ContentPart 转成 NormalizedPart。
21
+ * - text:透传
22
+ * - image_url / image / inline_data:归一化为 image + data URI
23
+ * - audio:归一化为 audio + data URI
24
+ */
25
+ export async function normalizePart(p: ContentPart): Promise<NormalizedPart> {
26
+ // 纯文本块
27
+ if (p.type === 'text') {
28
+ return { type: 'text', data: p.text };
29
+ }
30
+
31
+ let dataUri: string;
32
+ let mime: string | undefined;
33
+ let meta: Record<string, unknown> | undefined;
34
+
35
+ // OpenAI 风格 image_url
36
+ if (p.type === 'image_url') {
37
+ dataUri = p.image_url.url;
38
+ if (p.image_url.detail) meta = { detail: p.image_url.detail };
39
+ }
40
+ // Anthropic 风格 image(base64 或 url)
41
+ else if (p.type === 'image') {
42
+ if (p.source.type === 'base64') {
43
+ dataUri = `data:${p.source.media_type};base64,${p.source.data}`;
44
+ mime = p.source.media_type;
45
+ } else {
46
+ // source.type === 'url'
47
+ dataUri = (p.source as { type: 'url'; url: string }).url;
48
+ }
49
+ }
50
+ // Gemini 风格 inline_data
51
+ else if (p.type === 'inline_data') {
52
+ dataUri = `data:${p.inline_data.mime_type};base64,${p.inline_data.data}`;
53
+ mime = p.inline_data.mime_type;
54
+ }
55
+ // OpenAI 风格 input_audio
56
+ else if (p.type === 'audio') {
57
+ dataUri = `data:audio/${p.input_audio.format};base64,${p.input_audio.data}`;
58
+ mime = `audio/${p.input_audio.format}`;
59
+ }
60
+ else {
61
+ throw new Error(`未知的 ContentPart 类型: ${(p as { type: string }).type}`);
62
+ }
63
+
64
+ // HTTP/HTTPS 远程 URL:尝试拉取并转换为 data URI
65
+ if (/^https?:\/\//i.test(dataUri)) {
66
+ const remote = await fetchRemoteToDataUri(dataUri);
67
+ return {
68
+ type: 'image',
69
+ data: remote.dataUri,
70
+ mimeType: remote.mimeType,
71
+ meta: { ...meta, remoteUrlOnly: remote.fallback ? dataUri : undefined },
72
+ };
73
+ }
74
+
75
+ // data URI:直接解析 MIME
76
+ if (/^data:/i.test(dataUri)) {
77
+ const m = /^data:([^;,]+)/.exec(dataUri);
78
+ return {
79
+ type: p.type === 'audio' ? 'audio' : 'image',
80
+ data: dataUri,
81
+ mimeType: mime || m?.[1],
82
+ meta,
83
+ };
84
+ }
85
+
86
+ // 兜底:当作裸 base64 + 推断 MIME
87
+ const fallbackMime =
88
+ mime || (p.type === 'audio' ? 'audio/mpeg' : 'image/png');
89
+ return {
90
+ type: p.type === 'audio' ? 'audio' : 'image',
91
+ data: `data:${fallbackMime};base64,${dataUri}`,
92
+ mimeType: fallbackMime,
93
+ meta,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * 远程图片 → data URI。
99
+ * 拉取失败时返回原始 URL(标记 fallback=true),让协议策略自行决定是否接受 URL。
100
+ */
101
+ export async function fetchRemoteToDataUri(
102
+ url: string
103
+ ): Promise<{ dataUri: string; mimeType?: string; fallback: boolean }> {
104
+ try {
105
+ const resp = await fetch(url);
106
+ if (!resp.ok) {
107
+ return { dataUri: url, fallback: true };
108
+ }
109
+ const mimeType = resp.headers.get('content-type') || undefined;
110
+ const buf = await resp.arrayBuffer();
111
+ // 在 Node 与浏览器中都能用的 base64 转换
112
+ const b64 = arrayBufferToBase64(buf);
113
+ const dataUri = `data:${mimeType || 'image/png'};base64,${b64}`;
114
+ return { dataUri, mimeType, fallback: false };
115
+ } catch {
116
+ return { dataUri: url, fallback: true };
117
+ }
118
+ }
119
+
120
+ /**
121
+ * ArrayBuffer → base64。Node 与浏览器通用。
122
+ */
123
+ function arrayBufferToBase64(buf: ArrayBuffer): string {
124
+ // Node 环境:使用 globalThis 上的 Buffer(避免引入 @types/node)
125
+ const g = globalThis as unknown as {
126
+ Buffer?: { from(data: ArrayBuffer): { toString(encoding: string): string } };
127
+ btoa?: (data: string) => string;
128
+ };
129
+ if (g.Buffer && typeof g.Buffer.from === 'function') {
130
+ return g.Buffer.from(buf).toString('base64');
131
+ }
132
+ // 浏览器:用 btoa
133
+ let binary = '';
134
+ const bytes = new Uint8Array(buf);
135
+ const chunk = 0x8000;
136
+ for (let i = 0; i < bytes.length; i += chunk) {
137
+ binary += String.fromCharCode.apply(
138
+ null,
139
+ Array.from(bytes.subarray(i, i + chunk)) as number[]
140
+ );
141
+ }
142
+ return g.btoa ? g.btoa(binary) : binary;
143
+ }
144
+
145
+ /**
146
+ * 把 ChatMessage[] 转为 NormalizedMessage[]。
147
+ *
148
+ * 规则:
149
+ * - content: string → 一个 text part
150
+ * - content: ContentPart[] → 逐个 normalizePart
151
+ * - role: 'tool' → 在统一层视为 'user'(避免各策略重复处理 tool role)
152
+ */
153
+ export async function normalizeMessages(
154
+ messages: ChatMessage[]
155
+ ): Promise<NormalizedMessage[]> {
156
+ const out: NormalizedMessage[] = [];
157
+ for (const m of messages) {
158
+ const role: NormalizedMessage['role'] = m.role === 'tool' ? 'user' : m.role;
159
+ if (typeof m.content === 'string') {
160
+ out.push({ role, parts: [{ type: 'text', data: m.content }] });
161
+ continue;
162
+ }
163
+ const parts: NormalizedPart[] = [];
164
+ for (const p of m.content) {
165
+ parts.push(await normalizePart(p));
166
+ }
167
+ out.push({ role, parts });
168
+ }
169
+ return out;
170
+ }
package/src/providers.ts CHANGED
@@ -133,7 +133,7 @@ export const PROVIDERS: Record<string, Provider> = {
133
133
  baseUrl: 'https://api.minimax.io/v1',
134
134
  needsApiKey: true,
135
135
  apiFormat: 'openai',
136
- supportsModelsApi: false,
136
+ supportsModelsApi: true,
137
137
  icon: `${ICON_CDN_BASE}/minimax.svg`,
138
138
  },
139
139
  // MiniMax 中文站(同服务商,独立域名;模型列表与海外版一致)
@@ -143,7 +143,7 @@ export const PROVIDERS: Record<string, Provider> = {
143
143
  baseUrl: 'https://api.minimaxi.com/v1',
144
144
  needsApiKey: true,
145
145
  apiFormat: 'openai',
146
- supportsModelsApi: false,
146
+ supportsModelsApi: true,
147
147
  icon: `${ICON_CDN_BASE}/minimax.svg`,
148
148
  },
149
149
  [PROVIDER_ID.XAI]: {