diff --git a/src/__tests__/export.test.ts b/src/__tests__/export.test.ts index d00a54a..3c04bf6 100644 --- a/src/__tests__/export.test.ts +++ b/src/__tests__/export.test.ts @@ -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', diff --git a/src/context/ChatContext.tsx b/src/context/ChatContext.tsx index ee639cd..aa9eafb 100644 --- a/src/context/ChatContext.tsx +++ b/src/context/ChatContext.tsx @@ -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(demos[0].envelope) @@ -36,45 +46,35 @@ export function ChatProvider({ children }: { children: ReactNode }) { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) - // 保存 Live 模式下的对话历史消息(用于 track 最新的消息列表) + // Live 模式下的完整消息列表(system + 所有 user/assistant/tool 对话历史) const liveMessagesRef = useRef([]) 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 ( diff --git a/src/services/tools.ts b/src/services/tools.ts new file mode 100644 index 0000000..8789ff5 --- /dev/null +++ b/src/services/tools.ts @@ -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 +): { 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(), + } +} diff --git a/src/utils/export.ts b/src/utils/export.ts index e47420c..90d1c2d 100644 --- a/src/utils/export.ts +++ b/src/utils/export.ts @@ -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