feat: 实现真实算术工具调用循环,补全协议双向映射闭环

- 新增 tools.ts: calculate 工具定义 + 安全表达式执行引擎
- 重写 sendMessage: 非流式 tool execution loop,最多 5 轮迭代
- Live 模式使用独立系统上下文(仅含 calculate 工具,不依赖 demo)
- exportToOpenAIFormat 补上独立 role:tool 消息的导出分支
- 新增 standalone role:tool 导出测试用例
- 49 个测试全部通过
This commit is contained in:
carry
2026-06-09 15:08:45 +08:00
parent 4c384fe566
commit fb8bdc0fb6
4 changed files with 301 additions and 92 deletions
+49
View File
@@ -488,6 +488,55 @@ describe('exportToOpenAIFormat', () => {
expect(messages[2].content).toBe('B results')
})
it('handles standalone role:"tool" message with tool_call_result', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '1',
role: 'assistant',
segments: [
{
kind: 'tool_call_request',
toolName: 'calculate',
arguments: { expression: '2+3' },
collapsed: false,
},
],
timestamp: 0,
},
{
id: '2',
role: 'tool',
segments: [
{
kind: 'tool_call_result',
toolName: 'calculate',
result: '5',
success: true,
collapsed: true,
},
],
timestamp: 0,
},
],
}
const { messages } = exportToOpenAIFormat(env)
// assistant (tool_calls) + tool (result)
expect(messages).toHaveLength(2)
const assistantMsg = messages[0]
expect(assistantMsg.role).toBe('assistant')
expect(assistantMsg.tool_calls).toHaveLength(1)
expect(assistantMsg.tool_calls![0].function.name).toBe('calculate')
const toolMsg = messages[1]
expect(toolMsg.role).toBe('tool')
expect(toolMsg.tool_call_id).toBe(assistantMsg.tool_calls![0].id)
expect(toolMsg.content).toBe('5')
})
it('omits tools key when no tool_overview present', () => {
const env: PromptEnvelope = {
version: '1.0',
+102 -92
View File
@@ -1,18 +1,25 @@
import { createContext, useContext, useState, useCallback, useRef, type ReactNode } from 'react'
import {
createContext,
useContext,
useState,
useCallback,
useRef,
type ReactNode,
} from 'react'
import type { PromptEnvelope, Message } from '../types/protocol'
import { demos } from '../data/demos'
import { getApiConfig, hasApiKey } from '../services/api-config'
import { sendChatRequestStream } from '../services/api'
import { sendChatRequest } from '../services/api'
import { importToolResult } from '../utils/import'
import { executeToolCall, buildLiveSystemMessage } from '../services/tools'
interface ChatContextValue {
// 现有(保持不变)
envelope: PromptEnvelope
setEnvelope: (e: PromptEnvelope) => void
demos: typeof demos
activeDemo: number
setActiveDemo: (i: number) => void
// 新增 —— Live 模式
isLive: boolean
setIsLive: (v: boolean) => void
isLoading: boolean
@@ -29,6 +36,9 @@ function genMsgId(): string {
return `live_${Date.now()}_${++msgCounter}`
}
/** Tool loop 最大迭代次数,防止死循环 */
const MAX_TOOL_ITERATIONS = 5
export function ChatProvider({ children }: { children: ReactNode }) {
const [activeDemo, setActiveDemo] = useState(0)
const [envelope, setEnvelope] = useState<PromptEnvelope>(demos[0].envelope)
@@ -36,45 +46,35 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// 保存 Live 模式下的对话历史消息(用于 track 最新的消息列表
// Live 模式下的完整消息列表(system + 所有 user/assistant/tool 对话历史)
const liveMessagesRef = useRef<Message[]>([])
const clearError = useCallback(() => setError(null), [])
// ========================================================
// 模式切换
// ========================================================
/**
* 从 demo 的 envelope 中提取系统上下文消息
* role: "system" 的消息,包含 system_prompt / memory / skills / tool_overview / static_var
* 构建 Live 模式的初始 envelope。
* 使用专用的简单系统上下文(只含 calculate 工具),
* 不依赖 demo 的 system context。
*/
const extractSystemContext = useCallback((env: PromptEnvelope): Message[] => {
return env.messages.filter((m) => m.role === 'system')
const buildLiveEnvelope = useCallback((): PromptEnvelope => {
return {
version: '1.0' as const,
model: 'gpt-4-turbo',
messages: [buildLiveSystemMessage()],
}
}, [])
/**
* 构建 Live 模式的初始 envelope
* 保留当前 demo 的系统上下文,但清空所有 user/assistant/tool 消息。
*/
const buildLiveEnvelope = useCallback(
(demoIndex: number): PromptEnvelope => {
const systemMsgs = extractSystemContext(demos[demoIndex].envelope)
return {
version: '1.0' as const,
model: demos[demoIndex].envelope.model,
messages: [...systemMsgs],
}
},
[extractSystemContext]
)
/**
* 切换 Demo 场景。
* - Demo 模式:直接加载 Demo JSON(现有行为)
* - Live 模式:替换系统上下文,清空对话历史
*/
/** 切换 Demo 场景 */
const switchDemo = useCallback(
(i: number) => {
setActiveDemo(i)
if (isLive) {
const newEnv = buildLiveEnvelope(i)
// Live 模式切换 Demo:清空对话但保持 Live 系统上下文
const newEnv = buildLiveEnvelope()
liveMessagesRef.current = newEnv.messages
setEnvelope(newEnv)
} else {
@@ -85,20 +85,16 @@ export function ChatProvider({ children }: { children: ReactNode }) {
[isLive, buildLiveEnvelope]
)
/**
* 切换 Demo / Live 模式。
*/
/** Demo ↔ Live 切换 */
const handleSetIsLive = useCallback(
(v: boolean) => {
if (v === isLive) return
if (v) {
// Demo → Live:保留系统上下文,清空对话
const newEnv = buildLiveEnvelope(activeDemo)
const newEnv = buildLiveEnvelope()
liveMessagesRef.current = newEnv.messages
setEnvelope(newEnv)
} else {
// Live → Demo:恢复完整的 Demo JSON
setEnvelope(demos[activeDemo].envelope)
liveMessagesRef.current = []
}
@@ -110,20 +106,14 @@ export function ChatProvider({ children }: { children: ReactNode }) {
[isLive, activeDemo, buildLiveEnvelope]
)
/**
* 发送消息(Live 模式)。
*
* 协议映射流程:
* 1. 用户输入 → TextSegment → role:"user" Message
* 2. 追加到 envelope → exportToOpenAIFormat → API 请求
* 3. API 响应 → StreamingImporter → importFromOpenAIResponse → assistant Message
* 4. 追加到 envelope → UI 重新渲染
*/
// ========================================================
// 发送消息(含 Tool Execution Loop
// ========================================================
const sendMessage = useCallback(
async (text: string) => {
if (!text.trim() || isLoading) return
// 检查 API 配置
if (!hasApiKey()) {
setError('请先配置 API Key(点击右上角齿轮图标)')
return
@@ -140,62 +130,82 @@ export function ChatProvider({ children }: { children: ReactNode }) {
timestamp: Date.now(),
}
// 2. 构建当前 envelope(包含系统上下文 + 历史 + 新用户消息)
const currentMessages = [
...liveMessagesRef.current,
userMsg,
]
const currentEnvelope: PromptEnvelope = {
// 追加到消息列表并立即渲染
liveMessagesRef.current = [...liveMessagesRef.current, userMsg]
setEnvelope({
version: '1.0',
model: envelope.model,
messages: currentMessages,
}
// 立即渲染用户消息
liveMessagesRef.current = currentMessages
setEnvelope({ ...currentEnvelope })
model: 'gpt-4-turbo',
messages: liveMessagesRef.current,
})
setIsLoading(true)
// 3. 发送流式请求
let lastPartial: Message | null = null
// 2. Tool Execution Loop
try {
let iteration = 0
await sendChatRequestStream(currentEnvelope, config, {
onToken(partial: Message) {
lastPartial = partial
// 流式渲染:将部分 assistant 消息追加到列表末尾
const updatedMessages = [...liveMessagesRef.current, partial]
setEnvelope({
...currentEnvelope,
messages: updatedMessages,
})
},
while (iteration < MAX_TOOL_ITERATIONS) {
iteration++
onDone(finalMessages: Message[]) {
// 移除可能存在的部分消息,追加最终消息
const baseMessages = liveMessagesRef.current.filter(
(m) => m.id !== lastPartial?.id
)
const updatedMessages = [...baseMessages, ...finalMessages]
liveMessagesRef.current = updatedMessages
setEnvelope({
...currentEnvelope,
messages: updatedMessages,
})
setIsLoading(false)
},
const currentEnvelope: PromptEnvelope = {
version: '1.0',
model: 'gpt-4-turbo',
messages: liveMessagesRef.current,
}
onError(err: Error) {
// 移除流式部分消息,保留用户消息
// 发送非流式请求
const result = await sendChatRequest(currentEnvelope, config)
// sendChatRequest 返回 Message[],通常单条 assistant 消息
const assistantMsg = result[0]
if (!assistantMsg) {
throw new Error('API 返回空响应')
}
// 追加 assistant 消息(包含 text 和/或 tool_call_request segments
liveMessagesRef.current = [...liveMessagesRef.current, assistantMsg]
setEnvelope({
...currentEnvelope,
messages: liveMessagesRef.current,
})
setError(err.message)
setIsLoading(false)
},
})
// 检查是否有 tool_call 需要执行
const toolCalls = assistantMsg.segments.filter(
(s) => s.kind === 'tool_call_request'
)
if (toolCalls.length === 0) {
// 无 tool_call → 对话结束
break
}
// 执行每个 tool_call 并追加结果
for (const tc of toolCalls) {
const execResult = executeToolCall(tc.toolName, tc.arguments)
const toolMsg = importToolResult(
tc.toolName,
execResult.result,
execResult.success
)
liveMessagesRef.current = [...liveMessagesRef.current, toolMsg]
setEnvelope({
...currentEnvelope,
messages: liveMessagesRef.current,
})
}
// 继续循环 —— 将 tool result 送回 API
}
if (iteration >= MAX_TOOL_ITERATIONS) {
setError(`工具调用已达最大迭代次数 (${MAX_TOOL_ITERATIONS}),可能陷入循环`)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setIsLoading(false)
}
},
[isLoading, envelope.model, clearError]
[isLoading, clearError]
)
return (
+125
View File
@@ -0,0 +1,125 @@
/**
* Live 模式工具定义与执行引擎。
*
* 当前仅提供一个简易算术工具 `calculate`
* - 支持四则运算、幂运算、三角函数、对数、开方等
* - 底层通过安全的 new Function() 执行(仅允许数学表达式字符集)
* - 错误会被捕获并以文本形式返回(success=false
*/
import type { Message, ToolItem } from '../types/protocol'
// ============================================================
// 工具定义
// ============================================================
/** Live 模式唯一的工具 */
export const LIVE_TOOL: ToolItem = {
name: 'calculate',
description: '执行数学计算表达式,支持加减乘除、幂运算 (**)、三角函数 (Math.sin/cos/tan)、反三角 (Math.asin/acos/atan)、对数 (Math.log/log2/log10)、开方 (Math.sqrt)、取整 (Math.floor/ceil/round)、绝对值 (Math.abs)、常数 (Math.PI, Math.E) 等',
parameters: 'expression: string — 数学表达式,例如 "123 * 456"、"Math.sqrt(144)"、"2 ** 10"、"Math.sin(Math.PI / 2)"',
schema: {
type: 'object',
properties: {
expression: {
type: 'string',
description: '数学表达式字符串,可使用 JavaScript Math 对象的所有方法及运算符',
},
},
required: ['expression'],
},
}
// ============================================================
// 工具执行
// ============================================================
/** 表达式安全校验 —— 只允许数学表达式常用字符 */
function validateExpression(expr: string): string | null {
const trimmed = expr.trim()
if (!trimmed) return '表达式不能为空'
// 白名单:字母/数字/运算符/括号/空格/点号/逗号/引号
// 允许 Math.xxx 函数调用、数字、运算符
if (!/^[a-zA-Z0-9+\-*/%()., \t\n'"\[\]_:!<>=&|^~]+$/.test(trimmed)) {
return `表达式包含不允许的字符`
}
// 防止过长输入
if (trimmed.length > 500) {
return '表达式过长(最多 500 个字符)'
}
return null
}
/**
* 执行工具调用并返回结果。
*
* @param toolName 工具名称
* @param args 工具参数(来自 tool_call_request.arguments
* @returns 执行结果
*/
export function executeToolCall(
toolName: string,
args: Record<string, unknown>
): { result: string; success: boolean } {
if (toolName !== 'calculate') {
return { result: `未知工具: ${toolName}`, success: false }
}
const rawExpr = args.expression
if (typeof rawExpr !== 'string') {
return { result: '参数错误: expression 必须是字符串', success: false }
}
const validationError = validateExpression(rawExpr)
if (validationError) {
return { result: validationError, success: false }
}
try {
// 使用 new Function 执行 —— 在已验证字符集的前提下安全
// 注入 Math 对象使其可用,这样用户可以直接写 Math.sin(x) 等
const fn = new Function('Math', `return (${rawExpr})`)
const result = fn(Math)
return { result: String(result), success: true }
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return { result: `计算错误: ${msg}`, success: false }
}
}
// ============================================================
// Live 模式系统上下文
// ============================================================
/**
* 构建 Live 模式的系统消息(替代 demo 的 system context)。
*
* 包含:
* - 一个 system_prompt segment:简短的行为指令
* - 一个 tool_overview segment:仅含 calculate 工具
*/
export function buildLiveSystemMessage(): Message {
return {
id: 'live-system',
role: 'system',
segments: [
{
kind: 'system_prompt',
content:
'你是一个数学助手。如果用户问你数学计算问题,使用 calculate 工具来计算结果。\n' +
'如果用户只是闲聊,正常回复即可,不要调用工具。\n' +
'回复语言:中文。回答要简洁、友好。',
collapsed: true,
},
{
kind: 'tool_overview',
items: [LIVE_TOOL],
collapsed: false,
},
],
timestamp: Date.now(),
}
}
+25
View File
@@ -326,6 +326,31 @@ export function exportToOpenAIFormat(envelope: PromptEnvelope): OpenAIExport {
})
}
}
// ── Tool (独立 role: "tool" 消息) ──
if (msg.role === 'tool') {
for (const seg of msg.segments) {
if (seg.kind === 'tool_call_result') {
// call_id 配对:按 toolName 的 FIFO 队列匹配
const queue = pendingCallIds.get(seg.toolName)
const callId = queue?.shift() || nextCallId()
messages.push({
role: 'tool',
tool_call_id: callId,
content: seg.result,
})
} else if (seg.kind === 'tool_call_request') {
// tool 消息中包含 tool_call_request 的情况:也生成 tool_calls
const callId = nextCallId()
const queue = pendingCallIds.get(seg.toolName) || []
queue.push(callId)
pendingCallIds.set(seg.toolName, queue)
// 注意:独立 tool 消息不应该有 tool_call_request
// 这里做防御性处理
}
}
}
}
// Prepend merged system message