my-ai-chat-framework 2.0.0 → 3.0.0

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.
@@ -1,119 +1,243 @@
1
1
  /**
2
2
  * 工具调用插件
3
- * 拦截消息中的 tool_calls,执行注册的工具,并将结果插入对话,然后继续请求
3
+ * 功能:拦截助手消息中的 tool_calls,执行对应的工具,将结果作为 tool 消息加入对话,
4
+ * 然后自动继续对话(通过 sendExisting / sendExistingStream),直到没有新的工具调用。
5
+ *
6
+ * 设计要点:
7
+ * - 支持普通请求和流式请求(通过 isStream 标志区分)
8
+ * - 支持多次工具调用循环(maxIterations 防止无限循环)
9
+ * - 并行执行工具
10
+ * - 工具执行失败时,仍然返回错误信息给 AI,而不是中断整个流程
11
+ * - 触发 tool-error 事件,方便用户监听工具执行异常
12
+ *
13
+ * 注意:请使用 createToolCallingPlugin() 工厂创建实例。
14
+ * 默认导出的 toolCallingPlugin 是兼容用的模块级单例,装到多个 ChatService
15
+ * 实例会互相覆盖(executor 表、工具定义、配置),新代码不要直接用单例。
4
16
  */
5
- export const toolCallingPlugin = {
6
- name: 'tool-calling',
7
- maxIterations: 5, // 防止无限循环
8
-
9
- install(chatService) {
10
- this.chatService = chatService;
11
- this._tools = new Map(); // 工具名 -> { executor, definition }
12
-
13
- // 覆盖 registerTool 方法
14
- chatService.registerTool = (name, description, executor) => {
15
- this._tools.set(name, { executor, description });
16
- // 可选:同时生成工具定义,以便在请求中告知 AI
17
- // 这里简化,依赖适配器在请求中自动包含工具定义(如果需要)
18
- return chatService;
19
- };
20
-
21
- // 拦截发送流程:在发送前注入工具定义(如果需要)
22
- // 在流式和非流式响应后检测 tool_calls
23
- const originalSend = chatService.send;
24
- chatService.send = async (input) => {
25
- return this._handleWithTools(originalSend, input, false);
26
- };
27
-
28
- const originalStream = chatService.stream;
29
- chatService.stream = async (input, onProgress, onDone) => {
30
- return this._handleWithTools(originalStream, input, true, onProgress, onDone);
31
- };
32
- },
33
-
34
- async _handleWithTools(originalMethod, input, isStream, onProgress, onDone) {
35
- const self = this;
36
- const chat = this.chatService;
37
- let iteration = 0;
38
-
39
- // 包装回调,用于递归处理
40
- async function processResponse(initialResponse) {
41
- let lastResponse = initialResponse;
42
- while (iteration < self.maxIterations) {
43
- // 检查是否有工具调用
44
- const toolCalls = lastResponse?.toolCalls;
45
- if (!toolCalls || toolCalls.length === 0) break;
46
-
47
- iteration++;
48
- // 执行所有工具
49
- const toolResults = [];
50
- for (const call of toolCalls) {
51
- const toolName = call.function?.name;
52
- const args = JSON.parse(call.function?.arguments || '{}');
53
- const tool = self._tools.get(toolName);
54
- if (!tool) {
55
- console.warn(`未找到工具: ${toolName}`);
56
- toolResults.push({ tool_call_id: call.id, error: `工具 ${toolName} 未注册`, success: false });
57
- continue;
58
- }
59
- try {
60
- const result = await tool.executor(args);
61
- toolResults.push({
62
- tool_call_id: call.id,
63
- content: typeof result === 'string' ? result : JSON.stringify(result),
64
- success: true
65
- });
66
- } catch (err) {
67
- console.error(`工具 ${toolName} 执行失败:`, err);
68
- toolResults.push({ tool_call_id: call.id, error: err.message, success: false });
17
+ export const toolCallingPlugin = createToolCallingPlugin();
18
+
19
+ /**
20
+ * 创建工具调用插件实例(工厂)。
21
+ * @param {Object} [options] — { timeout, maxIterations }
22
+ */
23
+ export function createToolCallingPlugin(options = {}) {
24
+ return {
25
+ name: 'tool-calling',
26
+ maxIterations: options.maxIterations || 5, // 最大工具调用循环次数,防止无限循环
27
+ _options: { ...options },
28
+ _tools: new Map(), // 工具名 -> { executor, description }
29
+ _toolDefs: [], // OpenAI 格式的工具定义(按注册顺序)
30
+ chatService: null,
31
+
32
+ /**
33
+ * 安装插件:挂 registerTool、注册 beforeRequest 钩子注入工具定义、注册响应处理钩子
34
+ * @param {ChatService} chatService - ChatService 实例
35
+ * @param {Object} [options] - 安装时配置(合并进 _options)
36
+ */
37
+ install(chatService, options = {}) {
38
+ this._options = { ...this._options, ...options };
39
+ this.chatService = chatService;
40
+
41
+ // 每次请求前把工具定义注入到请求配置(v3.0:beforeSend 车间,不再用 _hooks 钩子)
42
+ chatService.pipe({
43
+ name: 'tool-calling-inject',
44
+ phase: 'beforeSend',
45
+ run: ({ config }) => {
46
+ if (this._toolDefs.length) config.tools = this._toolDefs;
47
+ }
48
+ });
49
+
50
+ /**
51
+ * 注册工具
52
+ * @param {string} name - 工具名称(唯一标识)
53
+ * @param {string} description - 工具描述(告诉 AI 何时调用)
54
+ * @param {Function} executor - 异步执行函数,接收参数对象,返回结果(字符串或对象)
55
+ * @param {Object} parameters - JSON Schema 参数定义(可选,默认为空对象)
56
+ * @returns {ChatService} 返回 chatService 实例,支持链式调用
57
+ */
58
+ chatService.registerTool = (name, description, executor, parameters = {}) => {
59
+ // 保存执行器
60
+ this._tools.set(name, { executor, description });
61
+
62
+ // 构建 OpenAI 兼容的工具定义
63
+ const toolDefinition = {
64
+ type: 'function',
65
+ function: {
66
+ name,
67
+ description,
68
+ parameters: {
69
+ type: 'object',
70
+ properties: parameters,
71
+ required: Object.keys(parameters).filter(key => parameters[key]?.required)
72
+ }
69
73
  }
74
+ };
75
+ // 避免重复添加相同工具
76
+ const existing = this._toolDefs.find(t => t.function.name === name);
77
+ if (!existing) {
78
+ this._toolDefs.push(toolDefinition);
70
79
  }
80
+ return chatService;
81
+ };
71
82
 
72
- // 添加工具结果消息
73
- for (const tr of toolResults) {
74
- chat.messages.addTool(tr.content || tr.error, tr.tool_call_id);
83
+ // 响应后处理(v3.0:afterSend 车间,不再占用 ChatService._processResponse 单座槽位)
84
+ chatService.pipe({
85
+ name: 'tool-calling-loop',
86
+ phase: 'afterSend',
87
+ run: async (ctx) => {
88
+ ctx.result = await this._handleWithTools(
89
+ ctx.result,
90
+ ctx.isStream,
91
+ ctx.onProgress || undefined,
92
+ ctx.onDone || undefined
93
+ );
75
94
  }
95
+ });
96
+ },
97
+
98
+ /**
99
+ * 核心处理逻辑:检查响应 → 执行工具 → 继续请求 → 循环
100
+ * @param {Object} initialResponse - 初始 AI 响应
101
+ * @param {boolean} isStream - 是否流式
102
+ * @param {Function} onProgress - 流式进度回调
103
+ * @param {Function} onDone - 流式完成回调
104
+ * @returns {Promise<Object>} 最终 AI 响应
105
+ */
106
+ async _handleWithTools(initialResponse, isStream, onProgress, onDone) {
107
+ const self = this;
108
+ const chat = this.chatService;
109
+ let iteration = 0;
76
110
 
77
- // 再次请求
78
- if (isStream) {
79
- // 流式重新请求:需要重新构建请求体
80
- const userMsg = chat.messages.getLast(); // 实际上应该重新生成请求
81
- // 这里简化:直接调用原 stream 方法,但需要避免循环
82
- // 实际实现需更细致,我们这里仅示意
83
- await new Promise((resolve) => {
84
- originalStream.call(chat, null, (chunk) => {
85
- if (onProgress) onProgress(chunk);
86
- lastResponse = chunk;
87
- }, (final) => {
88
- lastResponse = final;
89
- resolve();
111
+ /**
112
+ * 递归处理工具调用
113
+ * @param {Object} initialResponse - 初始 AI 响应(可能是第一次请求的响应)
114
+ * @returns {Promise<Object>} 最终 AI 响应(不含 tool_calls)
115
+ */
116
+ async function processResponse(initialResponse) {
117
+ let lastResponse = initialResponse;
118
+ while (iteration < self.maxIterations) {
119
+ // 检查 AI 返回的消息中是否包含工具调用请求
120
+ const toolCalls = lastResponse?.toolCalls;
121
+ if (!toolCalls || toolCalls.length === 0) break;
122
+
123
+ iteration++;
124
+ // 并行执行所有工具调用(彼此独立,不会因一个失败而阻塞其他)
125
+ const toolResults = await Promise.all(
126
+ toolCalls.map(async (call) => {
127
+ const toolName = call.function?.name;
128
+
129
+ // 解析参数(JSON.parse 可能失败,需单独 try/catch)
130
+ let args;
131
+ try {
132
+ args = JSON.parse(call.function?.arguments || '{}');
133
+ } catch (parseErr) {
134
+ chat.emit('tool-error', {
135
+ toolName: toolName || 'unknown',
136
+ error: parseErr,
137
+ toolCallId: call.id,
138
+ stage: 'parse',
139
+ timestamp: Date.now()
140
+ });
141
+ return {
142
+ tool_call_id: call.id,
143
+ error: `参数解析失败: ${parseErr.message}`,
144
+ success: false
145
+ };
146
+ }
147
+
148
+ const tool = self._tools.get(toolName);
149
+ if (!tool) {
150
+ chat.emit('tool-error', {
151
+ toolName,
152
+ error: new Error(`Tool not registered: ${toolName}`),
153
+ toolCallId: call.id,
154
+ stage: 'lookup',
155
+ timestamp: Date.now()
156
+ });
157
+ return {
158
+ tool_call_id: call.id,
159
+ error: `工具 ${toolName} 未注册`,
160
+ success: false
161
+ };
162
+ }
163
+
164
+ try {
165
+ // 工具执行超时控制(工厂 options.timeout 优先,兼容旧 config.toolTimeout)
166
+ const toolTimeout = self._options.timeout ?? chat.config.toolTimeout;
167
+ let execPromise = tool.executor(args);
168
+ if (toolTimeout && typeof toolTimeout === 'number' && toolTimeout > 0) {
169
+ const timeoutErr = new Error(`工具 ${toolName} 执行超时 (${toolTimeout}ms)`);
170
+ timeoutErr.name = 'ToolTimeoutError';
171
+ const timeoutPromise = new Promise((_, reject) =>
172
+ setTimeout(() => reject(timeoutErr), toolTimeout)
173
+ );
174
+ execPromise = Promise.race([execPromise, timeoutPromise]);
175
+ }
176
+
177
+ const result = await execPromise;
178
+ chat.emit('tool-success', {
179
+ toolName,
180
+ result,
181
+ toolCallId: call.id,
182
+ timestamp: Date.now()
183
+ });
184
+ return {
185
+ tool_call_id: call.id,
186
+ content: typeof result === 'string' ? result : JSON.stringify(result),
187
+ success: true
188
+ };
189
+ } catch (err) {
190
+ const stage = err.name === 'ToolTimeoutError' ? 'timeout' : 'execute';
191
+ chat.emit('tool-error', {
192
+ toolName,
193
+ error: err,
194
+ toolCallId: call.id,
195
+ stage,
196
+ timestamp: Date.now()
197
+ });
198
+ return {
199
+ tool_call_id: call.id,
200
+ error: err.message,
201
+ success: false
202
+ };
203
+ }
204
+ })
205
+ );
206
+
207
+ // 将工具执行结果作为 tool 消息添加到对话历史
208
+ for (const tr of toolResults) {
209
+ // 注意:addTool 的第二个参数是 tool_call_id
210
+ // 如果工具执行失败且没有内容,则传递错误信息作为 content
211
+ const content = tr.success ? tr.content : (tr.error || '工具执行失败');
212
+ chat.messages.addTool(content, tr.tool_call_id);
213
+ }
214
+
215
+ // 再次请求 AI,获取基于工具结果的回复
216
+ if (isStream) {
217
+ // 流式继续:使用 sendExistingStream
218
+ await new Promise((resolve) => {
219
+ chat.sendExistingStream(
220
+ (chunk) => {
221
+ if (onProgress) onProgress(chunk);
222
+ lastResponse = chunk;
223
+ },
224
+ (final) => {
225
+ lastResponse = final;
226
+ resolve();
227
+ }
228
+ );
90
229
  });
91
- });
92
- } else {
93
- const reqBody = chat._adapter.buildRequest(chat.messages.getAll(), chat.config);
94
- const apiResp = await chat._adapter.send(reqBody, chat.config);
95
- lastResponse = chat._adapter.parseResponse(apiResp);
230
+ } else {
231
+ // 非流式继续:使用 sendExisting
232
+ lastResponse = await chat.sendExisting();
233
+ }
96
234
  }
235
+ return lastResponse;
97
236
  }
98
- return lastResponse;
99
- }
100
237
 
101
- if (isStream) {
102
- // 流式模式
103
- let finalMessage;
104
- await originalMethod.call(chat, input,
105
- (chunk) => {
106
- if (onProgress) onProgress(chunk);
107
- },
108
- async (final) => {
109
- finalMessage = await processResponse(final);
110
- if (onDone) onDone(finalMessage);
111
- }
112
- );
113
- } else {
114
- const resp = await originalMethod.call(chat, input);
115
- const final = await processResponse(resp);
238
+ // 初始响应已由 ChatService 发出,直接进入工具调用循环
239
+ const final = await processResponse(initialResponse);
116
240
  return final;
117
241
  }
118
- }
119
- };
242
+ };
243
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * MessageFormatter — 可注册的消息格式转换器
3
+ *
4
+ * 职责:
5
+ * 1. 内置 OpenA I兼容格式的转换逻辑
6
+ * 2. 支持 register(name, fn) 注册自定义格式(如 Anthropic、Gemini 等)
7
+ * 3. 统一处理 capabilities 过滤(reasoning、vision 等)
8
+ * 4. 所有适配器通过此工具获取 API 消息数组,消除重复代码
9
+ *
10
+ * 用法:
11
+ * import { MessageFormatter } from 'my-ai-chat-framework';
12
+ *
13
+ * // 使用内置格式
14
+ * const msgs = MessageFormatter.format({
15
+ * messages, systemPrompts, capabilities, resolveImage
16
+ * }); // 默认 'openai'
17
+ *
18
+ * // 注册自定义格式
19
+ * MessageFormatter.register('anthropic', ({ messages, systemPrompts, capabilities }) => {
20
+ * // 返回 Anthropic 格式的消息数组
21
+ * });
22
+ */
23
+
24
+ import { isString } from './typeCheck.js';
25
+
26
+ // ====== 内置格式 ======
27
+
28
+ const IS_URL = /^https?:\/\//i;
29
+
30
+ function defaultResolveImage(imageId) {
31
+ if (IS_URL.test(imageId)) return { url: imageId };
32
+ return null;
33
+ }
34
+
35
+ function buildMultimodalContent(textContent, images, resolveImage) {
36
+ const content = [{ type: 'text', text: textContent }];
37
+ for (const ref of images) {
38
+ const resolved = resolveImage(ref);
39
+ if (!resolved) continue;
40
+ if (resolved.url) {
41
+ content.push({ type: 'image_url', image_url: { url: resolved.url } });
42
+ } else if (resolved.data) {
43
+ const mime = resolved.mimeType || 'image/png';
44
+ content.push({ type: 'image_url', image_url: { url: `data:${mime};base64,${resolved.data}` } });
45
+ }
46
+ }
47
+ return content;
48
+ }
49
+
50
+ /** OpenAI 兼容格式 */
51
+ function toOpenAI({ messages, systemPrompts, capabilities, resolveImage }) {
52
+ const rImg = typeof resolveImage === 'function' ? resolveImage : defaultResolveImage;
53
+ const result = [];
54
+
55
+ // 1) system 置顶
56
+ for (const sp of systemPrompts) {
57
+ if (sp?.content && isString(sp.content) && sp.content.trim()) {
58
+ result.push({ role: 'system', content: sp.content.trim() });
59
+ }
60
+ }
61
+
62
+ // 2) 识别底部连续 ephemeral(只保留这些临时消息,其他 ephemeral 忽略)
63
+ let ephemEnd = messages.length;
64
+ for (let i = messages.length - 1; i >= 0; i--) {
65
+ if (messages[i]._ephemeral) ephemEnd = i;
66
+ else break;
67
+ }
68
+
69
+ // 3) 过滤 + 字段映射
70
+ for (let i = 0; i < messages.length; i++) {
71
+ const msg = messages[i];
72
+
73
+ // ephemeral 消息:仅底部连续的保留,其余跳过
74
+ if (msg._ephemeral) {
75
+ if (i < ephemEnd) continue;
76
+ // 底部 ephemeral 按原始 role 处理,不加 _ephemeral 标记
77
+ }
78
+
79
+ // 非底部 ephemeral 的正常消息仍保留原有过滤
80
+ if (msg.role === 'system' && !msg._ephemeral) continue;
81
+
82
+ if (msg.role === 'tool') {
83
+ result.push({ role: 'tool', content: msg.content || '', tool_call_id: msg.toolCallId });
84
+ continue;
85
+ }
86
+
87
+ if (msg.role === 'assistant' && msg.toolCalls?.length) {
88
+ const entry = { role: 'assistant', content: msg.content || '', tool_calls: msg.toolCalls };
89
+ if (msg.prefix) entry.prefix = true;
90
+ if (capabilities?.reasoning && msg.reasoningContent) {
91
+ entry.reasoning_content = msg.reasoningContent;
92
+ }
93
+ result.push(entry);
94
+ continue;
95
+ }
96
+
97
+ // 常规消息
98
+ const hasText = msg.content && isString(msg.content) && msg.content.trim();
99
+ const hasImages = msg.images && Array.isArray(msg.images) && msg.images.length > 0;
100
+ if (!hasText && !hasImages) {
101
+ if (msg.role === 'assistant' && msg.prefix) {
102
+ const prefixEntry = { role: 'assistant', content: '', prefix: true };
103
+ if (capabilities?.reasoning && msg.reasoningContent) {
104
+ prefixEntry.reasoning_content = msg.reasoningContent;
105
+ }
106
+ result.push(prefixEntry);
107
+ }
108
+ continue;
109
+ }
110
+
111
+ const entry = { role: msg.role };
112
+
113
+ if (hasImages) {
114
+ if (!capabilities?.vision) {
115
+ throw new Error(
116
+ '[MessageFormatter] 消息包含图片但模型不支持视觉(capabilities.vision=false)。' +
117
+ '请切换模型或移除图片。'
118
+ );
119
+ }
120
+ entry.content = buildMultimodalContent(msg.content || '', msg.images, rImg);
121
+ } else {
122
+ entry.content = msg.content;
123
+ }
124
+
125
+ if (msg.prefix) entry.prefix = true;
126
+ // 常规 assistant 不回传 reasoningContent——API 自己会重新推理
127
+ result.push(entry);
128
+ }
129
+
130
+ return result;
131
+ }
132
+
133
+ // ====== 格式化器 ======
134
+
135
+ export const MessageFormatter = {
136
+ _formats: new Map([
137
+ ['openai', toOpenAI]
138
+ ]),
139
+
140
+ /**
141
+ * 注册自定义格式
142
+ * @param {string} name — 格式名称(如 'anthropic', 'gemini')
143
+ * @param {Function} fn — 转换函数,接收 { messages, systemPrompts, capabilities, resolveImage },返回消息数组
144
+ */
145
+ register(name, fn) {
146
+ if (!name || typeof name !== 'string' || !name.trim()) {
147
+ throw new Error('[MessageFormatter] 格式名称必须是非空字符串');
148
+ }
149
+ if (typeof fn !== 'function') {
150
+ throw new Error('[MessageFormatter] 转换函数必须是 function');
151
+ }
152
+ this._formats.set(name.trim(), fn);
153
+ },
154
+
155
+ /**
156
+ * 移除自定义格式(内置格式不可移除)
157
+ * @param {string} name
158
+ */
159
+ unregister(name) {
160
+ if (name === 'openai') {
161
+ throw new Error('[MessageFormatter] 内置格式 "openai" 不可移除');
162
+ }
163
+ this._formats.delete(name);
164
+ },
165
+
166
+ /**
167
+ * 列出所有已注册的格式名称
168
+ * @returns {Array<string>}
169
+ */
170
+ listFormats() {
171
+ return [...this._formats.keys()];
172
+ },
173
+
174
+ /**
175
+ * 按指定格式转换消息
176
+ * @param {Object} options
177
+ * @param {Array} options.messages — 内部消息列表
178
+ * @param {Array} [options.systemPrompts=[]] — 启用的 system prompt
179
+ * @param {Object} [options.capabilities={}] — 模型能力标签
180
+ * @param {Function} [options.resolveImage] — 图片解析钩子
181
+ * @param {string} [options.format='openai'] — 目标格式名称
182
+ * @returns {Array<Object>}
183
+ */
184
+ format(options = {}) {
185
+ const formatName = options.format || 'openai';
186
+ const fn = this._formats.get(formatName);
187
+ if (!fn) {
188
+ throw new Error(
189
+ `[MessageFormatter] 未知格式 "${formatName}"。可用格式: ${[...this._formats.keys()].join(', ')}`
190
+ );
191
+ }
192
+ return fn({
193
+ messages: options.messages || [],
194
+ systemPrompts: options.systemPrompts || [],
195
+ capabilities: options.capabilities || {},
196
+ resolveImage: options.resolveImage
197
+ });
198
+ }
199
+ };
200
+
201
+ // 向后兼容:保留 assembleMessages 函数
202
+ export function assembleMessages(options = {}) {
203
+ return MessageFormatter.format({ ...options, format: 'openai' });
204
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 类型判断工具
3
+ */
4
+
5
+ /**
6
+ * 判断一个值是否为字符串
7
+ * @param {any} value - 要检查的值
8
+ * @returns {boolean} 如果是字符串则返回 true
9
+ */
10
+ export function isString(value) {
11
+ return typeof value === 'string';
12
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * URL 工具函数
3
+ */
4
+
5
+ /**
6
+ * 拼接 baseUrl 和 path
7
+ * 注意:path 应以 '/' 开头,否则会替换 baseUrl 的最后一段
8
+ * @param {string} baseUrl - 基础 URL(如 https://api.deepseek.com)
9
+ * @param {string} path - 路径(如 /v1/chat/completions)
10
+ * @returns {string} 完整的 URL
11
+ */
12
+ export function joinUrl(baseUrl, path) {
13
+ // 移除 baseUrl 末尾的斜杠(如果有)
14
+ const base = baseUrl.replace(/\/$/, '');
15
+ // 确保 path 以斜杠开头
16
+ const p = path.startsWith('/') ? path : '/' + path;
17
+ return base + p;
18
+ }
package/.env DELETED
@@ -1,15 +0,0 @@
1
- # AI Chat Framework 环境变量配置
2
-
3
- # DeepSeek API 配置
4
- DEEPSEEK_API_KEY=sk-3b396e7fd64b463390d4ff033c6e7e0b
5
- DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions
6
-
7
- # 测试配置
8
- TEST_API_KEY=sk-3b396e7fd64b463390d4ff033c6e7e0b
9
- TEST_MODEL=deepseek-chat
10
- TEST_TEMPERATURE=0.7
11
- TEST_MAX_TOKENS=2000
12
-
13
- # 调试配置
14
- DEBUG=false
15
- LOG_LEVEL=info