Upload 19 files
Browse files- anthropic-adapter.js +43 -13
- mail.js +32 -7
- message-convert.js +80 -14
- openai-adapter.js +42 -14
- pool.js +14 -7
- server.js +4 -2
- stream-transform.js +288 -0
- tool-prompt.js +239 -0
- 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 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 (
|
| 37 |
-
const userText = extractText(
|
| 38 |
-
return
|
| 39 |
}
|
| 40 |
|
| 41 |
// 多轮: 格式化为带角色标签的文本
|
| 42 |
let text = '';
|
| 43 |
-
if (
|
| 44 |
|
| 45 |
-
for (const msg of
|
| 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 (
|
| 63 |
-
const userText =
|
| 64 |
-
|
|
|
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
// 多轮
|
| 68 |
let text = '';
|
| 69 |
-
if (
|
| 70 |
|
| 71 |
-
for (const msg of
|
| 72 |
const role = msg.role === 'assistant' ? 'Assistant' : 'User';
|
| 73 |
-
|
|
|
|
| 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 |
-
|
| 87 |
-
//
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 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
|
| 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
|
| 121 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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;
|