diff --git a/src/main/presenter/builtInToolsPresenter/base.ts b/src/main/presenter/builtInToolsPresenter/base.ts new file mode 100644 index 0000000000000000000000000000000000000000..000f03ef5fe93344ce78a9ef61664ed4f6b80b80 --- /dev/null +++ b/src/main/presenter/builtInToolsPresenter/base.ts @@ -0,0 +1,154 @@ +import { MCPToolResponse } from '@shared/presenter' + +export interface BuiltInToolDefinition { + name: string + description: string + parameters: { + type: string + properties: Record + required: string[] + } +} + +export interface BuiltInToolExecuteResult { + content: string + success: boolean + metadata?: Record +} + +export interface BuiltInToolResponse { + toolCallId: string + content: string + success: boolean + metadata?: Record + rawData: MCPToolResponse +} + +export function validateToolArgs( + tool: BuiltInToolDefinition, + args: any +): { ok: true; normalizedArgs: any } | { ok: false; message: string } { + try { + const properties = tool.parameters.properties || {} + const normalizedArgs = normalizeToolArgs(args, properties) + + const required = Array.isArray(tool.parameters.required) ? tool.parameters.required : [] + + for (const key of required) { + if (normalizedArgs == null || !(key in normalizedArgs)) { + return { ok: false, message: `缺少必填参数: ${key}` } + } + } + + for (const [key, schema] of Object.entries(properties)) { + if (!(key in (normalizedArgs || {}))) continue + const val = (normalizedArgs as any)[key] + const schemaTypes = getSchemaTypes((schema as any)?.type) + + if (schemaTypes.has('string') && typeof val !== 'string') { + return { ok: false, message: `参数 ${key} 需要为 string` } + } + if (schemaTypes.has('boolean') && typeof val !== 'boolean') { + return { ok: false, message: `参数 ${key} 需要为 boolean` } + } + if ((schemaTypes.has('number') || schemaTypes.has('integer')) && typeof val !== 'number') { + return { ok: false, message: `参数 ${key} 需要为 number` } + } + } + + return { ok: true, normalizedArgs } + } catch { + return { ok: true, normalizedArgs: args } + } +} + +function normalizeToolArgs( + args: any, + properties: Record +): Record | any { + if (args == null || typeof args !== 'object' || Array.isArray(args)) return args + + const normalized: Record = { ...(args as Record) } + + for (const [key, schema] of Object.entries(properties)) { + if (!(key in normalized)) continue + normalized[key] = coerceToolArgValue(normalized[key], schema) + } + + return normalized +} + +function coerceToolArgValue(value: unknown, schema: any): unknown { + if (value == null) return value + + const schemaTypes = getSchemaTypes(schema?.type) + + if ((schemaTypes.has('number') || schemaTypes.has('integer')) && typeof value === 'string') { + const trimmed = value.trim() + if (trimmed.length > 0) { + const parsedNumber = Number(trimmed) + if (!Number.isNaN(parsedNumber) && Number.isFinite(parsedNumber)) { + if (schemaTypes.has('integer') && !Number.isInteger(parsedNumber)) { + return value + } + return parsedNumber + } + } + } + + if (schemaTypes.has('boolean') && typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (normalized === 'true') return true + if (normalized === 'false') return false + } + + if ( + schemaTypes.has('object') && + schema?.properties && + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + const nested: Record = { ...(value as Record) } + for (const [nestedKey, nestedSchema] of Object.entries(schema.properties)) { + if (!(nestedKey in nested)) continue + nested[nestedKey] = coerceToolArgValue(nested[nestedKey], nestedSchema) + } + return nested + } + + if (schemaTypes.has('array') && Array.isArray(value) && schema?.items) { + return value.map((item) => coerceToolArgValue(item, schema.items)) + } + + return value +} + +function getSchemaTypes(typeDefinition: unknown): Set { + if (typeof typeDefinition === 'string') { + return new Set([typeDefinition]) + } + if (Array.isArray(typeDefinition)) { + return new Set(typeDefinition.filter((t): t is string => typeof t === 'string')) + } + return new Set() +} + +export function buildRawData( + toolCallId: string, + content: string, + isError: boolean, + metadata?: Record +): MCPToolResponse { + const rawData: MCPToolResponse = { + toolCallId, + content, + isError + } + + if (metadata && Object.keys(metadata).length > 0) { + rawData._meta = metadata + } + + return rawData +} diff --git a/src/main/presenter/builtInToolsPresenter/executeCommandTool.ts b/src/main/presenter/builtInToolsPresenter/executeCommandTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..716100e9e5acc5fc0c91dce331e1a6501925b6a2 --- /dev/null +++ b/src/main/presenter/builtInToolsPresenter/executeCommandTool.ts @@ -0,0 +1,163 @@ +import fs from 'fs/promises' +import path from 'path' +import { exec as execCallback } from 'child_process' +import { promisify } from 'util' +import { BuiltInToolDefinition, BuiltInToolResponse, buildRawData } from './base' + +const execAsync = promisify(execCallback) + +export const executeCommandTool: BuiltInToolDefinition = { + name: 'execute_command', + description: '在当前或指定工作目录中执行命令行指令,并返回标准输出和标准错误。', + parameters: { + type: 'object', + properties: { + command: { + type: 'string', + description: '要执行的完整命令字符串。' + }, + working_directory: { + type: 'string', + description: '执行命令时使用的工作目录(可选,默认使用当前进程目录)。' + }, + timeout: { + type: 'number', + description: '命令允许运行的最长时间(毫秒,默认 30000)。' + }, + shell: { + type: 'string', + description: '用于执行命令的 shell(可选,留空则使用系统默认值)。' + } + }, + required: ['command'] + } +} + +export async function executeCommandToolHandler( + args: any, + toolCallId: string +): Promise { + try { + const { command, working_directory, timeout, shell } = args ?? {} + + if (typeof command !== 'string' || command.trim().length === 0) { + throw new Error('command 参数不能为空,并且必须是字符串') + } + const trimmedCommand = command.trim() + + let resolvedCwd = process.cwd() + if (working_directory !== undefined) { + if (typeof working_directory !== 'string' || working_directory.trim().length === 0) { + throw new Error('working_directory 必须是字符串') + } + const cwdCandidate = path.isAbsolute(working_directory) + ? working_directory + : path.resolve(process.cwd(), working_directory) + try { + const stats = await fs.stat(cwdCandidate) + if (!stats.isDirectory()) { + throw new Error(`工作目录不是有效的目录: ${cwdCandidate}`) + } + } catch { + throw new Error(`工作目录不存在或无法访问: ${cwdCandidate}`) + } + resolvedCwd = cwdCandidate + } + + const resolvedTimeout = + typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0 ? timeout : 30_000 + + const execOptions: { + cwd: string + timeout: number + maxBuffer: number + shell?: string + } = { + cwd: resolvedCwd, + timeout: resolvedTimeout, + maxBuffer: 10 * 1024 * 1024 + } + + if (typeof shell === 'string' && shell.trim().length > 0) { + execOptions.shell = shell.trim() + } + + const { stdout, stderr } = await execAsync(trimmedCommand, execOptions) + + const metadata = { + command: trimmedCommand, + cwd: resolvedCwd, + timeout: resolvedTimeout, + shell: execOptions.shell ?? 'default', + exitCode: 0, + stdout, + stderr + } + + const successMessage = `命令执行成功 (exit 0)\n命令: ${trimmedCommand}\n工作目录: ${resolvedCwd}${ + execOptions.shell ? `\nShell: ${execOptions.shell}` : '' + }\n\nstdout:\n${stdout || '(空)'}\n\nstderr:\n${stderr || '(空)'}` + + return { + toolCallId, + content: successMessage, + success: true, + metadata, + rawData: buildRawData(toolCallId, successMessage, false, metadata) + } + } catch (error) { + const execError = error as + | (Error & { + stdout?: string + stderr?: string + code?: number | string + signal?: NodeJS.Signals + killed?: boolean + }) + | undefined + + const stdout = execError?.stdout ?? '' + const stderr = execError?.stderr ?? '' + const exitCode = execError?.code ?? null + const signal = execError?.signal ?? null + const errorMessage = + execError?.message ?? (error instanceof Error ? error.message : String(error)) + const exitInfo = + exitCode !== null ? `exit ${exitCode}` : signal ? `signal ${signal}` : '未知退出状态' + + const metadata = { + command: typeof args?.command === 'string' ? args.command.trim() : '', + cwd: + typeof args?.working_directory === 'string' + ? path.isAbsolute(args.working_directory) + ? args.working_directory + : path.resolve(process.cwd(), args.working_directory) + : process.cwd(), + timeout: + typeof args?.timeout === 'number' && Number.isFinite(args.timeout) && args.timeout > 0 + ? args.timeout + : 30_000, + shell: + typeof args?.shell === 'string' && args.shell.trim().length > 0 + ? args.shell.trim() + : 'default', + exitCode, + signal, + stdout, + stderr, + error: errorMessage + } + + const failureMessage = `命令执行失败 (${exitInfo})\n命令: ${metadata.command}\n工作目录: ${metadata.cwd}\n\nstdout:\n${stdout || '(空)'}\n\nstderr:\n${stderr || '(空)'}\n\n错误信息: ${errorMessage}` + + return { + toolCallId, + content: failureMessage, + success: false, + metadata, + rawData: buildRawData(toolCallId, failureMessage, true, metadata) + } + } +} + +export { executeCommandToolHandler as executeExecuteCommandTool } diff --git a/src/main/presenter/builtInToolsPresenter/index.ts b/src/main/presenter/builtInToolsPresenter/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3d99a52647cc0b61e04500bbac1f6c4fca00d01 --- /dev/null +++ b/src/main/presenter/builtInToolsPresenter/index.ts @@ -0,0 +1,96 @@ +import { Tool } from '@shared/presenter' +import { BuiltInToolDefinition, BuiltInToolResponse, validateToolArgs, buildRawData } from './base' +import { readFileTool, executeReadFileTool } from './readFileTool' +import { writeFileTool, executeWriteFileTool } from './writeFileTool' +import { listFilesTool, executeListFilesTool } from './listFilesTool' +import { executeCommandTool, executeCommandToolHandler } from './executeCommandTool' + +export const builtInTools: Record = { + [readFileTool.name]: readFileTool, + [writeFileTool.name]: writeFileTool, + [listFilesTool.name]: listFilesTool, + [executeCommandTool.name]: executeCommandTool +} + +type BuiltInExecutor = (args: any, toolCallId: string) => Promise +const builtInToolExecutors: Record = { + [readFileTool.name]: executeReadFileTool, + [writeFileTool.name]: executeWriteFileTool, + [listFilesTool.name]: executeListFilesTool, + [executeCommandTool.name]: executeCommandToolHandler +} + +async function executeBuiltInToolInternal( + toolName: string, + args: any, + toolCallId: string +): Promise { + const def = builtInTools[toolName] + let resolvedArgs = args + if (def) { + const check = validateToolArgs(def, args) + if (!check.ok) { + const failureMessage = `参数校验失败: ${check.message}` + const meta = { error: check.message, tool: toolName, args } + return { + toolCallId, + content: failureMessage, + success: false, + metadata: meta, + rawData: buildRawData(toolCallId, failureMessage, true, meta) + } + } + resolvedArgs = check.normalizedArgs + } + + const executor = builtInToolExecutors[toolName] + if (executor) { + return await executor(resolvedArgs, toolCallId) + } + const msg = `未知的内置工具: ${toolName}` + const metadata = { error: `Unknown built-in tool: ${toolName}` } + return { + toolCallId, + content: msg, + success: false, + metadata, + rawData: buildRawData(toolCallId, msg, true, metadata) + } +} + +export { executeBuiltInToolInternal as executeBuiltInTool } + +export class BuiltInToolsPresenter { + async getBuiltInTools(): Promise { + const definitions = Object.values(builtInTools) + return definitions.map((def) => ({ + name: def.name, + description: def.description, + inputSchema: def.parameters, + annotations: { + title: def.name, + readOnlyHint: false, + destructiveHint: ['write_file', 'execute_command'].includes(def.name), + idempotentHint: false, + openWorldHint: true + } + })) + } + + async getToolDescription(toolName: string): Promise { + const definition = builtInTools[toolName] + return definition?.description || null + } + + isBuiltInTool(toolName: string): boolean { + return toolName in builtInTools + } + + async executeBuiltInTool( + toolName: string, + args: any, + toolCallId: string + ): Promise { + return await executeBuiltInToolInternal(toolName, args, toolCallId) + } +} diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 4c6101b63f7a97e24914624cdf0c2b3ea2d54499..4f6df5b8f565f41cec494288fbb9194967ed6059 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -22,7 +22,8 @@ import { ITabPresenter, IThreadPresenter, IUpgradePresenter, - IWindowPresenter + IWindowPresenter, + IBuiltInToolsPresenter } from '@shared/presenter' import { eventBus } from '@/eventbus' import { LLMProviderPresenter } from './llmProviderPresenter' @@ -40,6 +41,7 @@ import { OAuthPresenter } from './oauthPresenter' import { FloatingButtonPresenter } from './floatingButtonPresenter' import { CONFIG_EVENTS, WINDOW_EVENTS } from '@/events' import { KnowledgePresenter } from './knowledgePresenter' +import { BuiltInToolsPresenter } from './builtInToolsPresenter' // IPC调用上下文接口 interface IPCCallContext { @@ -77,6 +79,7 @@ export class Presenter implements IPresenter { oauthPresenter: OAuthPresenter floatingButtonPresenter: FloatingButtonPresenter knowledgePresenter: IKnowledgePresenter + builtInToolsPresenter: IBuiltInToolsPresenter // llamaCppPresenter: LlamaCppPresenter // 保留原始注释 dialogPresenter: IDialogPresenter lifecycleManager: ILifecycleManager @@ -110,6 +113,7 @@ export class Presenter implements IPresenter { this.trayPresenter = new TrayPresenter() this.floatingButtonPresenter = new FloatingButtonPresenter(this.configPresenter) this.dialogPresenter = new DialogPresenter() + this.builtInToolsPresenter = new BuiltInToolsPresenter() // Define dbDir for knowledge presenter const dbDir = path.join(app.getPath('userData'), 'app_db') diff --git a/src/main/presenter/llmProviderPresenter/index.ts b/src/main/presenter/llmProviderPresenter/index.ts index 96d82a76dab2ac5c10147aadcd7176c12fae9c3d..2a91dafeb3f73a5617ff88ebee311f1f931d1645 100644 --- a/src/main/presenter/llmProviderPresenter/index.ts +++ b/src/main/presenter/llmProviderPresenter/index.ts @@ -794,6 +794,7 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { arguments: string }> = [] const currentToolChunks: Record = {} + let pendingBuiltInTextBuffer = '' try { console.log(`[Agent Loop] Iteration ${toolCallCount + 1} for event: ${eventId}`) @@ -844,13 +845,21 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { switch (chunk.type) { case 'text': if (chunk.content) { - currentContent += chunk.content - yield { - type: 'response', - data: { + // 处理文本中的 标签,并将普通文本或工具事件下发给前端 + const { updatedContent, pendingBuffer, responses } = + await this.processBuiltInToolStreamingText({ + chunkContent: chunk.content, + currentContent, + pendingBuffer: pendingBuiltInTextBuffer, eventId, - content: chunk.content - } + currentToolCalls, + providerId + }) + currentContent = updatedContent + pendingBuiltInTextBuffer = pendingBuffer + // 将辅助函数收集到的响应逐条发送 + for (const response of responses) { + yield response } } break @@ -989,7 +998,28 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { console.log( `Provider stream stopped for event ${eventId}. Reason: ${chunk.stop_reason}` ) - if (chunk.stop_reason === 'tool_use') { + if (pendingBuiltInTextBuffer) { + const remainingText = pendingBuiltInTextBuffer + pendingBuiltInTextBuffer = '' + if (remainingText) { + currentContent += remainingText + yield { + type: 'response', + data: { + eventId, + content: remainingText + } + } + } + } + + const hasPendingToolData = Object.keys(currentToolChunks).length > 0 + const shouldContinue = + chunk.stop_reason === 'tool_use' || + currentToolCalls.length > 0 || + hasPendingToolData + + if (shouldContinue) { // Consolidate any remaining tool call chunks for (const id in currentToolChunks) { currentToolCalls.push({ @@ -1052,7 +1082,145 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { toolCallCount++ - // Find the tool definition to get server info + // Check if it's a built-in tool + if (presenter.builtInToolsPresenter.isBuiltInTool(toolCall.name)) { + // Handle built-in tool execution + yield { + type: 'response', + data: { + eventId, + tool_call: 'running', + tool_call_id: toolCall.id, + tool_call_name: toolCall.name, + tool_call_params: toolCall.arguments, + tool_call_server_name: 'Built-in Tool', + tool_call_server_icons: [], + tool_call_server_description: 'System built-in tool' + } + } + + try { + // Execute built-in tool via presenter + const toolResponse = await presenter.builtInToolsPresenter.executeBuiltInTool( + toolCall.name, + JSON.parse(toolCall.arguments), + toolCall.id + ) + + if (abortController.signal.aborted) break // Check after tool call returns + + // Non-native FC: Add tool execution record to conversation history for next LLM turn. + const formattedToolRecordText = `${JSON.stringify({ function_call_record: { name: toolCall.name, arguments: toolCall.arguments, response: toolResponse.content } })}` + + let lastAssistantMessage = conversationMessages.findLast( + (m) => m.role === 'assistant' + ) + + if (lastAssistantMessage) { + if (typeof lastAssistantMessage.content === 'string') { + lastAssistantMessage.content += formattedToolRecordText + '\n' + } else if (Array.isArray(lastAssistantMessage.content)) { + lastAssistantMessage.content.push({ + type: 'text', + text: formattedToolRecordText + '\n' + }) + } else { + lastAssistantMessage.content = [ + { type: 'text', text: formattedToolRecordText + '\n' } + ] + } + } else { + conversationMessages.push({ + role: 'assistant', + content: [{ type: 'text', text: formattedToolRecordText + '\n' }] + }) + lastAssistantMessage = conversationMessages[conversationMessages.length - 1] + } + + const userPromptText = + '以上是你刚执行的工具调用及其响应信息,已帮你插入,请仔细阅读工具响应,并继续你的回答。' + conversationMessages.push({ + role: 'user', + content: [{ type: 'text', text: userPromptText }] + }) + + yield { + type: 'response', + data: { + eventId, + tool_call: 'end', + tool_call_id: toolCall.id, + tool_call_response: toolResponse.content, + tool_call_name: toolCall.name, + tool_call_params: toolCall.arguments, + tool_call_server_name: 'Built-in Tool', + tool_call_server_icons: [], + tool_call_server_description: 'System built-in tool', + tool_call_response_raw: toolResponse.rawData + } + } + } catch (toolError) { + if (abortController.signal.aborted) break + + console.error( + `Built-in tool execution error for ${toolCall.name} (event ${eventId}):`, + toolError + ) + const errorMessage = + toolError instanceof Error ? toolError.message : String(toolError) + + const formattedErrorText = `编号为 ${toolCall.id} 的工具 ${toolCall.name} 调用执行失败: ${errorMessage}` + + let lastAssistantMessage = conversationMessages.findLast( + (m) => m.role === 'assistant' + ) + if (lastAssistantMessage) { + if (typeof lastAssistantMessage.content === 'string') { + lastAssistantMessage.content += '\n' + formattedErrorText + '\n' + } else if (Array.isArray(lastAssistantMessage.content)) { + lastAssistantMessage.content.push({ + type: 'text', + text: '\n' + formattedErrorText + '\n' + }) + } else { + lastAssistantMessage.content = [ + { type: 'text', text: '\n' + formattedErrorText + '\n' } + ] + } + } else { + conversationMessages.push({ + role: 'assistant', + content: [{ type: 'text', text: formattedErrorText + '\n' }] + }) + } + + const userPromptText = + '以上是你刚调用的工具及其执行的错误信息,已帮你插入,请根据情况继续回答或重新尝试。' + conversationMessages.push({ + role: 'user', + content: [{ type: 'text', text: userPromptText }] + }) + + yield { + type: 'response', + data: { + eventId, + tool_call: 'error', + tool_call_id: toolCall.id, + tool_call_name: toolCall.name, + tool_call_params: toolCall.arguments, + tool_call_response: errorMessage, + tool_call_server_name: 'Built-in Tool', + tool_call_server_icons: [], + tool_call_server_description: 'System built-in tool' + } + } + } + + continue // Skip to next tool call since built-in tool is handled + } + + // Find the tool definition to get server info (for MCP tools) const toolDef = ( await presenter.mcpPresenter.getAllToolDefinitions(enabledMcpTools) ).find((t) => t.function.name === toolCall.name) @@ -1878,6 +2046,231 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { } } + private async processBuiltInToolStreamingText({ + chunkContent, + currentContent, + pendingBuffer, + eventId, + currentToolCalls, + providerId + }: { + chunkContent: string + currentContent: string + pendingBuffer: string + eventId: string + currentToolCalls: Array<{ id: string; name: string; arguments: string }> + providerId: string + }): Promise<{ + updatedContent: string + pendingBuffer: string + responses: Array<{ type: 'response'; data: any }> + }> { + // 缓存标记和基本状态 + const builtInStartTag = '' + const builtInEndTag = '' + const responses: Array<{ type: 'response'; data: any }> = [] + let mergedBuffer = pendingBuffer + chunkContent + let updatedContent = currentContent + + // 计算可安全输出的文本长度,避免截断标签 + const getSafeFlushIndex = (buffer: string): number => { + const maxCheckLength = Math.min(builtInStartTag.length - 1, buffer.length) + for (let len = maxCheckLength; len > 0; len--) { + const suffix = buffer.slice(buffer.length - len) + if (builtInStartTag.startsWith(suffix)) { + return buffer.length - len + } + } + return buffer.length + } + + while (mergedBuffer.length > 0) { + const startIndex = mergedBuffer.indexOf(builtInStartTag) + + // 没有起始标签时,输出纯文本,剩余部分保留等待更多数据 + if (startIndex === -1) { + const safeFlushIndex = getSafeFlushIndex(mergedBuffer) + const flushText = mergedBuffer.slice(0, safeFlushIndex) + + if (flushText) { + updatedContent += flushText + responses.push({ + type: 'response', + data: { + eventId, + content: flushText + } + }) + } + + const remaining = mergedBuffer.slice(safeFlushIndex) + return { updatedContent, pendingBuffer: remaining, responses } + } + + // 输出起始标签之前的纯文本 + const textBeforeTag = mergedBuffer.slice(0, startIndex) + if (textBeforeTag) { + updatedContent += textBeforeTag + responses.push({ + type: 'response', + data: { + eventId, + content: textBeforeTag + } + }) + } + + // 剩下的缓冲区从起始标签开始 + mergedBuffer = mergedBuffer.slice(startIndex) + + const endIndex = mergedBuffer.indexOf(builtInEndTag, builtInStartTag.length) + + // 没有闭合标签,继续等待后续数据 + if (endIndex === -1) { + return { updatedContent, pendingBuffer: mergedBuffer, responses } + } + + // 截取完整的内置工具标签块 + const builtInBlock = mergedBuffer.slice(0, endIndex + builtInEndTag.length) + + const parsedCalls = this.parseBuiltInToolCalls(builtInBlock, `non-native-${providerId}`) + + if (parsedCalls.length === 0) { + // 解析失败则直接当作纯文本输出 + updatedContent += builtInBlock + responses.push({ + type: 'response', + data: { + eventId, + content: builtInBlock + } + }) + } else { + // 解析成功则缓存工具信息,并通知前端工具调用事件 + for (const call of parsedCalls) { + currentToolCalls.push({ + id: call.id, + name: call.name, + arguments: call.arguments + }) + + responses.push({ + type: 'response', + data: { + eventId, + tool_call: 'start', + tool_call_id: call.id, + tool_call_name: call.name, + tool_call_params: '' + } + }) + + responses.push({ + type: 'response', + data: { + eventId, + tool_call: 'update', + tool_call_id: call.id, + tool_call_name: call.name, + tool_call_params: call.arguments + } + }) + } + } + + // 移动到下一个待处理区间 + mergedBuffer = mergedBuffer.slice(endIndex + builtInEndTag.length) + } + + return { updatedContent, pendingBuffer: mergedBuffer, responses } + } + + private parseBuiltInToolCalls( + response: string, + fallbackIdPrefix: string = 'tool-call' + ): Array<{ id: string; name: string; arguments: string }> { + try { + const results: Array<{ id: string; name: string; arguments: string }> = [] + + const blocks = response.match(/[\s\S]*?<\/built_in_tool_call>/g) || [ + response + ] + + const parseLeafArgs = (xml: string): Record => { + const args: Record = {} + const leaf = /<([a-zA-Z0-9_\-]+)>\s*([^<>]+?)\s*<\/\1>/g + let match: RegExpExecArray | null + while ((match = leaf.exec(xml)) !== null) { + const key = match[1] + const val = match[2] + if (key in args) { + const prev = args[key] + args[key] = Array.isArray(prev) ? [...prev, val] : [prev, val] + } else { + args[key] = val + } + } + return args + } + + blocks.forEach((block, index) => { + try { + const builtWrapperMatch = block.match( + /([\s\S]*?)<\/built_in_tool_call>/ + ) + const blockBody = builtWrapperMatch ? builtWrapperMatch[1].trim() : block.trim() + + const toolTagMatch = blockBody.match(/^<([a-zA-Z0-9_\-]+)\b[\s\S]*?<\/\1>/) + if (!toolTagMatch) return + const toolName = toolTagMatch[1] + + const innerMatch = blockBody.match(new RegExp(`<${toolName}>([\\s\\S]*?)`)) + const inner = innerMatch ? innerMatch[1] : '' + + const rawArgs = inner.trim() + + let argsObj: any = {} + const jsonCandidate = rawArgs.replace(/^```[a-zA-Z]*\n?|```$/g, '').trim() + if ( + (jsonCandidate.startsWith('{') && jsonCandidate.endsWith('}')) || + (jsonCandidate.startsWith('[') && jsonCandidate.endsWith(']')) + ) { + try { + argsObj = JSON.parse(jsonCandidate) + } catch { + argsObj = parseLeafArgs(rawArgs) + } + } else { + argsObj = parseLeafArgs(rawArgs) + } + + const id = `${toolName || fallbackIdPrefix}-${index}-${Date.now()}` + let argsStr = '{}' + try { + argsStr = JSON.stringify(argsObj) + } catch { + argsStr = '{}' + } + + results.push({ id, name: toolName, arguments: argsStr }) + } catch (parseError) { + console.warn( + '[LLMProviderPresenter] Failed to parse built-in tool call block:', + parseError + ) + } + }) + + return results + } catch (error) { + console.error( + '[LLMProviderPresenter] Unexpected error while parsing built-in tool calls:', + error + ) + return [] + } + } + private onProvidersUpdated(providers: LLM_PROVIDER[]): void { for (const provider of providers) { if (provider.rateLimit) { diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index d48e6121afecbd2fc1a89556dcea87d3c40e2047..09e4f65db70fb1dfee7a36e7f3fd09a38bbeda6f 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -1064,6 +1064,40 @@ export interface ProgressResponse { completed?: number } +// +export interface IBuiltInToolsPresenter { + /** + * 获取所有内置工具的定义 + */ + getBuiltInTools(): Promise + + /** + * 获取工具的描述信息 + * @param toolName 工具名称 + */ + getToolDescription(toolName: string): Promise + + /** + * 检查给定名称是否为内置工具 + */ + isBuiltInTool(toolName: string): boolean + + /** + * 直接执行内置工具(返回底层执行结果,包含 rawData) + */ + executeBuiltInTool( + toolName: string, + args: any, + toolCallId: string + ): Promise<{ + toolCallId: string + content: string + success: boolean + metadata?: Record + rawData: MCPToolResponse + }> +} + // MCP related type definitions export interface MCPServerConfig { command: string