bingn commited on
Commit
99f8658
·
verified ·
1 Parent(s): d9606f2

Upload 19 files

Browse files
Files changed (9) hide show
  1. anthropic-adapter.js +43 -13
  2. mail.js +32 -7
  3. message-convert.js +80 -14
  4. openai-adapter.js +42 -14
  5. pool.js +14 -7
  6. server.js +4 -2
  7. stream-transform.js +288 -0
  8. tool-prompt.js +239 -0
  9. ui.js +26 -3
anthropic-adapter.js CHANGED
@@ -9,7 +9,8 @@
9
  import crypto from 'crypto';
10
  import { createContext, sendMessageStreaming } from './chat.js';
11
  import { anthropicToText, resolveModel } from './message-convert.js';
12
- import { transformToAnthropicSSE, collectFullResponse } from './stream-transform.js';
 
13
 
14
  const MAX_RETRY = 3;
15
 
@@ -44,10 +45,11 @@ export async function handleMessages(body, res, pool) {
44
  return;
45
  }
46
 
47
- const text = anthropicToText(body.system, body.messages);
48
  const model = resolveModel(body.model);
49
  const clientModel = body.model || 'claude-3-sonnet';
50
  const stream = body.stream === true;
 
51
 
52
  if (!text) {
53
  sendError(res, 400, 'No valid message content found');
@@ -74,15 +76,27 @@ export async function handleMessages(body, res, pool) {
74
  const result = await sendMessageStreaming(account.cookies, ctx.chatId, text, model);
75
  if (result.cookies) account.cookies = result.cookies;
76
 
77
- transformToAnthropicSSE(result.stream, res, clientModel, requestId, (errMsg) => {
78
- const msg = (errMsg || '').toLowerCase();
79
- if (msg.includes('limit') || msg.includes('quota') || msg.includes('exhaust') || msg.includes('exceed')) {
80
- pool.release(account, { quotaExhausted: true });
81
- } else {
82
- pool.release(account, { success: false });
83
- }
84
- account = null;
85
- });
 
 
 
 
 
 
 
 
 
 
 
 
86
  result.stream.on('end', () => { if (account) pool.release(account, { success: true }); });
87
  result.stream.on('error', (err) => {
88
  if (!account) return;
@@ -131,6 +145,22 @@ export async function handleMessages(body, res, pool) {
131
  const full = await collectFullResponse(result.stream);
132
  pool.release(account, { success: true });
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  res.writeHead(200, {
135
  'Content-Type': 'application/json',
136
  'Access-Control-Allow-Origin': '*',
@@ -140,8 +170,8 @@ export async function handleMessages(body, res, pool) {
140
  type: 'message',
141
  role: 'assistant',
142
  model: clientModel,
143
- content: [{ type: 'text', text: full.text }],
144
- stop_reason: 'end_turn',
145
  stop_sequence: null,
146
  usage: { input_tokens: 0, output_tokens: 0 },
147
  }));
 
9
  import crypto from 'crypto';
10
  import { createContext, sendMessageStreaming } from './chat.js';
11
  import { anthropicToText, resolveModel } from './message-convert.js';
12
+ import { transformToAnthropicSSE, collectFullResponse, transformToAnthropicSSEWithTools } from './stream-transform.js';
13
+ import { parseToolCalls, toAnthropicToolUse } from './tool-prompt.js';
14
 
15
  const MAX_RETRY = 3;
16
 
 
45
  return;
46
  }
47
 
48
+ const text = anthropicToText(body.system, body.messages, body.tools, body.tool_choice);
49
  const model = resolveModel(body.model);
50
  const clientModel = body.model || 'claude-3-sonnet';
51
  const stream = body.stream === true;
52
+ const hasTools = body.tools && body.tools.length > 0;
53
 
54
  if (!text) {
55
  sendError(res, 400, 'No valid message content found');
 
76
  const result = await sendMessageStreaming(account.cookies, ctx.chatId, text, model);
77
  if (result.cookies) account.cookies = result.cookies;
78
 
79
+ if (hasTools) {
80
+ transformToAnthropicSSEWithTools(result.stream, res, clientModel, requestId, (errMsg) => {
81
+ const msg = (errMsg || '').toLowerCase();
82
+ if (msg.includes('limit') || msg.includes('quota') || msg.includes('exhaust') || msg.includes('exceed')) {
83
+ pool.release(account, { quotaExhausted: true });
84
+ } else {
85
+ pool.release(account, { success: false });
86
+ }
87
+ account = null;
88
+ });
89
+ } else {
90
+ transformToAnthropicSSE(result.stream, res, clientModel, requestId, (errMsg) => {
91
+ const msg = (errMsg || '').toLowerCase();
92
+ if (msg.includes('limit') || msg.includes('quota') || msg.includes('exhaust') || msg.includes('exceed')) {
93
+ pool.release(account, { quotaExhausted: true });
94
+ } else {
95
+ pool.release(account, { success: false });
96
+ }
97
+ account = null;
98
+ });
99
+ }
100
  result.stream.on('end', () => { if (account) pool.release(account, { success: true }); });
101
  result.stream.on('error', (err) => {
102
  if (!account) return;
 
145
  const full = await collectFullResponse(result.stream);
146
  pool.release(account, { success: true });
147
 
148
+ // 检测工具调用
149
+ const { hasToolCalls, toolCalls, textContent } = hasTools
150
+ ? parseToolCalls(full.text)
151
+ : { hasToolCalls: false, toolCalls: [], textContent: full.text };
152
+
153
+ const content = [];
154
+ if (textContent) {
155
+ content.push({ type: 'text', text: textContent });
156
+ }
157
+ if (hasToolCalls) {
158
+ content.push(...toAnthropicToolUse(toolCalls));
159
+ }
160
+ if (content.length === 0) {
161
+ content.push({ type: 'text', text: full.text });
162
+ }
163
+
164
  res.writeHead(200, {
165
  'Content-Type': 'application/json',
166
  'Access-Control-Allow-Origin': '*',
 
170
  type: 'message',
171
  role: 'assistant',
172
  model: clientModel,
173
+ content,
174
+ stop_reason: hasToolCalls ? 'tool_use' : 'end_turn',
175
  stop_sequence: null,
176
  usage: { input_tokens: 0, output_tokens: 0 },
177
  }));
mail.js CHANGED
@@ -35,6 +35,25 @@ function prompt(question) {
35
 
36
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  function httpsGet(url, options = {}) {
39
  return new Promise((resolve, reject) => {
40
  const urlObj = new URL(url);
@@ -313,6 +332,7 @@ class GPTMailProvider extends MailProvider {
313
  return false;
314
  }
315
 
 
316
  async _pickGoodEmail() {
317
  const candidates = [];
318
  const tasks = Array.from({ length: 8 }, () =>
@@ -618,9 +638,14 @@ class MailTmProvider extends MailProvider {
618
  this.accountPassword = null;
619
  }
620
 
 
 
 
 
 
621
  async createInbox() {
622
  // 1. 获取可用域名
623
- const domainsResp = await httpsRequest('GET', this.apiHost, '/domains');
624
  if (!domainsResp.ok) throw new Error(`Mail.tm 获取域名失败: ${domainsResp.status}`);
625
  const domains = domainsResp.data['hydra:member'] || domainsResp.data || [];
626
  if (!domains?.length) throw new Error('Mail.tm 无可用域名');
@@ -631,7 +656,7 @@ class MailTmProvider extends MailProvider {
631
  const email = `${user}@${domain}`;
632
  this.accountPassword = crypto.randomBytes(12).toString('base64url');
633
 
634
- const createResp = await httpsRequest('POST', this.apiHost, '/accounts', {
635
  address: email,
636
  password: this.accountPassword,
637
  });
@@ -639,7 +664,7 @@ class MailTmProvider extends MailProvider {
639
  this.accountId = createResp.data.id;
640
 
641
  // 3. 登录获取 JWT
642
- const loginResp = await httpsRequest('POST', this.apiHost, '/token', {
643
  address: email,
644
  password: this.accountPassword,
645
  });
@@ -665,7 +690,7 @@ class MailTmProvider extends MailProvider {
665
 
666
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
667
  try {
668
- const resp = await httpsRequest('GET', this.apiHost, '/messages', null, {
669
  'Authorization': `Bearer ${this.jwtToken}`,
670
  });
671
  if (!resp.ok) { await sleep(pollInterval); continue; }
@@ -684,7 +709,7 @@ class MailTmProvider extends MailProvider {
684
 
685
  // 获取邮件详情
686
  if (msg.id) {
687
- const detailResp = await httpsRequest('GET', this.apiHost, `/messages/${msg.id}`, null, {
688
  'Authorization': `Bearer ${this.jwtToken}`,
689
  });
690
  if (detailResp.ok) {
@@ -699,7 +724,7 @@ class MailTmProvider extends MailProvider {
699
  }
700
 
701
  // 如果详情解析失败,尝试 /sources 获取原始 MIME
702
- const srcResp = await httpsRequest('GET', this.apiHost, `/sources/${msg.id}`, null, {
703
  'Authorization': `Bearer ${this.jwtToken}`,
704
  });
705
  if (srcResp.ok) {
@@ -726,7 +751,7 @@ class MailTmProvider extends MailProvider {
726
  async cleanup() {
727
  if (this.jwtToken && this.accountId) {
728
  try {
729
- await httpsRequest('DELETE', this.apiHost, `/accounts/${this.accountId}`, null, {
730
  'Authorization': `Bearer ${this.jwtToken}`,
731
  });
732
  } catch {}
 
35
 
36
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
37
 
38
+ /**
39
+ * 按域名限流器 — 确保对同一域名的请求间隔不小于 minGap ms
40
+ * 避免 Mail.tm 等服务 429
41
+ */
42
+ const _rateLimitState = {}; // { [host]: { queue: Promise, lastReq: number } }
43
+ function rateLimitWrap(host, fn, minGap = 1200) {
44
+ if (!_rateLimitState[host]) _rateLimitState[host] = { queue: Promise.resolve() };
45
+ const state = _rateLimitState[host];
46
+ const job = state.queue.then(async () => {
47
+ const now = Date.now();
48
+ const elapsed = now - (state.lastReq || 0);
49
+ if (elapsed < minGap) await sleep(minGap - elapsed);
50
+ state.lastReq = Date.now();
51
+ return fn();
52
+ });
53
+ state.queue = job.catch(() => {}); // 不让单次失败阻塞后续
54
+ return job;
55
+ }
56
+
57
  function httpsGet(url, options = {}) {
58
  return new Promise((resolve, reject) => {
59
  const urlObj = new URL(url);
 
332
  return false;
333
  }
334
 
335
+
336
  async _pickGoodEmail() {
337
  const candidates = [];
338
  const tasks = Array.from({ length: 8 }, () =>
 
638
  this.accountPassword = null;
639
  }
640
 
641
+ /** 限流包装 — 所有 Mail.tm API 请求都走这里 */
642
+ _req(method, pathStr, body, extraHeaders) {
643
+ return rateLimitWrap(this.apiHost, () => httpsRequest(method, this.apiHost, pathStr, body, extraHeaders || {}));
644
+ }
645
+
646
  async createInbox() {
647
  // 1. 获取可用域名
648
+ const domainsResp = await this._req('GET', '/domains');
649
  if (!domainsResp.ok) throw new Error(`Mail.tm 获取域名失败: ${domainsResp.status}`);
650
  const domains = domainsResp.data['hydra:member'] || domainsResp.data || [];
651
  if (!domains?.length) throw new Error('Mail.tm 无可用域名');
 
656
  const email = `${user}@${domain}`;
657
  this.accountPassword = crypto.randomBytes(12).toString('base64url');
658
 
659
+ const createResp = await this._req('POST', '/accounts', {
660
  address: email,
661
  password: this.accountPassword,
662
  });
 
664
  this.accountId = createResp.data.id;
665
 
666
  // 3. 登录获取 JWT
667
+ const loginResp = await this._req('POST', '/token', {
668
  address: email,
669
  password: this.accountPassword,
670
  });
 
690
 
691
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
692
  try {
693
+ const resp = await this._req('GET', '/messages', null, {
694
  'Authorization': `Bearer ${this.jwtToken}`,
695
  });
696
  if (!resp.ok) { await sleep(pollInterval); continue; }
 
709
 
710
  // 获取邮件详情
711
  if (msg.id) {
712
+ const detailResp = await this._req('GET', `/messages/${msg.id}`, null, {
713
  'Authorization': `Bearer ${this.jwtToken}`,
714
  });
715
  if (detailResp.ok) {
 
724
  }
725
 
726
  // 如果详情解析失败,尝试 /sources 获取原始 MIME
727
+ const srcResp = await this._req('GET', `/sources/${msg.id}`, null, {
728
  'Authorization': `Bearer ${this.jwtToken}`,
729
  });
730
  if (srcResp.ok) {
 
751
  async cleanup() {
752
  if (this.jwtToken && this.accountId) {
753
  try {
754
+ await this._req('DELETE', `/accounts/${this.accountId}`, null, {
755
  'Authorization': `Bearer ${this.jwtToken}`,
756
  });
757
  } catch {}
message-convert.js CHANGED
@@ -6,6 +6,7 @@
6
  */
7
 
8
  import config from './config.js';
 
9
 
10
  /**
11
  * 从 content 字段提取纯文本
@@ -24,25 +25,55 @@ function extractText(content) {
24
 
25
  /**
26
  * OpenAI messages → chataibot text
27
- * @param {Array} messages - [{ role: 'system'|'user'|'assistant', content }]
 
 
28
  */
29
- export function openaiToText(messages) {
30
  if (!messages || !messages.length) return '';
31
 
32
  const system = messages.filter(m => m.role === 'system').map(m => extractText(m.content)).join('\n');
33
  const conversation = messages.filter(m => m.role !== 'system');
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  // 单轮: 只有一条 user 消息
36
- if (conversation.length === 1 && conversation[0].role === 'user') {
37
- const userText = extractText(conversation[0].content);
38
- return system ? `${system}\n\n${userText}` : userText;
39
  }
40
 
41
  // 多轮: 格式化为带角色标签的文本
42
  let text = '';
43
- if (system) text += `[System] ${system}\n\n`;
44
 
45
- for (const msg of conversation) {
46
  const role = msg.role === 'assistant' ? 'Assistant' : 'User';
47
  text += `[${role}] ${extractText(msg.content)}\n\n`;
48
  }
@@ -54,23 +85,58 @@ export function openaiToText(messages) {
54
  * Anthropic messages → chataibot text
55
  * @param {string|undefined} system - system prompt (Anthropic 单独字段)
56
  * @param {Array} messages - [{ role: 'user'|'assistant', content }]
 
 
57
  */
58
- export function anthropicToText(system, messages) {
59
  if (!messages || !messages.length) return system || '';
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  // 单轮
62
- if (messages.length === 1 && messages[0].role === 'user') {
63
- const userText = extractText(messages[0].content);
64
- return system ? `${system}\n\n${userText}` : userText;
 
 
65
  }
66
 
67
  // 多轮
68
  let text = '';
69
- if (system) text += `[System] ${system}\n\n`;
70
 
71
- for (const msg of messages) {
72
  const role = msg.role === 'assistant' ? 'Assistant' : 'User';
73
- text += `[${role}] ${extractText(msg.content)}\n\n`;
 
74
  }
75
 
76
  return text.trim();
 
6
  */
7
 
8
  import config from './config.js';
9
+ import { serializeTools, serializeToolsAnthropic } from './tool-prompt.js';
10
 
11
  /**
12
  * 从 content 字段提取纯文本
 
25
 
26
  /**
27
  * OpenAI messages → chataibot text
28
+ * @param {Array} messages - [{ role: 'system'|'user'|'assistant'|'tool', content }]
29
+ * @param {Array} [tools] - OpenAI tools 数组
30
+ * @param {*} [toolChoice] - tool_choice 参数
31
  */
32
+ export function openaiToText(messages, tools, toolChoice) {
33
  if (!messages || !messages.length) return '';
34
 
35
  const system = messages.filter(m => m.role === 'system').map(m => extractText(m.content)).join('\n');
36
  const conversation = messages.filter(m => m.role !== 'system');
37
 
38
+ // 工具定义注入到 system prompt 末尾
39
+ const toolPrompt = serializeTools(tools, toolChoice);
40
+
41
+ // 处理 tool 角色消息 (工具返回结果) — 转为文本格式
42
+ const processedConversation = [];
43
+ for (const msg of conversation) {
44
+ if (msg.role === 'tool') {
45
+ // 工具返回结果,格式化为文本
46
+ const toolName = msg.name || msg.tool_call_id || 'unknown';
47
+ processedConversation.push({
48
+ role: 'user',
49
+ content: `[Tool Result: ${toolName}]\n${extractText(msg.content)}`,
50
+ });
51
+ } else if (msg.role === 'assistant' && msg.tool_calls) {
52
+ // assistant 发起的工具调用 — 转为文本表示
53
+ let callText = extractText(msg.content) || '';
54
+ for (const tc of msg.tool_calls) {
55
+ const fn = tc.function || {};
56
+ callText += `\n\`\`\`tool_calls\n[{"name": "${fn.name}", "arguments": ${fn.arguments || '{}'}}]\n\`\`\``;
57
+ }
58
+ processedConversation.push({ role: 'assistant', content: callText.trim() });
59
+ } else {
60
+ processedConversation.push(msg);
61
+ }
62
+ }
63
+
64
+ const fullSystem = system + toolPrompt;
65
+
66
  // 单轮: 只有一条 user 消息
67
+ if (processedConversation.length === 1 && processedConversation[0].role === 'user') {
68
+ const userText = extractText(processedConversation[0].content);
69
+ return fullSystem ? `${fullSystem}\n\n${userText}` : userText;
70
  }
71
 
72
  // 多轮: 格式化为带角色标签的文本
73
  let text = '';
74
+ if (fullSystem) text += `[System] ${fullSystem}\n\n`;
75
 
76
+ for (const msg of processedConversation) {
77
  const role = msg.role === 'assistant' ? 'Assistant' : 'User';
78
  text += `[${role}] ${extractText(msg.content)}\n\n`;
79
  }
 
85
  * Anthropic messages → chataibot text
86
  * @param {string|undefined} system - system prompt (Anthropic 单独字段)
87
  * @param {Array} messages - [{ role: 'user'|'assistant', content }]
88
+ * @param {Array} [tools] - Anthropic tools 数组
89
+ * @param {*} [toolChoice] - tool_choice 参数
90
  */
91
+ export function anthropicToText(system, messages, tools, toolChoice) {
92
  if (!messages || !messages.length) return system || '';
93
 
94
+ // 工具定义注入到 system prompt 末尾
95
+ const toolPrompt = serializeToolsAnthropic(tools, toolChoice);
96
+ const fullSystem = (system || '') + toolPrompt;
97
+
98
+ // 处理 Anthropic content 数组中的 tool_use 和 tool_result
99
+ const processedMessages = [];
100
+ for (const msg of messages) {
101
+ if (Array.isArray(msg.content)) {
102
+ // Anthropic content 可能包含 tool_use / tool_result blocks
103
+ const parts = [];
104
+ for (const block of msg.content) {
105
+ if (block.type === 'text') {
106
+ parts.push(block.text);
107
+ } else if (block.type === 'tool_use') {
108
+ parts.push(`\`\`\`tool_calls\n[{"name": "${block.name}", "arguments": ${JSON.stringify(block.input || {})}}]\n\`\`\``);
109
+ } else if (block.type === 'tool_result') {
110
+ const resultContent = typeof block.content === 'string'
111
+ ? block.content
112
+ : Array.isArray(block.content)
113
+ ? block.content.filter(c => c.type === 'text').map(c => c.text).join('\n')
114
+ : JSON.stringify(block.content);
115
+ parts.push(`[Tool Result: ${block.tool_use_id || 'unknown'}]\n${resultContent}`);
116
+ }
117
+ }
118
+ processedMessages.push({ role: msg.role, content: parts.join('\n') });
119
+ } else {
120
+ processedMessages.push(msg);
121
+ }
122
+ }
123
+
124
  // 单轮
125
+ if (processedMessages.length === 1 && processedMessages[0].role === 'user') {
126
+ const userText = typeof processedMessages[0].content === 'string'
127
+ ? processedMessages[0].content
128
+ : extractText(processedMessages[0].content);
129
+ return fullSystem ? `${fullSystem}\n\n${userText}` : userText;
130
  }
131
 
132
  // 多轮
133
  let text = '';
134
+ if (fullSystem) text += `[System] ${fullSystem}\n\n`;
135
 
136
+ for (const msg of processedMessages) {
137
  const role = msg.role === 'assistant' ? 'Assistant' : 'User';
138
+ const content = typeof msg.content === 'string' ? msg.content : extractText(msg.content);
139
+ text += `[${role}] ${content}\n\n`;
140
  }
141
 
142
  return text.trim();
openai-adapter.js CHANGED
@@ -9,7 +9,8 @@
9
  import crypto from 'crypto';
10
  import { createContext, sendMessageStreaming } from './chat.js';
11
  import { openaiToText, resolveModel } from './message-convert.js';
12
- import { transformToOpenAISSE, collectFullResponse } from './stream-transform.js';
 
13
 
14
  const MAX_RETRY = 3;
15
 
@@ -52,10 +53,11 @@ export async function handleChatCompletions(body, res, pool) {
52
  return;
53
  }
54
 
55
- const text = openaiToText(body.messages);
56
  const model = resolveModel(body.model);
57
  const clientModel = body.model || 'gpt-4o';
58
  const stream = body.stream === true;
 
59
 
60
  if (!text) {
61
  sendError(res, 400, 'No valid message content found');
@@ -83,16 +85,29 @@ export async function handleChatCompletions(body, res, pool) {
83
  if (result.cookies) account.cookies = result.cookies;
84
 
85
  // 流获取成功 — 开始写 SSE (此后无法重试)
86
- transformToOpenAISSE(result.stream, res, clientModel, requestId, (errMsg) => {
87
- // streamingError 回调 — 检测额度耗尽
88
- const msg = (errMsg || '').toLowerCase();
89
- if (msg.includes('limit') || msg.includes('quota') || msg.includes('exhaust') || msg.includes('exceed')) {
90
- pool.release(account, { quotaExhausted: true });
91
- } else {
92
- pool.release(account, { success: false });
93
- }
94
- account = null; // 标记已释放
95
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  result.stream.on('end', () => { if (account) pool.release(account, { success: true }); });
97
  result.stream.on('error', (err) => {
98
  if (!account) return;
@@ -142,6 +157,19 @@ export async function handleChatCompletions(body, res, pool) {
142
  const full = await collectFullResponse(result.stream);
143
  pool.release(account, { success: true });
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  res.writeHead(200, {
146
  'Content-Type': 'application/json',
147
  'Access-Control-Allow-Origin': '*',
@@ -153,8 +181,8 @@ export async function handleChatCompletions(body, res, pool) {
153
  model: clientModel,
154
  choices: [{
155
  index: 0,
156
- message: { role: 'assistant', content: full.text },
157
- finish_reason: 'stop',
158
  }],
159
  usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
160
  }));
 
9
  import crypto from 'crypto';
10
  import { createContext, sendMessageStreaming } from './chat.js';
11
  import { openaiToText, resolveModel } from './message-convert.js';
12
+ import { transformToOpenAISSE, collectFullResponse, transformToOpenAISSEWithTools } from './stream-transform.js';
13
+ import { parseToolCalls, toOpenAIToolCalls } from './tool-prompt.js';
14
 
15
  const MAX_RETRY = 3;
16
 
 
53
  return;
54
  }
55
 
56
+ const text = openaiToText(body.messages, body.tools, body.tool_choice);
57
  const model = resolveModel(body.model);
58
  const clientModel = body.model || 'gpt-4o';
59
  const stream = body.stream === true;
60
+ const hasTools = body.tools && body.tools.length > 0;
61
 
62
  if (!text) {
63
  sendError(res, 400, 'No valid message content found');
 
85
  if (result.cookies) account.cookies = result.cookies;
86
 
87
  // 流获取成功 — 开始写 SSE (此后无法重试)
88
+ if (hasTools) {
89
+ // 有工具定义时,需要缓冲完整响应来检测 tool_calls
90
+ transformToOpenAISSEWithTools(result.stream, res, clientModel, requestId, (errMsg) => {
91
+ const msg = (errMsg || '').toLowerCase();
92
+ if (msg.includes('limit') || msg.includes('quota') || msg.includes('exhaust') || msg.includes('exceed')) {
93
+ pool.release(account, { quotaExhausted: true });
94
+ } else {
95
+ pool.release(account, { success: false });
96
+ }
97
+ account = null;
98
+ });
99
+ } else {
100
+ transformToOpenAISSE(result.stream, res, clientModel, requestId, (errMsg) => {
101
+ // streamingError 回调 — 检测额度耗尽
102
+ const msg = (errMsg || '').toLowerCase();
103
+ if (msg.includes('limit') || msg.includes('quota') || msg.includes('exhaust') || msg.includes('exceed')) {
104
+ pool.release(account, { quotaExhausted: true });
105
+ } else {
106
+ pool.release(account, { success: false });
107
+ }
108
+ account = null; // 标记已释放
109
+ });
110
+ }
111
  result.stream.on('end', () => { if (account) pool.release(account, { success: true }); });
112
  result.stream.on('error', (err) => {
113
  if (!account) return;
 
157
  const full = await collectFullResponse(result.stream);
158
  pool.release(account, { success: true });
159
 
160
+ // 检测工具调用
161
+ const { hasToolCalls, toolCalls, textContent } = hasTools
162
+ ? parseToolCalls(full.text)
163
+ : { hasToolCalls: false, toolCalls: [], textContent: full.text };
164
+
165
+ const message = { role: 'assistant' };
166
+ if (hasToolCalls) {
167
+ message.content = textContent || null;
168
+ message.tool_calls = toOpenAIToolCalls(toolCalls);
169
+ } else {
170
+ message.content = full.text;
171
+ }
172
+
173
  res.writeHead(200, {
174
  'Content-Type': 'application/json',
175
  'Access-Control-Allow-Origin': '*',
 
181
  model: clientModel,
182
  choices: [{
183
  index: 0,
184
+ message,
185
+ finish_reason: hasToolCalls ? 'tool_calls' : 'stop',
186
  }],
187
  usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
188
  }));
pool.js CHANGED
@@ -301,11 +301,13 @@ export class AccountPool {
301
 
302
  /**
303
  * 批量并行注册多个账号
 
 
304
  */
305
- async _registerBatch(count) {
306
  const tasks = [];
307
  for (let i = 0; i < count; i++) {
308
- tasks.push(this._registerNew());
309
  // 每个注册之间间隔一小段避免邮箱服务压力
310
  if (i < count - 1) await sleep(500);
311
  }
@@ -317,30 +319,35 @@ export class AccountPool {
317
 
318
  /**
319
  * 手动触发注册 (供 Dashboard 调用)
 
 
 
320
  * @returns {{ registering: number, queued: number }}
321
  */
322
- manualRegister(count = 5) {
323
  count = Math.min(Math.max(1, count), 50);
 
324
  const available = this._maxConcurrentRegister - this._registeringCount;
325
  const actual = Math.min(count, available);
326
  if (actual <= 0) {
327
  return { registering: this._registeringCount, queued: 0 };
328
  }
329
- this._registerBatch(actual).catch(() => {});
330
  return { registering: this._registeringCount + actual, queued: actual };
331
  }
332
 
333
  /**
334
  * 自动注册新账号 (支持并行)
 
335
  */
336
- async _registerNew() {
337
  if (this._registeringCount >= this._maxConcurrentRegister) return null;
338
  this._registeringCount++;
339
 
340
  try {
341
  this._addLog('开始注册新账号...');
342
- // 优先用配置的 provider,如果不可用则降级到 mailtm
343
- let providerName = config.mailProvider === 'manual' ? 'mailtm' : config.mailProvider;
344
  let providerOpts = config[providerName] || {};
345
  // 检查 provider 是否配置完整
346
  if (providerName === 'moemail' && !providerOpts.apiUrl) providerName = 'mailtm';
 
301
 
302
  /**
303
  * 批量并行注册多个账号
304
+ * @param {number} count
305
+ * @param {string} [provider] - 可选邮箱 provider 覆盖
306
  */
307
+ async _registerBatch(count, provider) {
308
  const tasks = [];
309
  for (let i = 0; i < count; i++) {
310
+ tasks.push(this._registerNew(provider));
311
  // 每个注册之间间隔一小段避免邮箱服务压力
312
  if (i < count - 1) await sleep(500);
313
  }
 
319
 
320
  /**
321
  * 手动触发注册 (供 Dashboard 调用)
322
+ * @param {number} count
323
+ * @param {string} [provider] - 可选邮箱 provider 覆盖
324
+ * @param {number} [concurrency] - 可选并发数覆盖
325
  * @returns {{ registering: number, queued: number }}
326
  */
327
+ manualRegister(count = 5, provider, concurrency) {
328
  count = Math.min(Math.max(1, count), 50);
329
+ if (concurrency) this._maxConcurrentRegister = Math.min(Math.max(1, concurrency), 20);
330
  const available = this._maxConcurrentRegister - this._registeringCount;
331
  const actual = Math.min(count, available);
332
  if (actual <= 0) {
333
  return { registering: this._registeringCount, queued: 0 };
334
  }
335
+ this._registerBatch(actual, provider).catch(() => {});
336
  return { registering: this._registeringCount + actual, queued: actual };
337
  }
338
 
339
  /**
340
  * 自动注册新账号 (支持并行)
341
+ * @param {string} [overrideProvider] - 可选邮箱 provider 覆盖
342
  */
343
+ async _registerNew(overrideProvider) {
344
  if (this._registeringCount >= this._maxConcurrentRegister) return null;
345
  this._registeringCount++;
346
 
347
  try {
348
  this._addLog('开始注册新账号...');
349
+ // 优先用 overrideProvider,否则用配置的 provider,如果不可用则降级到 mailtm
350
+ let providerName = overrideProvider || (config.mailProvider === 'manual' ? 'mailtm' : config.mailProvider);
351
  let providerOpts = config[providerName] || {};
352
  // 检查 provider 是否配置完整
353
  if (providerName === 'moemail' && !providerOpts.apiUrl) providerName = 'mailtm';
server.js CHANGED
@@ -117,8 +117,10 @@ const server = http.createServer(async (req, res) => {
117
  try {
118
  const body = await parseBody(req);
119
  const count = Math.min(Math.max(1, body.count || 5), 50);
120
- const result = pool.manualRegister(count);
121
- sendJson(res, 200, { ok: true, message: `\u5DF2\u63D0\u4EA4 ${result.queued} \u4E2A\u6CE8\u518C\u4EFB\u52A1`, ...result });
 
 
122
  } catch (e) {
123
  sendJson(res, 200, { ok: false, message: e.message });
124
  }
 
117
  try {
118
  const body = await parseBody(req);
119
  const count = Math.min(Math.max(1, body.count || 5), 50);
120
+ const provider = body.provider || undefined;
121
+ const concurrency = body.concurrency ? Math.min(Math.max(1, body.concurrency), 20) : undefined;
122
+ const result = pool.manualRegister(count, provider, concurrency);
123
+ sendJson(res, 200, { ok: true, message: `已提交 ${result.queued} 个注册任务`, ...result });
124
  } catch (e) {
125
  sendJson(res, 200, { ok: false, message: e.message });
126
  }
stream-transform.js CHANGED
@@ -6,6 +6,8 @@
6
  * {"type":"finalResult","data":{"mainText":"...","questions":[...]}}
7
  */
8
 
 
 
9
  /**
10
  * JSON 对象流解析器 — 通过花括号计数提取完整 JSON 对象
11
  * chataibot 返回的不是 NDJSON(无换行),而是紧凑的 JSON 对象序列
@@ -242,6 +244,292 @@ export function transformToAnthropicSSE(upstreamStream, res, model, requestId, o
242
  });
243
  }
244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  /**
246
  * 消费 NDJSON 流,收集完整响应 (用于非流式请求)
247
  */
 
6
  * {"type":"finalResult","data":{"mainText":"...","questions":[...]}}
7
  */
8
 
9
+ import { parseToolCalls, toOpenAIToolCalls, toAnthropicToolUse, detectToolCallStart } from './tool-prompt.js';
10
+
11
  /**
12
  * JSON 对象流解析器 — 通过花括号计数提取完整 JSON 对象
13
  * chataibot 返回的不是 NDJSON(无换行),而是紧凑的 JSON 对象序列
 
244
  });
245
  }
246
 
247
+ /**
248
+ * chataibot → OpenAI SSE (带工具调用检测)
249
+ *
250
+ * 策略: 先正常流式输出文本。当检测到 ```tool_calls 开头时,
251
+ * 停止流式文本输出,缓冲剩余内容。流结束后解析完整文本,
252
+ * 如果包含工具调用则发送 tool_calls chunk,否则补发剩余文本。
253
+ */
254
+ export function transformToOpenAISSEWithTools(upstreamStream, res, model, requestId, onStreamError) {
255
+ res.writeHead(200, {
256
+ 'Content-Type': 'text/event-stream',
257
+ 'Cache-Control': 'no-cache',
258
+ 'Connection': 'keep-alive',
259
+ 'Access-Control-Allow-Origin': '*',
260
+ });
261
+
262
+ const ts = Math.floor(Date.now() / 1000);
263
+ let fullText = '';
264
+ let toolCallDetected = false;
265
+ let streamError = false;
266
+
267
+ function writeChunk(delta, finishReason = null) {
268
+ const obj = {
269
+ id: requestId,
270
+ object: 'chat.completion.chunk',
271
+ created: ts,
272
+ model,
273
+ choices: [{ index: 0, delta, finish_reason: finishReason }],
274
+ };
275
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
276
+ }
277
+
278
+ writeChunk({ role: 'assistant', content: '' });
279
+
280
+ const parser = createJsonStreamParser((obj) => {
281
+ switch (obj.type) {
282
+ case 'chunk':
283
+ fullText += obj.data;
284
+ if (!toolCallDetected) {
285
+ if (detectToolCallStart(fullText)) {
286
+ toolCallDetected = true;
287
+ // 不再流式输出后续 chunk
288
+ } else {
289
+ writeChunk({ content: obj.data });
290
+ }
291
+ }
292
+ break;
293
+
294
+ case 'reasoningContent':
295
+ if (!toolCallDetected) {
296
+ writeChunk({ reasoning_content: obj.data });
297
+ }
298
+ break;
299
+
300
+ case 'finalResult':
301
+ if (obj.data?.mainText) fullText = obj.data.mainText;
302
+ // 最终处理在 end 事件中
303
+ break;
304
+
305
+ case 'streamingError':
306
+ streamError = true;
307
+ if (onStreamError) onStreamError(obj.data);
308
+ break;
309
+ }
310
+ });
311
+
312
+ upstreamStream.on('data', (chunk) => parser.feed(chunk));
313
+ upstreamStream.on('end', () => {
314
+ parser.flush();
315
+ if (res.writableEnded) return;
316
+
317
+ if (streamError) {
318
+ writeChunk({}, 'stop');
319
+ res.write('data: [DONE]\n\n');
320
+ res.end();
321
+ return;
322
+ }
323
+
324
+ // 解析完整文本中的 tool calls
325
+ const { hasToolCalls, toolCalls, textContent } = parseToolCalls(fullText);
326
+
327
+ if (hasToolCalls) {
328
+ // 如果之前已经流式输出了部分文本 (tool_calls 标记之前的文本)
329
+ // 而解析后 textContent 为空或很少,无需额外处理
330
+
331
+ // 发送 tool_calls
332
+ const openaiToolCalls = toOpenAIToolCalls(toolCalls);
333
+ for (let i = 0; i < openaiToolCalls.length; i++) {
334
+ const tc = openaiToolCalls[i];
335
+ // 首个 chunk: 含 tool call id, type, function.name, function.arguments 开始部分
336
+ writeChunk({
337
+ tool_calls: [{
338
+ index: i,
339
+ id: tc.id,
340
+ type: 'function',
341
+ function: { name: tc.function.name, arguments: tc.function.arguments },
342
+ }],
343
+ });
344
+ }
345
+ writeChunk({}, 'tool_calls');
346
+ } else {
347
+ // 没有工具调用 — 如果之前因检测到 ```tool_calls 停了输出
348
+ // 需要补发被缓冲的文本
349
+ if (toolCallDetected) {
350
+ // 找到之前已输出的部分,补发剩余
351
+ const markerIdx = fullText.indexOf('```tool_calls');
352
+ if (markerIdx >= 0) {
353
+ writeChunk({ content: fullText.substring(markerIdx) });
354
+ }
355
+ }
356
+ writeChunk({}, 'stop');
357
+ }
358
+
359
+ res.write('data: [DONE]\n\n');
360
+ res.end();
361
+ });
362
+
363
+ upstreamStream.on('error', () => {
364
+ if (!res.writableEnded) {
365
+ res.write('data: [DONE]\n\n');
366
+ res.end();
367
+ }
368
+ });
369
+ }
370
+
371
+ /**
372
+ * chataibot → Anthropic SSE (带工具调用检测)
373
+ *
374
+ * 同 OpenAI 版本,先流式输出文本,检测到工具调用后缓冲,
375
+ * 最终解析并发送 tool_use content blocks。
376
+ */
377
+ export function transformToAnthropicSSEWithTools(upstreamStream, res, model, requestId, onStreamError) {
378
+ res.writeHead(200, {
379
+ 'Content-Type': 'text/event-stream',
380
+ 'Cache-Control': 'no-cache',
381
+ 'Connection': 'keep-alive',
382
+ 'Access-Control-Allow-Origin': '*',
383
+ });
384
+
385
+ function writeEvent(event, data) {
386
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
387
+ }
388
+
389
+ let headerSent = false;
390
+ let textBlockIndex = 0;
391
+ let fullText = '';
392
+ let toolCallDetected = false;
393
+ let streamError = false;
394
+
395
+ function ensureHeader() {
396
+ if (headerSent) return;
397
+ headerSent = true;
398
+ writeEvent('message_start', {
399
+ type: 'message_start',
400
+ message: {
401
+ id: requestId,
402
+ type: 'message',
403
+ role: 'assistant',
404
+ model,
405
+ content: [],
406
+ stop_reason: null,
407
+ usage: { input_tokens: 0, output_tokens: 0 },
408
+ },
409
+ });
410
+ writeEvent('content_block_start', {
411
+ type: 'content_block_start',
412
+ index: textBlockIndex,
413
+ content_block: { type: 'text', text: '' },
414
+ });
415
+ }
416
+
417
+ const parser = createJsonStreamParser((obj) => {
418
+ switch (obj.type) {
419
+ case 'chunk':
420
+ fullText += obj.data;
421
+ if (!toolCallDetected) {
422
+ if (detectToolCallStart(fullText)) {
423
+ toolCallDetected = true;
424
+ } else {
425
+ ensureHeader();
426
+ writeEvent('content_block_delta', {
427
+ type: 'content_block_delta',
428
+ index: textBlockIndex,
429
+ delta: { type: 'text_delta', text: obj.data },
430
+ });
431
+ }
432
+ }
433
+ break;
434
+
435
+ case 'reasoningContent':
436
+ if (!toolCallDetected) {
437
+ ensureHeader();
438
+ writeEvent('content_block_delta', {
439
+ type: 'content_block_delta',
440
+ index: textBlockIndex,
441
+ delta: { type: 'text_delta', text: obj.data },
442
+ });
443
+ }
444
+ break;
445
+
446
+ case 'finalResult':
447
+ if (obj.data?.mainText) fullText = obj.data.mainText;
448
+ break;
449
+
450
+ case 'streamingError':
451
+ streamError = true;
452
+ if (onStreamError) onStreamError(obj.data);
453
+ break;
454
+ }
455
+ });
456
+
457
+ upstreamStream.on('data', (chunk) => parser.feed(chunk));
458
+ upstreamStream.on('end', () => {
459
+ parser.flush();
460
+ if (res.writableEnded) return;
461
+ ensureHeader();
462
+
463
+ if (streamError) {
464
+ writeEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex });
465
+ writeEvent('message_delta', {
466
+ type: 'message_delta',
467
+ delta: { stop_reason: 'end_turn' },
468
+ usage: { output_tokens: 0 },
469
+ });
470
+ writeEvent('message_stop', { type: 'message_stop' });
471
+ res.end();
472
+ return;
473
+ }
474
+
475
+ const { hasToolCalls, toolCalls, textContent } = parseToolCalls(fullText);
476
+
477
+ if (hasToolCalls) {
478
+ // 关闭文本 block
479
+ writeEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex });
480
+
481
+ // 发送 tool_use blocks
482
+ const toolUseBlocks = toAnthropicToolUse(toolCalls);
483
+ for (let i = 0; i < toolUseBlocks.length; i++) {
484
+ const blockIdx = textBlockIndex + 1 + i;
485
+ const tu = toolUseBlocks[i];
486
+ writeEvent('content_block_start', {
487
+ type: 'content_block_start',
488
+ index: blockIdx,
489
+ content_block: { type: 'tool_use', id: tu.id, name: tu.name, input: {} },
490
+ });
491
+ writeEvent('content_block_delta', {
492
+ type: 'content_block_delta',
493
+ index: blockIdx,
494
+ delta: { type: 'input_json_delta', partial_json: JSON.stringify(tu.input) },
495
+ });
496
+ writeEvent('content_block_stop', { type: 'content_block_stop', index: blockIdx });
497
+ }
498
+
499
+ writeEvent('message_delta', {
500
+ type: 'message_delta',
501
+ delta: { stop_reason: 'tool_use' },
502
+ usage: { output_tokens: 0 },
503
+ });
504
+ } else {
505
+ // 没有工具调用 — 补发被缓冲的文本
506
+ if (toolCallDetected) {
507
+ const markerIdx = fullText.indexOf('```tool_calls');
508
+ if (markerIdx >= 0) {
509
+ writeEvent('content_block_delta', {
510
+ type: 'content_block_delta',
511
+ index: textBlockIndex,
512
+ delta: { type: 'text_delta', text: fullText.substring(markerIdx) },
513
+ });
514
+ }
515
+ }
516
+ writeEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex });
517
+ writeEvent('message_delta', {
518
+ type: 'message_delta',
519
+ delta: { stop_reason: 'end_turn' },
520
+ usage: { output_tokens: 0 },
521
+ });
522
+ }
523
+
524
+ writeEvent('message_stop', { type: 'message_stop' });
525
+ res.end();
526
+ });
527
+
528
+ upstreamStream.on('error', () => {
529
+ if (!res.writableEnded) res.end();
530
+ });
531
+ }
532
+
533
  /**
534
  * 消费 NDJSON 流,收集完整响应 (用于非流式请求)
535
  */
tool-prompt.js ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * tool-prompt.js - 工具调用提示词注入 & 响应解析
3
+ *
4
+ * 由于 chataibot.pro 不原生支持 function calling / tool use,
5
+ * 我们通过提示词注入实现:
6
+ * 1. 将 tools 定义序列化到 system prompt
7
+ * 2. 指示模型在需要调用工具时输出特定 JSON 格式
8
+ * 3. 从模型响应中解析 JSON 工具调用
9
+ * 4. 转换回 OpenAI tool_calls / Anthropic tool_use 标准格式
10
+ */
11
+
12
+ /**
13
+ * 将 JSON Schema 参数描述转为简洁可读的文本
14
+ */
15
+ function describeParams(schema) {
16
+ if (!schema || !schema.properties) return ' (no parameters)';
17
+ const required = new Set(schema.required || []);
18
+ const lines = [];
19
+ for (const [name, prop] of Object.entries(schema.properties)) {
20
+ const req = required.has(name) ? ' (required)' : ' (optional)';
21
+ const type = prop.type || 'any';
22
+ const desc = prop.description ? ` - ${prop.description}` : '';
23
+ const enumStr = prop.enum ? ` [enum: ${prop.enum.join(', ')}]` : '';
24
+ lines.push(` - ${name}: ${type}${req}${desc}${enumStr}`);
25
+ }
26
+ return lines.join('\n');
27
+ }
28
+
29
+ /**
30
+ * 将 OpenAI 格式 tools 数组序列化为注入 prompt
31
+ * @param {Array} tools - OpenAI tools array [{ type: 'function', function: { name, description, parameters } }]
32
+ * @param {string} [toolChoice] - 'auto' | 'none' | 'required' | { type: 'function', function: { name } }
33
+ * @returns {string} 注入到 system prompt 的文本
34
+ */
35
+ export function serializeTools(tools, toolChoice) {
36
+ if (!tools || tools.length === 0) return '';
37
+
38
+ // tool_choice=none 时不注入工具
39
+ if (toolChoice === 'none') return '';
40
+
41
+ // 取第一个工具生成示例
42
+ const exampleTool = tools[0]?.function || tools[0] || { name: 'example_func' };
43
+ const exampleArgs = {};
44
+ const exampleParams = (exampleTool.parameters || {}).properties || {};
45
+ for (const [k, v] of Object.entries(exampleParams)) {
46
+ if (v.type === 'number' || v.type === 'integer') exampleArgs[k] = 0;
47
+ else if (v.type === 'boolean') exampleArgs[k] = true;
48
+ else if (v.type === 'array') exampleArgs[k] = [];
49
+ else if (v.type === 'object') exampleArgs[k] = {};
50
+ else exampleArgs[k] = 'value';
51
+ if (Object.keys(exampleArgs).length >= 2) break; // 示例最多2个参数
52
+ }
53
+
54
+ let prompt = `\n\n---\nYou have access to the following tools/functions. When you need to call a tool, you MUST respond ONLY with a JSON block wrapped in \`\`\`tool_calls\`\`\` markers.
55
+
56
+ FORMAT (follow this EXACTLY):
57
+ \`\`\`tool_calls
58
+ [{"name": "${exampleTool.name}", "arguments": ${JSON.stringify(exampleArgs)}}]
59
+ \`\`\`
60
+
61
+ CRITICAL RULES:
62
+ 1. The response must contain ONLY the \`\`\`tool_calls\`\`\` block when calling tools — no explanation, no extra text before or after.
63
+ 2. "arguments" must be a valid JSON object matching the function's parameter schema.
64
+ 3. The JSON array can contain one or multiple tool calls: [{"name": "func1", "arguments": {...}}, {"name": "func2", "arguments": {...}}]
65
+ 4. If you do NOT need to call any tool, respond normally with plain text (no \`\`\`tool_calls\`\`\` block).
66
+
67
+ Available tools:\n`;
68
+
69
+ for (const tool of tools) {
70
+ const fn = tool.function || tool;
71
+ prompt += `\n### ${fn.name}\n`;
72
+ if (fn.description) prompt += `${fn.description}\n`;
73
+ prompt += `Parameters:\n${describeParams(fn.parameters)}\n`;
74
+ }
75
+
76
+ // tool_choice 处理
77
+ if (toolChoice === 'required') {
78
+ prompt += `\nIMPORTANT: You MUST call at least one tool. Always respond with a tool_calls block.\n`;
79
+ } else if (typeof toolChoice === 'object' && toolChoice?.function?.name) {
80
+ prompt += `\nIMPORTANT: You MUST call the tool "${toolChoice.function.name}". Always respond with a tool_calls block containing this tool.\n`;
81
+ }
82
+
83
+ prompt += '---\n';
84
+ return prompt;
85
+ }
86
+
87
+ /**
88
+ * 将 Anthropic 格式 tools 转为通用格式再序列化
89
+ * @param {Array} tools - Anthropic tools [{ name, description, input_schema }]
90
+ * @param {object} [toolChoice] - { type: 'auto'|'any'|'tool', name? }
91
+ */
92
+ export function serializeToolsAnthropic(tools, toolChoice) {
93
+ if (!tools || tools.length === 0) return '';
94
+
95
+ // 转为 OpenAI 格式
96
+ const openaiTools = tools.map(t => ({
97
+ type: 'function',
98
+ function: {
99
+ name: t.name,
100
+ description: t.description || '',
101
+ parameters: t.input_schema || {},
102
+ },
103
+ }));
104
+
105
+ // 映射 toolChoice
106
+ let choice = 'auto';
107
+ if (toolChoice) {
108
+ if (toolChoice.type === 'any') choice = 'required';
109
+ else if (toolChoice.type === 'tool') choice = { function: { name: toolChoice.name } };
110
+ else if (toolChoice.type === 'auto') choice = 'auto';
111
+ }
112
+
113
+ return serializeTools(openaiTools, choice);
114
+ }
115
+
116
+ /**
117
+ * 从模型的文本响应中解析工具调用
118
+ * @param {string} text - 模型完整回复
119
+ * @returns {{ hasToolCalls: boolean, toolCalls: Array, textContent: string }}
120
+ *
121
+ * toolCalls: [{ name: string, arguments: object }]
122
+ * textContent: 工具调用之��的文本部分 (通常为空)
123
+ */
124
+ export function parseToolCalls(text) {
125
+ if (!text) return { hasToolCalls: false, toolCalls: [], textContent: text || '' };
126
+
127
+ // 匹配 ```tool_calls ... ``` 代码块
128
+ const blockRegex = /```tool_calls\s*\n?([\s\S]*?)```/;
129
+ const match = text.match(blockRegex);
130
+
131
+ if (match) {
132
+ try {
133
+ let parsed = JSON.parse(match[1].trim());
134
+ // 支持单个对象或数组
135
+ if (!Array.isArray(parsed)) parsed = [parsed];
136
+
137
+ const toolCalls = parsed
138
+ .filter(tc => tc && tc.name)
139
+ .map(tc => ({
140
+ name: tc.name,
141
+ arguments: tc.arguments || {},
142
+ }));
143
+
144
+ if (toolCalls.length > 0) {
145
+ // 去掉 tool_calls 块后的剩余文本
146
+ const textContent = text.replace(blockRegex, '').trim();
147
+ return { hasToolCalls: true, toolCalls, textContent };
148
+ }
149
+ } catch {}
150
+ }
151
+
152
+ // 备用: 匹配不带 tool_calls 标签的 JSON 块 (有些模型不严格遵循格式)
153
+ const jsonBlockRegex = /```(?:json)?\s*\n?(\[\s*\{[\s\S]*?"name"[\s\S]*?\}\s*\])\s*```/;
154
+ const jsonMatch = text.match(jsonBlockRegex);
155
+
156
+ if (jsonMatch) {
157
+ const result = tryParseToolArray(jsonMatch[1], text, jsonBlockRegex);
158
+ if (result) return result;
159
+ }
160
+
161
+ // 备用2: 裸 JSON 对象 (无代码块包裹) — 一些小模型会直接输出 JSON
162
+ // 只在文本末尾检测,避免误匹配正文中的 JSON
163
+ const tailText = text.slice(-2000); // 只看最后 2000 字符
164
+ const nakedJsonRegex = /(\[\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:[\s\S]*?\}\s*\])\s*$/;
165
+ const nakedMatch = tailText.match(nakedJsonRegex);
166
+
167
+ if (nakedMatch) {
168
+ const result = tryParseToolArray(nakedMatch[1], text, new RegExp(nakedMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&').slice(0, 100) + '[\\s\\S]*'));
169
+ if (result) return result;
170
+ }
171
+
172
+ return { hasToolCalls: false, toolCalls: [], textContent: text };
173
+ }
174
+
175
+ /**
176
+ * 尝试解析 JSON 数组为 tool calls
177
+ */
178
+ function tryParseToolArray(jsonStr, fullText, removeRegex) {
179
+ try {
180
+ let parsed = JSON.parse(jsonStr.trim());
181
+ if (!Array.isArray(parsed)) parsed = [parsed];
182
+ const toolCalls = parsed
183
+ .filter(tc => tc && tc.name)
184
+ .map(tc => ({
185
+ name: tc.name,
186
+ arguments: tc.arguments || {},
187
+ }));
188
+ if (toolCalls.length > 0) {
189
+ const textContent = fullText.replace(removeRegex, '').trim();
190
+ return { hasToolCalls: true, toolCalls, textContent };
191
+ }
192
+ } catch {}
193
+ return null;
194
+ }
195
+
196
+ /**
197
+ * 将解析到的 toolCalls 转为 OpenAI 格式的 tool_calls 数组
198
+ */
199
+ export function toOpenAIToolCalls(toolCalls) {
200
+ return toolCalls.map((tc, i) => ({
201
+ id: `call_${Date.now().toString(36)}_${i}`,
202
+ type: 'function',
203
+ function: {
204
+ name: tc.name,
205
+ arguments: typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments),
206
+ },
207
+ }));
208
+ }
209
+
210
+ /**
211
+ * 将解析到的 toolCalls 转为 Anthropic 格式的 tool_use content blocks
212
+ */
213
+ export function toAnthropicToolUse(toolCalls) {
214
+ return toolCalls.map((tc, i) => ({
215
+ type: 'tool_use',
216
+ id: `toolu_${Date.now().toString(36)}_${i}`,
217
+ name: tc.name,
218
+ input: typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : tc.arguments,
219
+ }));
220
+ }
221
+
222
+ /**
223
+ * 检测流式文本中是否开始了 tool_calls 块
224
+ * 用于流式模式下判断何时需要缓冲而非直接输出
225
+ */
226
+ export function detectToolCallStart(accumulatedText) {
227
+ // 检测是否出现了 ```tool_calls 的开头
228
+ return accumulatedText.includes('```tool_calls');
229
+ }
230
+
231
+ /**
232
+ * 检测 tool_calls 块是否完整 (闭合的 ``` 标记)
233
+ */
234
+ export function detectToolCallEnd(accumulatedText) {
235
+ const start = accumulatedText.indexOf('```tool_calls');
236
+ if (start < 0) return false;
237
+ const afterStart = accumulatedText.substring(start + '```tool_calls'.length);
238
+ return afterStart.includes('```');
239
+ }
ui.js CHANGED
@@ -97,9 +97,12 @@ tr:hover td { background: #fafbfc; }
97
  .empty { text-align: center; padding: 40px; color: var(--text-sec); font-size: .9rem; }
98
 
99
  /* Register bar */
100
- .reg-bar { display: flex; align-items: center; gap: 10px; }
 
101
  .reg-input { width: 60px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: .85rem; text-align: center; outline: none; }
102
  .reg-input:focus { border-color: var(--c-inuse); }
 
 
103
  .btn { padding: 6px 16px; border: none; border-radius: 6px; font-size: .82rem; font-weight: 500; cursor: pointer; transition: background .15s, opacity .15s; }
104
  .btn-primary { background: var(--c-inuse); color: #fff; }
105
  .btn-primary:hover { background: #2563eb; }
@@ -163,8 +166,24 @@ tr:hover td { background: #fafbfc; }
163
  <div class="section-head">
164
  <span>\u8D26\u53F7\u7BA1\u7406</span>
165
  <div class="reg-bar">
166
- <span style="font-size:.82rem;font-weight:400;color:var(--text-sec)">\u6CE8\u518C\u6570\u91CF</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  <input type="number" class="reg-input" id="regCount" value="5" min="1" max="50">
 
 
168
  <button class="btn btn-primary" id="regBtn" onclick="doRegister()">\u624B\u52A8\u6CE8\u518C</button>
169
  <span class="reg-msg" id="regMsg"></span>
170
  </div>
@@ -372,15 +391,19 @@ async function doRegister() {
372
  var btn = document.getElementById("regBtn");
373
  var msg = document.getElementById("regMsg");
374
  var count = parseInt(document.getElementById("regCount").value) || 5;
 
 
375
  btn.disabled = true;
376
  btn.textContent = "\u6CE8\u518C\u4E2D...";
377
  msg.className = "reg-msg";
378
  msg.textContent = "";
379
  try {
 
 
380
  var r = await fetch("/pool/register", {
381
  method: "POST",
382
  headers: { "Content-Type": "application/json" },
383
- body: JSON.stringify({ count: count })
384
  }).then(function(r){ return r.json() });
385
  msg.className = "reg-msg " + (r.ok ? "ok" : "err");
386
  msg.textContent = r.message;
 
97
  .empty { text-align: center; padding: 40px; color: var(--text-sec); font-size: .9rem; }
98
 
99
  /* Register bar */
100
+ .reg-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
101
+ .reg-label { font-size: .82rem; font-weight: 400; color: var(--text-sec); }
102
  .reg-input { width: 60px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: .85rem; text-align: center; outline: none; }
103
  .reg-input:focus { border-color: var(--c-inuse); }
104
+ .reg-select { padding: 5px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: .82rem; outline: none; background: var(--bg-card); color: var(--text); cursor: pointer; max-width: 140px; }
105
+ .reg-select:focus { border-color: var(--c-inuse); }
106
  .btn { padding: 6px 16px; border: none; border-radius: 6px; font-size: .82rem; font-weight: 500; cursor: pointer; transition: background .15s, opacity .15s; }
107
  .btn-primary { background: var(--c-inuse); color: #fff; }
108
  .btn-primary:hover { background: #2563eb; }
 
166
  <div class="section-head">
167
  <span>\u8D26\u53F7\u7BA1\u7406</span>
168
  <div class="reg-bar">
169
+ <span class="reg-label">\u90AE\u7BB1</span>
170
+ <select class="reg-select" id="regProvider">
171
+ <option value="">auto (\u914D\u7F6E\u9ED8\u8BA4)</option>
172
+ <option value="mailtm">Mail.tm</option>
173
+ <option value="guerrilla">Guerrilla</option>
174
+ <option value="tempmail">TempMail</option>
175
+ <option value="tempmailio">TempMail.io</option>
176
+ <option value="dropmail">Dropmail</option>
177
+ <option value="linshiyou">linshiyou</option>
178
+ <option value="gptmail">GPTMail</option>
179
+ <option value="moemail">MoeMail</option>
180
+ <option value="duckmail">DuckMail</option>
181
+ <option value="catchall">CatchAll</option>
182
+ </select>
183
+ <span class="reg-label">\u6570\u91CF</span>
184
  <input type="number" class="reg-input" id="regCount" value="5" min="1" max="50">
185
+ <span class="reg-label">\u5E76\u53D1</span>
186
+ <input type="number" class="reg-input" id="regConcurrency" value="5" min="1" max="10">
187
  <button class="btn btn-primary" id="regBtn" onclick="doRegister()">\u624B\u52A8\u6CE8\u518C</button>
188
  <span class="reg-msg" id="regMsg"></span>
189
  </div>
 
391
  var btn = document.getElementById("regBtn");
392
  var msg = document.getElementById("regMsg");
393
  var count = parseInt(document.getElementById("regCount").value) || 5;
394
+ var provider = document.getElementById("regProvider").value || undefined;
395
+ var concurrency = parseInt(document.getElementById("regConcurrency").value) || 5;
396
  btn.disabled = true;
397
  btn.textContent = "\u6CE8\u518C\u4E2D...";
398
  msg.className = "reg-msg";
399
  msg.textContent = "";
400
  try {
401
+ var payload = { count: count, concurrency: concurrency };
402
+ if (provider) payload.provider = provider;
403
  var r = await fetch("/pool/register", {
404
  method: "POST",
405
  headers: { "Content-Type": "application/json" },
406
+ body: JSON.stringify(payload)
407
  }).then(function(r){ return r.json() });
408
  msg.className = "reg-msg " + (r.ok ? "ok" : "err");
409
  msg.textContent = r.message;