ikun2 / tool-prompt.js
bingn's picture
Upload 19 files
99f8658 verified
/**
* tool-prompt.js - 工具调用提示词注入 & 响应解析
*
* 由于 chataibot.pro 不原生支持 function calling / tool use,
* 我们通过提示词注入实现:
* 1. 将 tools 定义序列化到 system prompt
* 2. 指示模型在需要调用工具时输出特定 JSON 格式
* 3. 从模型响应中解析 JSON 工具调用
* 4. 转换回 OpenAI tool_calls / Anthropic tool_use 标准格式
*/
/**
* 将 JSON Schema 参数描述转为简洁可读的文本
*/
function describeParams(schema) {
if (!schema || !schema.properties) return ' (no parameters)';
const required = new Set(schema.required || []);
const lines = [];
for (const [name, prop] of Object.entries(schema.properties)) {
const req = required.has(name) ? ' (required)' : ' (optional)';
const type = prop.type || 'any';
const desc = prop.description ? ` - ${prop.description}` : '';
const enumStr = prop.enum ? ` [enum: ${prop.enum.join(', ')}]` : '';
lines.push(` - ${name}: ${type}${req}${desc}${enumStr}`);
}
return lines.join('\n');
}
/**
* 将 OpenAI 格式 tools 数组序列化为注入 prompt
* @param {Array} tools - OpenAI tools array [{ type: 'function', function: { name, description, parameters } }]
* @param {string} [toolChoice] - 'auto' | 'none' | 'required' | { type: 'function', function: { name } }
* @returns {string} 注入到 system prompt 的文本
*/
export function serializeTools(tools, toolChoice) {
if (!tools || tools.length === 0) return '';
// tool_choice=none 时不注入工具
if (toolChoice === 'none') return '';
// 取第一个工具生成示例
const exampleTool = tools[0]?.function || tools[0] || { name: 'example_func' };
const exampleArgs = {};
const exampleParams = (exampleTool.parameters || {}).properties || {};
for (const [k, v] of Object.entries(exampleParams)) {
if (v.type === 'number' || v.type === 'integer') exampleArgs[k] = 0;
else if (v.type === 'boolean') exampleArgs[k] = true;
else if (v.type === 'array') exampleArgs[k] = [];
else if (v.type === 'object') exampleArgs[k] = {};
else exampleArgs[k] = 'value';
if (Object.keys(exampleArgs).length >= 2) break; // 示例最多2个参数
}
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.
FORMAT (follow this EXACTLY):
\`\`\`tool_calls
[{"name": "${exampleTool.name}", "arguments": ${JSON.stringify(exampleArgs)}}]
\`\`\`
CRITICAL RULES:
1. The response must contain ONLY the \`\`\`tool_calls\`\`\` block when calling tools — no explanation, no extra text before or after.
2. "arguments" must be a valid JSON object matching the function's parameter schema.
3. The JSON array can contain one or multiple tool calls: [{"name": "func1", "arguments": {...}}, {"name": "func2", "arguments": {...}}]
4. If you do NOT need to call any tool, respond normally with plain text (no \`\`\`tool_calls\`\`\` block).
Available tools:\n`;
for (const tool of tools) {
const fn = tool.function || tool;
prompt += `\n### ${fn.name}\n`;
if (fn.description) prompt += `${fn.description}\n`;
prompt += `Parameters:\n${describeParams(fn.parameters)}\n`;
}
// tool_choice 处理
if (toolChoice === 'required') {
prompt += `\nIMPORTANT: You MUST call at least one tool. Always respond with a tool_calls block.\n`;
} else if (typeof toolChoice === 'object' && toolChoice?.function?.name) {
prompt += `\nIMPORTANT: You MUST call the tool "${toolChoice.function.name}". Always respond with a tool_calls block containing this tool.\n`;
}
prompt += '---\n';
return prompt;
}
/**
* 将 Anthropic 格式 tools 转为通用格式再序列化
* @param {Array} tools - Anthropic tools [{ name, description, input_schema }]
* @param {object} [toolChoice] - { type: 'auto'|'any'|'tool', name? }
*/
export function serializeToolsAnthropic(tools, toolChoice) {
if (!tools || tools.length === 0) return '';
// 转为 OpenAI 格式
const openaiTools = tools.map(t => ({
type: 'function',
function: {
name: t.name,
description: t.description || '',
parameters: t.input_schema || {},
},
}));
// 映射 toolChoice
let choice = 'auto';
if (toolChoice) {
if (toolChoice.type === 'any') choice = 'required';
else if (toolChoice.type === 'tool') choice = { function: { name: toolChoice.name } };
else if (toolChoice.type === 'auto') choice = 'auto';
}
return serializeTools(openaiTools, choice);
}
/**
* 从模型的文本响应中解析工具调用
* @param {string} text - 模型完整回复
* @returns {{ hasToolCalls: boolean, toolCalls: Array, textContent: string }}
*
* toolCalls: [{ name: string, arguments: object }]
* textContent: 工具调用之外的文本部分 (通常为空)
*/
export function parseToolCalls(text) {
if (!text) return { hasToolCalls: false, toolCalls: [], textContent: text || '' };
// 匹配 ```tool_calls ... ``` 代码块
const blockRegex = /```tool_calls\s*\n?([\s\S]*?)```/;
const match = text.match(blockRegex);
if (match) {
try {
let parsed = JSON.parse(match[1].trim());
// 支持单个对象或数组
if (!Array.isArray(parsed)) parsed = [parsed];
const toolCalls = parsed
.filter(tc => tc && tc.name)
.map(tc => ({
name: tc.name,
arguments: tc.arguments || {},
}));
if (toolCalls.length > 0) {
// 去掉 tool_calls 块后的剩余文本
const textContent = text.replace(blockRegex, '').trim();
return { hasToolCalls: true, toolCalls, textContent };
}
} catch {}
}
// 备用: 匹配不带 tool_calls 标签的 JSON 块 (有些模型不严格遵循格式)
const jsonBlockRegex = /```(?:json)?\s*\n?(\[\s*\{[\s\S]*?"name"[\s\S]*?\}\s*\])\s*```/;
const jsonMatch = text.match(jsonBlockRegex);
if (jsonMatch) {
const result = tryParseToolArray(jsonMatch[1], text, jsonBlockRegex);
if (result) return result;
}
// 备用2: 裸 JSON 对象 (无代码块包裹) — 一些小模型会直接输出 JSON
// 只在文本末尾检测,避免误匹配正文中的 JSON
const tailText = text.slice(-2000); // 只看最后 2000 字符
const nakedJsonRegex = /(\[\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:[\s\S]*?\}\s*\])\s*$/;
const nakedMatch = tailText.match(nakedJsonRegex);
if (nakedMatch) {
const result = tryParseToolArray(nakedMatch[1], text, new RegExp(nakedMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&').slice(0, 100) + '[\\s\\S]*'));
if (result) return result;
}
return { hasToolCalls: false, toolCalls: [], textContent: text };
}
/**
* 尝试解析 JSON 数组为 tool calls
*/
function tryParseToolArray(jsonStr, fullText, removeRegex) {
try {
let parsed = JSON.parse(jsonStr.trim());
if (!Array.isArray(parsed)) parsed = [parsed];
const toolCalls = parsed
.filter(tc => tc && tc.name)
.map(tc => ({
name: tc.name,
arguments: tc.arguments || {},
}));
if (toolCalls.length > 0) {
const textContent = fullText.replace(removeRegex, '').trim();
return { hasToolCalls: true, toolCalls, textContent };
}
} catch {}
return null;
}
/**
* 将解析到的 toolCalls 转为 OpenAI 格式的 tool_calls 数组
*/
export function toOpenAIToolCalls(toolCalls) {
return toolCalls.map((tc, i) => ({
id: `call_${Date.now().toString(36)}_${i}`,
type: 'function',
function: {
name: tc.name,
arguments: typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments),
},
}));
}
/**
* 将解析到的 toolCalls 转为 Anthropic 格式的 tool_use content blocks
*/
export function toAnthropicToolUse(toolCalls) {
return toolCalls.map((tc, i) => ({
type: 'tool_use',
id: `toolu_${Date.now().toString(36)}_${i}`,
name: tc.name,
input: typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : tc.arguments,
}));
}
/**
* 检测流式文本中是否开始了 tool_calls 块
* 用于流式模式下判断何时需要缓冲而非直接输出
*/
export function detectToolCallStart(accumulatedText) {
// 检测是否出现了 ```tool_calls 的开头
return accumulatedText.includes('```tool_calls');
}
/**
* 检测 tool_calls 块是否完整 (闭合的 ``` 标记)
*/
export function detectToolCallEnd(accumulatedText) {
const start = accumulatedText.indexOf('```tool_calls');
if (start < 0) return false;
const afterStart = accumulatedText.substring(start + '```tool_calls'.length);
return afterStart.includes('```');
}