closer-code 1.0.0 → 1.0.1
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/.closer-code.example.json +32 -0
- package/DUAL_OPTIMIZATION_COMPLETE.md +293 -0
- package/README.md +167 -557
- package/README_OPENAI.md +163 -0
- package/THINKING_THROTTLING_OPTIMIZATION.md +244 -0
- package/THROTTLING_1_5S_OPTIMIZATION.md +401 -0
- package/TOOLS_IMPROVEMENTS_SUMMARY.md +273 -0
- package/cloco.md +5 -1
- package/config.example.json +15 -94
- package/config.mcp.example.json +81 -0
- package/dist/bash-runner.js +5 -126
- package/dist/batch-cli.js +286 -20658
- package/dist/closer-cli.js +329 -21135
- package/dist/index.js +308 -31036
- package/docs/ANTHROPIC_TOOL_ERROR_HANDLING.md +220 -0
- package/docs/BUILD_COMMANDS.md +79 -0
- package/docs/CTRL_Z_SUPPORT.md +189 -0
- package/docs/DEEPSEEK_R1_INTEGRATION.md +427 -0
- package/docs/FIX_OPENAI_TOOL_ERROR_HANDLING.md +375 -0
- package/docs/FIX_OPENAI_TOOL_RESULT.md +198 -0
- package/docs/INPUT_ENHANCEMENTS.md +192 -0
- package/docs/MCP_IMPLEMENTATION_SUMMARY.md +428 -0
- package/docs/MCP_INTEGRATION.md +418 -0
- package/docs/MCP_QUICKSTART.md +299 -0
- package/docs/MCP_README.md +166 -0
- package/docs/MINIFY_BUILD.md +180 -0
- package/docs/MULTILINE_INPUT_FEATURE.md +119 -0
- package/docs/OPENAI_CLIENT.md +258 -0
- package/docs/PROJECT_LOCAL_CONFIG.md +471 -0
- package/docs/PROJECT_LOCAL_CONFIG_SUMMARY.md +407 -0
- package/docs/REFACTOR_CONVERSATION.md +306 -0
- package/docs/REGION_EDIT_DESIGN.md +475 -0
- package/docs/SIGNAL_HANDLING.md +171 -0
- package/docs/STREAM_UPDATE_THROTTLE.md +273 -0
- package/docs/TOOLS_REFACTOR_PLAN.md +520 -0
- package/ds_r1.md +249 -0
- package/examples/abort-fence-example.js +294 -0
- package/package.json +18 -4
- package/src/ai-client-legacy.js +6 -1
- package/src/ai-client-openai.js +672 -0
- package/src/ai-client.js +30 -13
- package/src/closer-cli.jsx +450 -162
- package/src/components/fullscreen-conversation.jsx +157 -0
- package/src/components/ink-text-input/index.jsx +324 -0
- package/src/components/multiline-text-input.jsx +614 -0
- package/src/components/progress-bar.jsx +135 -0
- package/src/components/tool-detail-view.jsx +82 -0
- package/src/components/tool-renderers/bash-renderer.jsx +197 -0
- package/src/components/tool-renderers/file-edit-renderer.jsx +247 -0
- package/src/components/tool-renderers/file-read-renderer.jsx +261 -0
- package/src/components/tool-renderers/file-write-renderer.jsx +222 -0
- package/src/components/tool-renderers/index.jsx +178 -0
- package/src/components/tool-renderers/list-renderer.jsx +274 -0
- package/src/components/tool-renderers/search-renderer.jsx +248 -0
- package/src/config.js +182 -20
- package/src/conversation/abort-fence.js +158 -0
- package/src/conversation/core.js +377 -0
- package/src/conversation/index.js +33 -0
- package/src/conversation/mcp-integration.js +96 -0
- package/src/conversation/plan-manager.js +295 -0
- package/src/conversation/stream-handler.js +154 -0
- package/src/conversation/tool-executor.js +264 -0
- package/src/conversation.js +23 -958
- package/src/hooks/use-throttled-state.js +158 -0
- package/src/input/enhanced-input.jsx +268 -0
- package/src/input/history.js +342 -0
- package/src/logger.js +20 -0
- package/src/mcp/client.js +275 -0
- package/src/mcp/tools-adapter.js +149 -0
- package/src/planner.js +18 -5
- package/src/prompt-builder.js +159 -0
- package/src/tools.js +457 -25
- package/src/utils/json-parser.js +231 -0
- package/src/utils/json-repair.js +146 -0
- package/src/utils/platform.js +259 -0
- package/test/test-ctrl-bf.js +121 -0
- package/test/test-deepseek-reasoning.js +118 -0
- package/test/test-history-navigation.js +80 -0
- package/test/test-input-fix.js +105 -0
- package/test/test-input-history.js +98 -0
- package/test/test-mcp.js +115 -0
- package/test/test-openai-client.js +152 -0
- package/test/test-openai-tool-result.js +199 -0
- package/test/test-project-config.js +106 -0
- package/test/test-shortcuts.js +79 -0
- package/test/test-stream-throttle.js +124 -0
- package/test/test-tool-error-handling.js +95 -0
- package/test/verify-input-fix.sh +35 -0
- package/test-abort-fence.js +263 -0
- package/test-abort-fix.js +54 -0
- package/test-abort-new-conversation.js +75 -0
- package/test-ctrl-z.js +54 -0
- package/test-file-read.js +105 -0
- package/test-tool-display.js +127 -0
- package/src/closer-cli.jsx.backup +0 -948
- package/test/workflows/longtalk/cloco.md +0 -19
- package/test/workflows/longtalk/emoji_500.txt +0 -63
- package/test/workflows/longtalk/emoji_list.txt +0 -20
- package/test-ctrl-c.jsx +0 -126
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* OpenAI AI 客户端模块 - 使用 @openai/agents
|
|
4
|
+
*
|
|
5
|
+
* 与 Anthropic SDK 的对应关系:
|
|
6
|
+
* - Anthropic: betaZodTool -> OpenAI: tool
|
|
7
|
+
* - Anthropic: toolRunner -> OpenAI: run
|
|
8
|
+
* - Anthropic: stream.on -> OpenAI: (暂无流式事件 API)
|
|
9
|
+
* - Anthropic: client.messages.create -> OpenAI: Agent + run
|
|
10
|
+
*
|
|
11
|
+
* 注意:@openai/agents 包提供了更高级的抽象,
|
|
12
|
+
* 但为了保持与现有代码的兼容性,我们需要适配到统一的接口。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Agent, run, tool } from '@openai/agents';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import OpenAI from 'openai';
|
|
18
|
+
import { safeJSONParse } from './utils/json-repair.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* OpenAI 客户端(使用 @openai/agents SDK)
|
|
22
|
+
*/
|
|
23
|
+
export class OpenAIClient {
|
|
24
|
+
constructor(config) {
|
|
25
|
+
this.apiKey = config.apiKey;
|
|
26
|
+
this.baseURL = config.baseURL || 'https://api.openai.com/v1';
|
|
27
|
+
this.model = config.model || 'gpt-4o';
|
|
28
|
+
this.maxTokens = config.maxTokens || 8192;
|
|
29
|
+
|
|
30
|
+
// 创建 OpenAI 客户端实例
|
|
31
|
+
this.client = new OpenAI({
|
|
32
|
+
apiKey: this.apiKey,
|
|
33
|
+
baseURL: this.baseURL
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// 存储当前 agent
|
|
37
|
+
this.currentAgent = null;
|
|
38
|
+
|
|
39
|
+
// DeepSeek-R1 Reasoning 支持
|
|
40
|
+
this.isDeepSeekReasoner = this.model.includes('deepseek-reasoner') ||
|
|
41
|
+
config.enableReasoning === true;
|
|
42
|
+
this.reasoningContent = ''; // 累积的推理内容
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 发送消息(非流式)
|
|
47
|
+
* 使用 @openai/agents 的 Agent 和 run
|
|
48
|
+
*
|
|
49
|
+
* @param {Array} messages - 消息数组
|
|
50
|
+
* @param {Object} options - 选项(system, tools, temperature)
|
|
51
|
+
* @returns {Promise} API 响应
|
|
52
|
+
*/
|
|
53
|
+
async chat(messages, options = {}) {
|
|
54
|
+
const system = options.system || 'You are a helpful AI programming assistant.';
|
|
55
|
+
const tools = options.tools || [];
|
|
56
|
+
const temperature = options.temperature ?? 0.7;
|
|
57
|
+
|
|
58
|
+
// 转换消息格式(Anthropic -> OpenAI),需要特殊处理 tool_result
|
|
59
|
+
const formattedMessages = [];
|
|
60
|
+
|
|
61
|
+
// 遍历消息并转换
|
|
62
|
+
for (const message of messages) {
|
|
63
|
+
const converted = this._convertMessageFormat(message);
|
|
64
|
+
|
|
65
|
+
// 检查是否包含 tool_result(需要拆分为多个 role: 'tool' 消息)
|
|
66
|
+
if (Array.isArray(message.content)) {
|
|
67
|
+
const toolResultBlocks = message.content.filter(block => block.type === 'tool_result');
|
|
68
|
+
const textBlocks = message.content.filter(block => block.type === 'text');
|
|
69
|
+
|
|
70
|
+
if (toolResultBlocks.length > 0) {
|
|
71
|
+
// 每个工具结果作为独立的 role: 'tool' 消息
|
|
72
|
+
for (const block of toolResultBlocks) {
|
|
73
|
+
formattedMessages.push({
|
|
74
|
+
role: 'tool',
|
|
75
|
+
tool_call_id: block.tool_use_id,
|
|
76
|
+
content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content)
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 如果有文本内容,添加为 user 消息
|
|
81
|
+
if (textBlocks.length > 0) {
|
|
82
|
+
const textContent = textBlocks.map(block => block.text).join('\n');
|
|
83
|
+
formattedMessages.push({
|
|
84
|
+
role: message.role,
|
|
85
|
+
content: textContent
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
// 没有 tool_result,直接添加转换后的消息
|
|
90
|
+
formattedMessages.push(converted);
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
// 不是数组,直接添加
|
|
94
|
+
formattedMessages.push(converted);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 如果有工具,需要转换为 OpenAI agents 的 tool 格式
|
|
99
|
+
const openaiTools = this._convertTools(tools);
|
|
100
|
+
|
|
101
|
+
// 创建 Agent
|
|
102
|
+
const agent = new Agent({
|
|
103
|
+
name: 'Assistant',
|
|
104
|
+
instructions: system,
|
|
105
|
+
tools: openaiTools,
|
|
106
|
+
temperature: temperature
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// 将消息转换为输入(简单拼接)
|
|
110
|
+
const input = this._messagesToInput(messages);
|
|
111
|
+
|
|
112
|
+
// 使用 run 函数执行
|
|
113
|
+
const result = await run(agent, input, {
|
|
114
|
+
maxTurns: 10 // 限制最大轮次
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// 转换响应格式(OpenAI -> Anthropic)
|
|
118
|
+
return this._convertResponseToAnthropic(result);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 发送消息(流式)
|
|
123
|
+
* 注意:@openai/agents 目前不提供与 Anthropic SDK 相同的流式事件 API
|
|
124
|
+
* 这里使用 OpenAI SDK 的原生流式 API
|
|
125
|
+
*
|
|
126
|
+
* @param {Array} messages - 消息数组
|
|
127
|
+
* @param {Object} options - 选项
|
|
128
|
+
* @param {Function} onChunk - 流式回调函数
|
|
129
|
+
* @returns {Promise} 最终消息
|
|
130
|
+
*/
|
|
131
|
+
async chatStream(messages, options = {}, onChunk) {
|
|
132
|
+
const system = options.system || 'You are a helpful AI programming assistant.';
|
|
133
|
+
const tools = options.tools || [];
|
|
134
|
+
const temperature = options.temperature ?? 0.7;
|
|
135
|
+
|
|
136
|
+
// 转换消息格式,需要特殊处理 tool_result
|
|
137
|
+
const formattedMessages = [
|
|
138
|
+
{ role: 'system', content: system }
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
// 遍历消息并转换
|
|
142
|
+
for (const message of messages) {
|
|
143
|
+
const converted = this._convertMessageFormat(message);
|
|
144
|
+
|
|
145
|
+
// 检查是否包含 tool_result(需要拆分为多个 role: 'tool' 消息)
|
|
146
|
+
if (Array.isArray(message.content)) {
|
|
147
|
+
const toolResultBlocks = message.content.filter(block => block.type === 'tool_result');
|
|
148
|
+
const textBlocks = message.content.filter(block => block.type === 'text');
|
|
149
|
+
|
|
150
|
+
if (toolResultBlocks.length > 0) {
|
|
151
|
+
// 每个工具结果作为独立的 role: 'tool' 消息
|
|
152
|
+
for (const block of toolResultBlocks) {
|
|
153
|
+
formattedMessages.push({
|
|
154
|
+
role: 'tool',
|
|
155
|
+
tool_call_id: block.tool_use_id,
|
|
156
|
+
content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content)
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 如果有文本内容,添加为 user 消息
|
|
161
|
+
if (textBlocks.length > 0) {
|
|
162
|
+
const textContent = textBlocks.map(block => block.text).join('\n');
|
|
163
|
+
formattedMessages.push({
|
|
164
|
+
role: message.role,
|
|
165
|
+
content: textContent
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
// 没有 tool_result,直接添加转换后的消息
|
|
170
|
+
formattedMessages.push(converted);
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
// 不是数组,直接添加
|
|
174
|
+
formattedMessages.push(converted);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 转换工具格式
|
|
179
|
+
const openaiTools = this._convertTools(tools);
|
|
180
|
+
|
|
181
|
+
// 构建 API 请求参数
|
|
182
|
+
const apiParams = {
|
|
183
|
+
model: this.model,
|
|
184
|
+
messages: formattedMessages,
|
|
185
|
+
tools: openaiTools.length > 0 ? openaiTools : undefined,
|
|
186
|
+
temperature: temperature,
|
|
187
|
+
max_tokens: this.maxTokens,
|
|
188
|
+
stream: true
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// DeepSeek-R1 Reasoning: 添加 thinking 参数
|
|
192
|
+
if (this.isDeepSeekReasoner && options.thinking?.type === 'enabled') {
|
|
193
|
+
apiParams.extra_body = {
|
|
194
|
+
thinking: { type: 'enabled' }
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 使用 OpenAI SDK 的原生流式 API
|
|
199
|
+
const stream = await this.client.chat.completions.create(apiParams);
|
|
200
|
+
|
|
201
|
+
let fullResponse = {
|
|
202
|
+
role: 'assistant',
|
|
203
|
+
content: [],
|
|
204
|
+
model: this.model
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
let currentToolCalls = [];
|
|
208
|
+
let accumulatedText = '';
|
|
209
|
+
let accumulatedReasoning = '';
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
for await (const chunk of stream) {
|
|
213
|
+
try {
|
|
214
|
+
const delta = chunk.choices[0]?.delta;
|
|
215
|
+
|
|
216
|
+
if (!delta) continue;
|
|
217
|
+
|
|
218
|
+
// 处理 DeepSeek-R1 的 reasoning_content
|
|
219
|
+
if (delta.reasoning_content) {
|
|
220
|
+
accumulatedReasoning += delta.reasoning_content;
|
|
221
|
+
|
|
222
|
+
if (typeof onChunk === 'function') {
|
|
223
|
+
onChunk({
|
|
224
|
+
type: 'reasoning',
|
|
225
|
+
delta: delta.reasoning_content,
|
|
226
|
+
snapshot: accumulatedReasoning
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 处理文本内容
|
|
232
|
+
if (delta.content) {
|
|
233
|
+
accumulatedText += delta.content;
|
|
234
|
+
|
|
235
|
+
if (typeof onChunk === 'function') {
|
|
236
|
+
onChunk({
|
|
237
|
+
type: 'text',
|
|
238
|
+
delta: delta.content,
|
|
239
|
+
snapshot: accumulatedText
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 处理工具调用
|
|
245
|
+
if (delta.tool_calls) {
|
|
246
|
+
for (const toolCall of delta.tool_calls) {
|
|
247
|
+
if (toolCall.index !== undefined) {
|
|
248
|
+
if (!currentToolCalls[toolCall.index]) {
|
|
249
|
+
currentToolCalls[toolCall.index] = {
|
|
250
|
+
id: toolCall.id,
|
|
251
|
+
type: 'function',
|
|
252
|
+
function: {
|
|
253
|
+
name: toolCall.function?.name || '',
|
|
254
|
+
arguments: toolCall.function?.arguments || ''
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
} else {
|
|
258
|
+
// 累积参数
|
|
259
|
+
if (toolCall.function?.arguments) {
|
|
260
|
+
currentToolCalls[toolCall.index].function.arguments +=
|
|
261
|
+
toolCall.function.arguments;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 检查是否完成
|
|
269
|
+
if (chunk.choices[0]?.finish_reason === 'stop' ||
|
|
270
|
+
chunk.choices[0]?.finish_reason === 'tool_calls') {
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
} catch (chunkError) {
|
|
274
|
+
console.error('[OpenAI Stream Chunk Error]:', chunkError.message);
|
|
275
|
+
// 继续处理下一个 chunk,不中断整个流
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch (streamError) {
|
|
280
|
+
console.error('[OpenAI Stream Error]:', streamError.message);
|
|
281
|
+
console.error('[OpenAI Stream Error Type]:', streamError.constructor.name);
|
|
282
|
+
|
|
283
|
+
// 如果是网络错误或流中断,仍然返回已累积的内容
|
|
284
|
+
if (streamError.name === 'AbortError' || streamError.name === 'NetworkError') {
|
|
285
|
+
console.warn('[OpenAI Stream] Stream interrupted, returning accumulated content');
|
|
286
|
+
} else {
|
|
287
|
+
// 其他错误也尝试返回已累积的内容
|
|
288
|
+
console.warn('[OpenAI Stream] Error occurred, returning accumulated content');
|
|
289
|
+
}
|
|
290
|
+
// 不抛出异常,继续处理已累积的内容
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 构建响应内容
|
|
294
|
+
if (accumulatedReasoning) {
|
|
295
|
+
// DeepSeek-R1 的 reasoning_content
|
|
296
|
+
fullResponse.content.push({
|
|
297
|
+
type: 'reasoning',
|
|
298
|
+
text: accumulatedReasoning
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// 保存推理内容用于后续工具调用
|
|
302
|
+
this.reasoningContent = accumulatedReasoning;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (accumulatedText) {
|
|
306
|
+
fullResponse.content.push({
|
|
307
|
+
type: 'text',
|
|
308
|
+
text: accumulatedText
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (currentToolCalls.length > 0) {
|
|
313
|
+
for (const toolCall of currentToolCalls) {
|
|
314
|
+
// 使用 jsonrepair 解析工具参数
|
|
315
|
+
const input = safeJSONParse(toolCall.function.arguments, {
|
|
316
|
+
fallback: {},
|
|
317
|
+
silent: false
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// 检查是否解析失败
|
|
321
|
+
// 情况1: 返回的不是对象(如字符串、数字等)
|
|
322
|
+
const isNotObject = typeof input !== 'object' || input === null;
|
|
323
|
+
// 情况2: 返回的是空对象
|
|
324
|
+
const isEmptyObject = typeof input === 'object' && input !== null && Object.keys(input).length === 0;
|
|
325
|
+
// 原始参数是否为空
|
|
326
|
+
const isArgsEmpty = toolCall.function.arguments.trim() === '' ||
|
|
327
|
+
toolCall.function.arguments.trim() === '{}';
|
|
328
|
+
|
|
329
|
+
// 如果返回的不是对象,且原始参数不为空,则认为解析失败
|
|
330
|
+
// 如果返回的是空对象,且原始参数不是空JSON,则认为解析失败
|
|
331
|
+
const parseError = (isNotObject && !isArgsEmpty) || (isEmptyObject && !isArgsEmpty);
|
|
332
|
+
|
|
333
|
+
fullResponse.content.push({
|
|
334
|
+
type: 'tool_use',
|
|
335
|
+
id: toolCall.id,
|
|
336
|
+
name: toolCall.function.name,
|
|
337
|
+
input,
|
|
338
|
+
parseError
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// DeepSeek-R1: 保存完整的响应消息(包含 reasoning_content)
|
|
344
|
+
// 用于下一轮工具调用
|
|
345
|
+
if (this.isDeepSeekReasoner) {
|
|
346
|
+
fullResponse.reasoning_content = accumulatedReasoning || '';
|
|
347
|
+
fullResponse.raw_content = accumulatedText || '';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return fullResponse;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 使用 tools 自动处理工具调用循环
|
|
355
|
+
* 对应 Anthropic 的 toolRunner
|
|
356
|
+
*
|
|
357
|
+
* @param {Array} messages - 消息数组
|
|
358
|
+
* @param {Array} tools - 工具数组(使用 Zod 定义的 tool)
|
|
359
|
+
* @param {Object} options - 选项
|
|
360
|
+
* @returns {Promise} 最终消息(所有工具调用完成后)
|
|
361
|
+
*/
|
|
362
|
+
async chatWithTools(messages, tools, options = {}) {
|
|
363
|
+
const system = options.system || 'You are a helpful AI programming assistant.';
|
|
364
|
+
const temperature = options.temperature ?? 0.7;
|
|
365
|
+
|
|
366
|
+
// 转换工具格式(Anthropic betaZodTool -> OpenAI tool)
|
|
367
|
+
const openaiTools = tools.map(tool => {
|
|
368
|
+
// betaZodTool 有 name, description, input_schema, run
|
|
369
|
+
// OpenAI tool 需要:name, description, parameters (Zod schema), execute
|
|
370
|
+
return {
|
|
371
|
+
name: tool.name,
|
|
372
|
+
description: tool.description,
|
|
373
|
+
parameters: tool.input_schema, // Zod schema
|
|
374
|
+
execute: tool.run
|
|
375
|
+
};
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// 创建 Agent
|
|
379
|
+
const agent = new Agent({
|
|
380
|
+
name: 'Assistant',
|
|
381
|
+
instructions: system,
|
|
382
|
+
tools: openaiTools,
|
|
383
|
+
temperature: temperature
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// 将消息转换为输入
|
|
387
|
+
const input = this._messagesToInput(messages);
|
|
388
|
+
|
|
389
|
+
// 使用 run 函数执行(自动处理工具调用循环)
|
|
390
|
+
const result = await run(agent, input, {
|
|
391
|
+
maxTurns: 10
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// 转换响应格式
|
|
395
|
+
return this._convertResponseToAnthropic(result);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 获取消息的 token 计数(使用 OpenAI SDK 的 tokenizer)
|
|
400
|
+
* @param {Array} messages - 消息数组
|
|
401
|
+
* @returns {Promise} Token 计数
|
|
402
|
+
*/
|
|
403
|
+
async countTokens(messages) {
|
|
404
|
+
// OpenAI SDK 没有内置的 countTokens 方法
|
|
405
|
+
// 使用简单的估算:1 token ≈ 4 characters
|
|
406
|
+
const text = JSON.stringify(messages);
|
|
407
|
+
return Math.ceil(text.length / 4);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* 清除历史消息中的 reasoning_content(DeepSeek-R1 特性)
|
|
412
|
+
*
|
|
413
|
+
* DeepSeek-R1 要求:
|
|
414
|
+
* - 同一轮的工具调用中:保留 reasoning_content
|
|
415
|
+
* - 新一轮对话开始时:清除 reasoning_content 以节省带宽
|
|
416
|
+
*
|
|
417
|
+
* @param {Array} messages - 消息数组
|
|
418
|
+
* @returns {Array} 清除后的消息数组
|
|
419
|
+
*/
|
|
420
|
+
clearReasoningContent(messages) {
|
|
421
|
+
if (!this.isDeepSeekReasoner) {
|
|
422
|
+
return messages; // 非 DeepSeek-R1 模型,无需处理
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// 清除每条消息中的 reasoning_content
|
|
426
|
+
return messages.map(message => {
|
|
427
|
+
if (message.reasoning_content !== undefined) {
|
|
428
|
+
// 创建新消息对象,不包含 reasoning_content
|
|
429
|
+
const { reasoning_content, ...messageWithoutReasoning } = message;
|
|
430
|
+
return messageWithoutReasoning;
|
|
431
|
+
}
|
|
432
|
+
return message;
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* 保留当前轮的 reasoning_content(DeepSeek-R1 特性)
|
|
438
|
+
*
|
|
439
|
+
* 用于在同一轮的工具调用中继续传递 reasoning_content
|
|
440
|
+
*
|
|
441
|
+
* @param {Array} messages - 消息数组
|
|
442
|
+
* @param {string} reasoningContent - 当前轮的推理内容
|
|
443
|
+
* @returns {Array} 添加了 reasoning_content 的消息数组
|
|
444
|
+
*/
|
|
445
|
+
appendCurrentReasoning(messages, reasoningContent) {
|
|
446
|
+
if (!this.isDeepSeekReasoner || !reasoningContent) {
|
|
447
|
+
return messages; // 非 DeepSeek-R1 或无推理内容,无需处理
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// 在最后一条 assistant 消息中添加 reasoning_content
|
|
451
|
+
const lastMessage = messages[messages.length - 1];
|
|
452
|
+
if (lastMessage && lastMessage.role === 'assistant') {
|
|
453
|
+
lastMessage.reasoning_content = reasoningContent;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return messages;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* 转换消息格式(Anthropic -> OpenAI)
|
|
461
|
+
* @private
|
|
462
|
+
*/
|
|
463
|
+
_convertMessageFormat(message) {
|
|
464
|
+
// Anthropic: { role, content } where content can be string or array
|
|
465
|
+
// OpenAI: { role, content } or { role, tool_calls } or { role: 'tool', tool_call_id, content }
|
|
466
|
+
|
|
467
|
+
if (typeof message.content === 'string') {
|
|
468
|
+
return {
|
|
469
|
+
role: message.role,
|
|
470
|
+
content: message.content
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// 处理 content 为数组的情况
|
|
475
|
+
if (Array.isArray(message.content)) {
|
|
476
|
+
// 检查是否包含 tool_use(assistant 的工具调用)
|
|
477
|
+
const toolUseBlocks = message.content.filter(block => block.type === 'tool_use');
|
|
478
|
+
const textBlocks = message.content.filter(block => block.type === 'text');
|
|
479
|
+
|
|
480
|
+
if (toolUseBlocks.length > 0) {
|
|
481
|
+
// OpenAI 格式:assistant 消息使用 tool_calls 字段
|
|
482
|
+
// 同时保留文本内容(如果有)
|
|
483
|
+
const result = {
|
|
484
|
+
role: message.role,
|
|
485
|
+
tool_calls: toolUseBlocks.map(block => ({
|
|
486
|
+
id: block.id,
|
|
487
|
+
type: 'function',
|
|
488
|
+
function: {
|
|
489
|
+
name: block.name,
|
|
490
|
+
arguments: JSON.stringify(block.input)
|
|
491
|
+
}
|
|
492
|
+
}))
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
// 如果有文本内容,添加 content 字段
|
|
496
|
+
if (textBlocks.length > 0) {
|
|
497
|
+
result.content = textBlocks.map(block => block.text).join('\n');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// DeepSeek-R1: 保留 reasoning_content 字段
|
|
501
|
+
if (this.isDeepSeekReasoner && message.reasoning_content !== undefined) {
|
|
502
|
+
result.reasoning_content = message.reasoning_content;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return result;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 检查是否包含 tool_result(user 的工具结果)
|
|
509
|
+
const toolResultBlocks = message.content.filter(block => block.type === 'tool_result');
|
|
510
|
+
|
|
511
|
+
if (toolResultBlocks.length > 0) {
|
|
512
|
+
// OpenAI 格式:每个 tool_result 应该是一个独立的 role: 'tool' 消息
|
|
513
|
+
// 但由于这个函数返回单个消息,我们需要特殊处理
|
|
514
|
+
// 如果只有一个 tool_result,返回标准格式
|
|
515
|
+
if (toolResultBlocks.length === 1 && !textBlocks.length) {
|
|
516
|
+
const block = toolResultBlocks[0];
|
|
517
|
+
return {
|
|
518
|
+
role: 'tool',
|
|
519
|
+
tool_call_id: block.tool_use_id,
|
|
520
|
+
content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content)
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// 如果有多个 tool_result 或同时有文本,返回 user 消息格式
|
|
525
|
+
// 注意:这种情况下,调用方需要将这个消息拆分为多个消息
|
|
526
|
+
return {
|
|
527
|
+
role: message.role,
|
|
528
|
+
content: toolResultBlocks.map(block => ({
|
|
529
|
+
type: 'text',
|
|
530
|
+
text: `[Tool Result for ${block.tool_use_id}]: ${typeof block.content === 'string' ? block.content : JSON.stringify(block.content)}`
|
|
531
|
+
}))
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 普通 text 内容
|
|
536
|
+
const textParts = message.content
|
|
537
|
+
.filter(block => block.type === 'text')
|
|
538
|
+
.map(block => block.text)
|
|
539
|
+
.join('\n');
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
role: message.role,
|
|
543
|
+
content: textParts
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
role: message.role,
|
|
549
|
+
content: ''
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* 转换工具格式(Anthropic betaZodTool -> OpenAI tool/function)
|
|
555
|
+
* @private
|
|
556
|
+
*/
|
|
557
|
+
_convertTools(anthropicTools) {
|
|
558
|
+
return anthropicTools.map(tool => ({
|
|
559
|
+
type: 'function',
|
|
560
|
+
function: {
|
|
561
|
+
name: tool.name,
|
|
562
|
+
description: tool.description,
|
|
563
|
+
parameters: tool.input_schema // Zod schema
|
|
564
|
+
}
|
|
565
|
+
}));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* 将消息数组转换为输入字符串
|
|
570
|
+
* @private
|
|
571
|
+
*/
|
|
572
|
+
_messagesToInput(messages) {
|
|
573
|
+
// 简单实现:提取所有文本内容
|
|
574
|
+
return messages
|
|
575
|
+
.map(m => {
|
|
576
|
+
if (typeof m.content === 'string') {
|
|
577
|
+
return `${m.role}: ${m.content}`;
|
|
578
|
+
}
|
|
579
|
+
if (Array.isArray(m.content)) {
|
|
580
|
+
// 提取文本内容
|
|
581
|
+
const textBlocks = m.content.filter(block => block.type === 'text');
|
|
582
|
+
const text = textBlocks.map(block => block.text).join('\n');
|
|
583
|
+
|
|
584
|
+
// 提取工具结果
|
|
585
|
+
const toolResultBlocks = m.content.filter(block => block.type === 'tool_result');
|
|
586
|
+
const toolResults = toolResultBlocks.map(block => {
|
|
587
|
+
const content = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
|
|
588
|
+
return `[Tool Result: ${content}]`;
|
|
589
|
+
}).join('\n');
|
|
590
|
+
|
|
591
|
+
// 组合文本和工具结果
|
|
592
|
+
const combined = [text, toolResults].filter(s => s).join('\n');
|
|
593
|
+
return `${m.role}: ${combined}`;
|
|
594
|
+
}
|
|
595
|
+
return '';
|
|
596
|
+
})
|
|
597
|
+
.join('\n\n');
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* 转换响应格式(OpenAI -> Anthropic)
|
|
602
|
+
* @private
|
|
603
|
+
*/
|
|
604
|
+
_convertResponseToAnthropic(result) {
|
|
605
|
+
// OpenAI agents 返回: { finalOutput, ... }
|
|
606
|
+
// 我们需要转换为 Anthropic 格式: { role, content, ... }
|
|
607
|
+
|
|
608
|
+
const textContent = result.finalOutput || '';
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
role: 'assistant',
|
|
612
|
+
content: [
|
|
613
|
+
{
|
|
614
|
+
type: 'text',
|
|
615
|
+
text: textContent
|
|
616
|
+
}
|
|
617
|
+
],
|
|
618
|
+
model: this.model,
|
|
619
|
+
finishReason: 'stop'
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* 创建兼容的工具定义
|
|
626
|
+
* 将现有的 betaZodTool 转换为 OpenAI agents 的 tool 格式
|
|
627
|
+
*/
|
|
628
|
+
export function createOpenAITool(anthropicTool) {
|
|
629
|
+
return tool({
|
|
630
|
+
name: anthropicTool.name,
|
|
631
|
+
description: anthropicTool.description,
|
|
632
|
+
parameters: anthropicTool.input_schema,
|
|
633
|
+
execute: async (input) => {
|
|
634
|
+
try {
|
|
635
|
+
// 调用原始工具的 run 方法
|
|
636
|
+
const result = await anthropicTool.run(input);
|
|
637
|
+
|
|
638
|
+
// 使用 jsonrepair 解析 JSON
|
|
639
|
+
const parsed = safeJSONParse(result, {
|
|
640
|
+
fallback: null,
|
|
641
|
+
silent: true
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// 如果解析失败,检查结果是否已经是对象
|
|
645
|
+
if (parsed === null) {
|
|
646
|
+
if (typeof result === 'object') {
|
|
647
|
+
return result;
|
|
648
|
+
}
|
|
649
|
+
// 返回原始字符串
|
|
650
|
+
return { result: result };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return parsed;
|
|
654
|
+
} catch (error) {
|
|
655
|
+
// 工具执行失败,返回错误信息
|
|
656
|
+
console.error(`[OpenAI Tool Execution Error] ${anthropicTool.name}:`, error.message);
|
|
657
|
+
return {
|
|
658
|
+
success: false,
|
|
659
|
+
error: error.message,
|
|
660
|
+
errorType: error.constructor.name
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* 批量转换工具
|
|
669
|
+
*/
|
|
670
|
+
export function convertToolsToOpenAI(anthropicTools) {
|
|
671
|
+
return anthropicTools.map(createOpenAITool);
|
|
672
|
+
}
|