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') 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', () => { it('omits tools key when no tool_overview present', () => {
const env: PromptEnvelope = { const env: PromptEnvelope = {
version: '1.0', 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 type { PromptEnvelope, Message } from '../types/protocol'
import { demos } from '../data/demos' import { demos } from '../data/demos'
import { getApiConfig, hasApiKey } from '../services/api-config' 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 { interface ChatContextValue {
// 现有(保持不变)
envelope: PromptEnvelope envelope: PromptEnvelope
setEnvelope: (e: PromptEnvelope) => void setEnvelope: (e: PromptEnvelope) => void
demos: typeof demos demos: typeof demos
activeDemo: number activeDemo: number
setActiveDemo: (i: number) => void setActiveDemo: (i: number) => void
// 新增 —— Live 模式
isLive: boolean isLive: boolean
setIsLive: (v: boolean) => void setIsLive: (v: boolean) => void
isLoading: boolean isLoading: boolean
@@ -29,6 +36,9 @@ function genMsgId(): string {
return `live_${Date.now()}_${++msgCounter}` return `live_${Date.now()}_${++msgCounter}`
} }
/** Tool loop 最大迭代次数,防止死循环 */
const MAX_TOOL_ITERATIONS = 5
export function ChatProvider({ children }: { children: ReactNode }) { export function ChatProvider({ children }: { children: ReactNode }) {
const [activeDemo, setActiveDemo] = useState(0) const [activeDemo, setActiveDemo] = useState(0)
const [envelope, setEnvelope] = useState<PromptEnvelope>(demos[0].envelope) const [envelope, setEnvelope] = useState<PromptEnvelope>(demos[0].envelope)
@@ -36,45 +46,35 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// 保存 Live 模式下的对话历史消息(用于 track 最新的消息列表 // Live 模式下的完整消息列表(system + 所有 user/assistant/tool 对话历史)
const liveMessagesRef = useRef<Message[]>([]) const liveMessagesRef = useRef<Message[]>([])
const clearError = useCallback(() => setError(null), []) const clearError = useCallback(() => setError(null), [])
// ========================================================
// 模式切换
// ========================================================
/** /**
* 从 demo 的 envelope 中提取系统上下文消息 * 构建 Live 模式的初始 envelope。
* role: "system" 的消息,包含 system_prompt / memory / skills / tool_overview / static_var * 使用专用的简单系统上下文(只含 calculate 工具),
* 不依赖 demo 的 system context。
*/ */
const extractSystemContext = useCallback((env: PromptEnvelope): Message[] => { const buildLiveEnvelope = useCallback((): PromptEnvelope => {
return env.messages.filter((m) => m.role === 'system') return {
version: '1.0' as const,
model: 'gpt-4-turbo',
messages: [buildLiveSystemMessage()],
}
}, []) }, [])
/** /** 切换 Demo 场景 */
* 构建 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 模式:替换系统上下文,清空对话历史
*/
const switchDemo = useCallback( const switchDemo = useCallback(
(i: number) => { (i: number) => {
setActiveDemo(i) setActiveDemo(i)
if (isLive) { if (isLive) {
const newEnv = buildLiveEnvelope(i) // Live 模式切换 Demo:清空对话但保持 Live 系统上下文
const newEnv = buildLiveEnvelope()
liveMessagesRef.current = newEnv.messages liveMessagesRef.current = newEnv.messages
setEnvelope(newEnv) setEnvelope(newEnv)
} else { } else {
@@ -85,20 +85,16 @@ export function ChatProvider({ children }: { children: ReactNode }) {
[isLive, buildLiveEnvelope] [isLive, buildLiveEnvelope]
) )
/** /** Demo ↔ Live 切换 */
* 切换 Demo / Live 模式。
*/
const handleSetIsLive = useCallback( const handleSetIsLive = useCallback(
(v: boolean) => { (v: boolean) => {
if (v === isLive) return if (v === isLive) return
if (v) { if (v) {
// Demo → Live:保留系统上下文,清空对话 const newEnv = buildLiveEnvelope()
const newEnv = buildLiveEnvelope(activeDemo)
liveMessagesRef.current = newEnv.messages liveMessagesRef.current = newEnv.messages
setEnvelope(newEnv) setEnvelope(newEnv)
} else { } else {
// Live → Demo:恢复完整的 Demo JSON
setEnvelope(demos[activeDemo].envelope) setEnvelope(demos[activeDemo].envelope)
liveMessagesRef.current = [] liveMessagesRef.current = []
} }
@@ -110,20 +106,14 @@ export function ChatProvider({ children }: { children: ReactNode }) {
[isLive, activeDemo, buildLiveEnvelope] [isLive, activeDemo, buildLiveEnvelope]
) )
/** // ========================================================
* 发送消息(Live 模式)。 // 发送消息(含 Tool Execution Loop
* // ========================================================
* 协议映射流程:
* 1. 用户输入 → TextSegment → role:"user" Message
* 2. 追加到 envelope → exportToOpenAIFormat → API 请求
* 3. API 响应 → StreamingImporter → importFromOpenAIResponse → assistant Message
* 4. 追加到 envelope → UI 重新渲染
*/
const sendMessage = useCallback( const sendMessage = useCallback(
async (text: string) => { async (text: string) => {
if (!text.trim() || isLoading) return if (!text.trim() || isLoading) return
// 检查 API 配置
if (!hasApiKey()) { if (!hasApiKey()) {
setError('请先配置 API Key(点击右上角齿轮图标)') setError('请先配置 API Key(点击右上角齿轮图标)')
return return
@@ -140,62 +130,82 @@ export function ChatProvider({ children }: { children: ReactNode }) {
timestamp: Date.now(), timestamp: Date.now(),
} }
// 2. 构建当前 envelope(包含系统上下文 + 历史 + 新用户消息) // 追加到消息列表并立即渲染
const currentMessages = [ liveMessagesRef.current = [...liveMessagesRef.current, userMsg]
...liveMessagesRef.current, setEnvelope({
userMsg,
]
const currentEnvelope: PromptEnvelope = {
version: '1.0', version: '1.0',
model: envelope.model, model: 'gpt-4-turbo',
messages: currentMessages, messages: liveMessagesRef.current,
} })
// 立即渲染用户消息
liveMessagesRef.current = currentMessages
setEnvelope({ ...currentEnvelope })
setIsLoading(true) setIsLoading(true)
// 3. 发送流式请求 // 2. Tool Execution Loop
let lastPartial: Message | null = null try {
let iteration = 0
await sendChatRequestStream(currentEnvelope, config, { while (iteration < MAX_TOOL_ITERATIONS) {
onToken(partial: Message) { iteration++
lastPartial = partial
// 流式渲染:将部分 assistant 消息追加到列表末尾
const updatedMessages = [...liveMessagesRef.current, partial]
setEnvelope({
...currentEnvelope,
messages: updatedMessages,
})
},
onDone(finalMessages: Message[]) { const currentEnvelope: PromptEnvelope = {
// 移除可能存在的部分消息,追加最终消息 version: '1.0',
const baseMessages = liveMessagesRef.current.filter( model: 'gpt-4-turbo',
(m) => m.id !== lastPartial?.id messages: liveMessagesRef.current,
) }
const updatedMessages = [...baseMessages, ...finalMessages]
liveMessagesRef.current = updatedMessages
setEnvelope({
...currentEnvelope,
messages: updatedMessages,
})
setIsLoading(false)
},
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({ setEnvelope({
...currentEnvelope, ...currentEnvelope,
messages: liveMessagesRef.current, 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 ( 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 // Prepend merged system message