webMcpAgentService.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. import { executeWebMcpTool, getWebMcpTool, type WebMcpToolResult } from './webMcpService';
  2. import {
  3. matchWebMcpChatTemplate,
  4. webMcpTemplateHelp,
  5. type WebMcpChatTemplate,
  6. } from './webMcpChatTemplates';
  7. import { getWebMcpTools } from './webMcpService';
  8. import { translateWebMcpInput } from '../share/webmcp';
  9. import { listDocuments } from './documentService';
  10. import { blockService } from './blockService';
  11. import { useEditorStore } from '../stores/editorStore';
  12. export interface WebMcpChatResult {
  13. handled: boolean;
  14. response?: string;
  15. toolName?: string;
  16. requiresConfirmation?: boolean;
  17. template?: WebMcpChatTemplate;
  18. input?: Record<string, unknown>;
  19. result?: WebMcpToolResult;
  20. }
  21. interface ExportCandidate {
  22. documentId: string;
  23. title: string;
  24. aliases?: string[];
  25. recordId?: string;
  26. }
  27. interface LocalDocumentCommand {
  28. toolName: string;
  29. input: Record<string, unknown>;
  30. documentTitle: string;
  31. }
  32. const READ_ONLY_TOOLS = new Set([
  33. 'list_documents',
  34. 'get_document',
  35. 'search_document',
  36. 'get_block',
  37. 'get_document_toc',
  38. 'get_document_stats',
  39. 'list_export_records',
  40. ]);
  41. const DOCUMENT_CANDIDATE_CACHE_TTL = 15_000;
  42. let documentCandidatesCache: { value: ExportCandidate[]; expiresAt: number } | undefined;
  43. let documentCandidatesRequest: Promise<ExportCandidate[]> | undefined;
  44. const stringifyResult = (result: WebMcpToolResult): string => {
  45. if (!result.ok) return `WebMCP 执行失败:${result.error || '未知错误'}`;
  46. return `WebMCP 执行结果:\n${JSON.stringify(result.data, null, 2)}`;
  47. };
  48. const getDocumentCandidates = async (): Promise<ExportCandidate[]> => {
  49. const currentDocumentId = useEditorStore.getState().documentId;
  50. const cacheContainsCurrentDocument = !currentDocumentId || documentCandidatesCache?.value.some(
  51. (candidate) => candidate.documentId === currentDocumentId
  52. );
  53. if (documentCandidatesCache && documentCandidatesCache.expiresAt > Date.now() && cacheContainsCurrentDocument) {
  54. return documentCandidatesCache.value;
  55. }
  56. if (documentCandidatesRequest) return documentCandidatesRequest;
  57. documentCandidatesRequest = loadDocumentCandidates();
  58. try {
  59. const value = await documentCandidatesRequest;
  60. documentCandidatesCache = { value, expiresAt: Date.now() + DOCUMENT_CANDIDATE_CACHE_TTL };
  61. return value;
  62. } finally {
  63. documentCandidatesRequest = undefined;
  64. }
  65. };
  66. const loadDocumentCandidates = async (): Promise<ExportCandidate[]> => {
  67. const unique = new Map<string, ExportCandidate>();
  68. const getBlockText = (block: unknown): string => {
  69. if (!block || typeof block !== 'object') return '';
  70. const content = (block as { content?: unknown }).content;
  71. if (typeof content === 'string') return content.trim();
  72. if (Array.isArray(content)) {
  73. return content
  74. .map((part) => part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string'
  75. ? (part as { text: string }).text
  76. : '')
  77. .join('')
  78. .trim();
  79. }
  80. return '';
  81. };
  82. const getFirstBlockText = (blocks: unknown): string =>
  83. Array.isArray(blocks) ? blocks.map(getBlockText).find(Boolean) || '' : '';
  84. const currentEditor = useEditorStore.getState();
  85. if (currentEditor.documentId) {
  86. const currentBlockAliases = currentEditor.blocks
  87. .map(getBlockText)
  88. .filter(Boolean)
  89. .slice(0, 20);
  90. unique.set(currentEditor.documentId, {
  91. documentId: currentEditor.documentId,
  92. title: getFirstBlockText(currentEditor.blocks) || currentEditor.documentId,
  93. aliases: currentBlockAliases,
  94. });
  95. }
  96. try {
  97. const documentResult = await listDocuments({
  98. userId: 'default-user',
  99. page: 1,
  100. pageSize: 100,
  101. sortBy: 'updated_at',
  102. sortOrder: 'desc',
  103. });
  104. await Promise.all(documentResult.documents.map(async (document) => {
  105. const detail = await executeWebMcpTool('get_document', { documentId: document.id });
  106. const blocks = detail.ok && detail.data && typeof detail.data === 'object'
  107. ? (detail.data as { blocks?: unknown }).blocks
  108. : undefined;
  109. if (!Array.isArray(blocks)) return;
  110. const firstText = getFirstBlockText(blocks);
  111. const existing = unique.get(document.id);
  112. unique.set(document.id, {
  113. ...existing,
  114. documentId: document.id,
  115. title: firstText || existing?.title || document.id,
  116. });
  117. }));
  118. } catch (error) {
  119. console.warn('[WebMCP] 获取文档名称失败,将继续使用导出记录:', error);
  120. }
  121. const exportResult = await executeWebMcpTool('list_export_records', { page: 1, pageSize: 100 });
  122. if (exportResult.ok && exportResult.data && typeof exportResult.data === 'object') {
  123. const records = (exportResult.data as { records?: unknown }).records;
  124. if (Array.isArray(records)) {
  125. for (const record of records) {
  126. if (!record || typeof record !== 'object') continue;
  127. const item = record as Record<string, unknown>;
  128. if (typeof item.documentId !== 'string' || typeof item.fileName !== 'string') continue;
  129. const candidate = unique.get(item.documentId) || {
  130. documentId: item.documentId,
  131. title: item.fileName.replace(/\.(docx?|DOCX?)$/i, '').replace(/[_-]\d{8,}$/, '').trim(),
  132. };
  133. const exportTitle = item.fileName.replace(/\.(docx?|DOCX?)$/i, '').replace(/[_-]\d{8,}$/, '').trim();
  134. if (exportTitle && exportTitle !== candidate.title) {
  135. candidate.aliases = [...new Set([...(candidate.aliases || []), exportTitle])];
  136. }
  137. if (!candidate.recordId && typeof item.recordId === 'string') candidate.recordId = item.recordId;
  138. unique.set(item.documentId, candidate);
  139. }
  140. }
  141. }
  142. return [...unique.values()];
  143. };
  144. const normalizeDocumentText = (value: string): string =>
  145. value
  146. .toLocaleLowerCase()
  147. .replace(/\.(docx?|DOCX?)$/i, '')
  148. .replace(/(?:文档|文件)$/i, '')
  149. .replace(/[“”"'「」《》]/g, '')
  150. .replace(/\s+/g, '')
  151. .trim();
  152. const findDocumentCandidate = (
  153. title: string,
  154. candidates: ExportCandidate[]
  155. ): { candidate?: ExportCandidate; ambiguous: boolean } => {
  156. const normalizedTitle = normalizeDocumentText(title);
  157. const isChineseShortName = (value: string): boolean =>
  158. value.length >= 3 && /^[\u3400-\u9fff]+$/.test(value);
  159. const isOrderedChineseMatch = (shortName: string, fullName: string): boolean => {
  160. if (!isChineseShortName(shortName)) return false;
  161. let shortIndex = 0;
  162. for (const character of fullName) {
  163. if (character === shortName[shortIndex]) shortIndex += 1;
  164. if (shortIndex === shortName.length) return true;
  165. }
  166. return false;
  167. };
  168. const matches = candidates.filter((candidate) => {
  169. const candidateTitles = [candidate.title, ...(candidate.aliases || [])].map(normalizeDocumentText);
  170. return candidate.documentId === title || candidate.recordId === title || candidateTitles.some((candidateTitle) =>
  171. candidateTitle === normalizedTitle ||
  172. candidateTitle.includes(normalizedTitle) ||
  173. normalizedTitle.includes(candidateTitle) ||
  174. isOrderedChineseMatch(normalizedTitle, candidateTitle)
  175. );
  176. });
  177. return { candidate: matches.length === 1 ? matches[0] : undefined, ambiguous: matches.length > 1 };
  178. };
  179. const getActionHelp = (action: string): string => {
  180. if (action === 'download_export_record') return '请告诉我要下载的文档名称,例如:下载产品说明文档。';
  181. if (action === 'export_document') return '请告诉我要导出的文档名称,例如:导出产品说明文档。';
  182. if (action === 'open_document') return '请告诉我要打开的文档名称,例如:打开产品说明文档。';
  183. if (action === 'get_document') return '请告诉我要读取的文档名称,例如:读取产品说明文档。';
  184. return '请告诉我要操作的文档名称和位置,例如:删除产品说明文档第一行、末行,或修改产品说明文档第二段为:新的内容。';
  185. };
  186. const chineseNumerals: Record<string, number> = {
  187. 零: 0,
  188. 〇: 0,
  189. 一: 1,
  190. 二: 2,
  191. 两: 2,
  192. 三: 3,
  193. 四: 4,
  194. 五: 5,
  195. 六: 6,
  196. 七: 7,
  197. 八: 8,
  198. 九: 9,
  199. 十: 10,
  200. 百: 100,
  201. };
  202. const parseChineseInteger = (value: string): number | undefined => {
  203. if (/^\d+$/.test(value)) return Number(value);
  204. let section = 0;
  205. let number = 0;
  206. for (const character of value) {
  207. const digit = chineseNumerals[character];
  208. if (digit === undefined) return undefined;
  209. if (digit === 10 || digit === 100) {
  210. section += (number || 1) * digit;
  211. number = 0;
  212. } else {
  213. number = digit;
  214. }
  215. }
  216. return section + number;
  217. };
  218. const parseBlockReference = (value: string): { reference?: string; remainder: string } => {
  219. const match = value.match(/(?:第\s*(\d+|[零〇一二两三四五六七八九十百]+)\s*(?:个)?\s*(?:行|段|块|标题|段落|条)|(?:最后|末尾|末)\s*(?:一行|一段|一个块|一块|一条|行|段|块|条)|首行|第一行)\s*$/i);
  220. if (!match) return { remainder: value };
  221. if (match[0].includes('最后') || match[0].includes('末尾') || match[0].includes('末')) {
  222. return { reference: 'last', remainder: value.slice(0, match.index).trim() };
  223. }
  224. if (match[0].includes('首行') || match[0].includes('第一行')) {
  225. return { reference: '1', remainder: value.slice(0, match.index).trim() };
  226. }
  227. const number = parseChineseInteger(match[1]);
  228. return number !== undefined && Number.isInteger(number) && number > 0
  229. ? { reference: String(number), remainder: value.slice(0, match.index).trim() }
  230. : { remainder: value };
  231. };
  232. const resolveBlockReference = async (documentId: string, reference: string): Promise<string | undefined> => {
  233. const blockResult = await blockService.getBlocks(documentId);
  234. const blocks = [...blockResult.blocks].sort((left, right) => left.block_order - right.block_order);
  235. const block = reference === 'last' ? blocks.at(-1) : blocks[Number(reference) - 1];
  236. return block?.id;
  237. };
  238. const parseLocalDocumentCommand = async (content: string): Promise<
  239. { command?: LocalDocumentCommand; response?: string; handled: boolean }
  240. > => {
  241. const text = content.trim();
  242. const actionMatch = text.match(/^(打开|查看|阅读|读取|导出|下载|删除|修改|更新|新增|插入)\s*(.*?)(?:文档|文件)?(?:这个文档|该文档)?$/i);
  243. if (!actionMatch) return { handled: false };
  244. const actionText = actionMatch[1];
  245. let remainder = actionMatch[2].trim().replace(/^(?:文档|文件)\s*/, '').trim();
  246. const isBlockAction = /^(删除|修改|更新|新增|插入)$/.test(actionText);
  247. let blockId: string | undefined;
  248. let blockReference: string | undefined;
  249. let blockContent: string | undefined;
  250. if (isBlockAction) {
  251. const blockMatch = remainder.match(/\b(block-[\w-]+)\b/i);
  252. blockId = blockMatch?.[1];
  253. blockContent = remainder.match(/(?:为|改为|内容为|插入|:|:)\s*(.+)$/)?.[1];
  254. remainder = remainder.replace(blockId || '', '').replace(/(?:为|改为|内容为|插入|:|:)\s*.+$/, '').trim();
  255. const parsedReference = parseBlockReference(remainder);
  256. blockReference = parsedReference.reference;
  257. remainder = parsedReference.remainder;
  258. remainder = remainder.replace(/(?:这个|该)?(?:文档|文件)\s*$/i, '').trim();
  259. }
  260. const toolName = actionText === '打开' ? 'open_document'
  261. : actionText === '导出' ? 'export_document'
  262. : actionText === '下载' ? 'download_export_record'
  263. : actionText === '读取' || actionText === '查看' || actionText === '阅读' ? 'get_document'
  264. : actionText === '删除' ? 'delete_block'
  265. : actionText === '新增' || actionText === '插入' ? 'insert_block'
  266. : 'update_block';
  267. if (!remainder || (isBlockAction && !blockId && !blockReference)) return { handled: true, response: getActionHelp(toolName) };
  268. const candidates = await getDocumentCandidates();
  269. const { candidate, ambiguous } = findDocumentCandidate(remainder, candidates);
  270. if (ambiguous) return { handled: true, response: `找到多份与“${remainder}”相近的文档,请提供更完整的文档名称。` };
  271. if (!candidate) return { handled: true, response: `没有找到名为“${remainder}”的文档,请确认文档名称后重试。` };
  272. if (blockReference) {
  273. try {
  274. blockId = await resolveBlockReference(candidate.documentId, blockReference);
  275. } catch (error) {
  276. console.warn('[WebMCP] 获取文档块失败:', error);
  277. }
  278. if (!blockId) {
  279. const position = blockReference === 'last' ? '末尾' : `第 ${blockReference}`;
  280. return { handled: true, response: `没有找到“${remainder}”的${position}内容块,请确认位置后重试。` };
  281. }
  282. }
  283. const input: Record<string, unknown> = toolName === 'download_export_record'
  284. ? { recordId: candidate.recordId || candidate.documentId }
  285. : { documentId: candidate.documentId };
  286. if (blockId) {
  287. if (toolName === 'insert_block') input.afterBlockId = blockId;
  288. else input.blockId = blockId;
  289. }
  290. if (toolName === 'update_block' && blockContent) input.content = blockContent;
  291. if (toolName === 'insert_block' && blockContent) {
  292. input.type = 'paragraph';
  293. input.content = blockContent;
  294. }
  295. if (isBlockAction && (!input.content && toolName !== 'delete_block')) {
  296. return { handled: true, response: `请补充要${toolName === 'insert_block' ? '插入' : '修改'}的内容。` };
  297. }
  298. return { handled: true, command: { toolName, input, documentTitle: candidate.title } };
  299. };
  300. const translateInput = async (content: string) =>
  301. translateWebMcpInput(content, {
  302. availableTools: getWebMcpTools().map((tool) => tool.name),
  303. documentCandidates: await getDocumentCandidates(),
  304. locale: 'zh-CN',
  305. });
  306. const isDocumentAction = (content: string): boolean =>
  307. /(打开|查看|阅读|读取|删除|修改|更新|新增|插入|导出|下载).*(文档|文件|区块|块)/i.test(content);
  308. const documentActionHelp = (content: string): string => {
  309. if (/(打开|查看|阅读|读取)/i.test(content)) {
  310. return '我还不知道你要打开哪份文档,请告诉我文档名称,例如:打开产品说明文档。';
  311. }
  312. if (/(删除|修改|更新|新增|插入)/i.test(content)) {
  313. return '请提供文档名称和文档块 ID,例如:修改产品说明文档 block-p-50 为:新的内容。';
  314. }
  315. if (/(导出)/i.test(content)) return '请告诉我要导出的文档名称,例如:导出产品说明文档。';
  316. if (/(下载)/i.test(content)) return '请提供导出记录 ID,例如:下载导出记录 rec-abc123。';
  317. return '请补充要操作的文档名称或文档块信息。';
  318. };
  319. const friendlyResult = (
  320. toolName: string,
  321. input: Record<string, unknown>,
  322. result: WebMcpToolResult
  323. ): string => {
  324. if (!result.ok) return `操作未完成:${result.error || '未知错误'}`;
  325. const documentId = typeof input.documentId === 'string' ? input.documentId : '';
  326. const blockId = typeof input.blockId === 'string' ? input.blockId : '';
  327. const documentLabel = typeof input.documentTitle === 'string' ? input.documentTitle : documentId;
  328. if (toolName === 'open_document') return `已帮您打开“${documentLabel || '指定'}”文档。`;
  329. if (toolName === 'get_document') return `已读取“${documentLabel || documentId}”文档内容。`;
  330. if (toolName === 'search_document') return `已完成“${documentLabel || documentId}”文档搜索。`;
  331. if (toolName === 'update_block') return `已修改“${documentLabel || documentId}”中的区块 ${blockId}。`;
  332. if (toolName === 'delete_block') return `已删除“${documentLabel || documentId}”中的区块 ${blockId}。`;
  333. if (toolName === 'insert_block') return `已向“${documentLabel || documentId}”插入新的内容块。`;
  334. if (toolName === 'export_document') return `已开始导出“${documentLabel || documentId}”文档。`;
  335. if (toolName === 'download_export_record') return `已开始下载导出记录 ${String(input.recordId || '')}。`;
  336. return stringifyResult(result);
  337. };
  338. export const isWebMcpChatCommand = (content: string): boolean =>
  339. content.trim() === '查看 WebMCP 模板' || matchWebMcpChatTemplate(content) !== null;
  340. export const parseWebMcpChatCommand = (content: string) => matchWebMcpChatTemplate(content);
  341. export const executeWebMcpChatCommand = async (content: string): Promise<WebMcpChatResult> => {
  342. if (content.trim() === '查看 WebMCP 模板') {
  343. return { handled: true, response: webMcpTemplateHelp() };
  344. }
  345. const matched = matchWebMcpChatTemplate(content);
  346. const localCommand = await parseLocalDocumentCommand(content);
  347. if (localCommand.handled && localCommand.command) {
  348. const tool = getWebMcpTool(localCommand.command.toolName);
  349. if (!tool) return { handled: true, response: `WebMCP 工具不存在:${localCommand.command.toolName}` };
  350. const { toolName, input, documentTitle } = localCommand.command;
  351. if (tool.requiresConfirmation) {
  352. return { handled: true, toolName, input, requiresConfirmation: true, response: `即将对“${documentTitle}”执行${tool.description.replace('。需要用户确认。', '')},请确认。` };
  353. }
  354. const result = await executeWebMcpTool(toolName, input);
  355. return { handled: true, toolName, input, result, response: friendlyResult(toolName, { ...input, documentTitle }, result) };
  356. }
  357. if (localCommand.handled && localCommand.response) return { handled: true, response: localCommand.response };
  358. if (!matched || !matched.template.toolName) {
  359. try {
  360. const translated = await translateInput(content);
  361. const translation = translated.translation;
  362. if (translation.intent === 'clarify') {
  363. return { handled: true, response: translation.question || '请补充更多信息后重试。' };
  364. }
  365. if (!translation.toolCall) {
  366. return isDocumentAction(content)
  367. ? { handled: true, response: documentActionHelp(content) }
  368. : { handled: false };
  369. }
  370. const tool = getWebMcpTool(translation.toolCall.name);
  371. if (!tool) return { handled: true, response: `WebMCP 工具不存在:${translation.toolCall.name}` };
  372. const input = { ...translation.toolCall.arguments };
  373. const displayInput = { ...input };
  374. if (!displayInput.documentTitle && typeof displayInput.documentId === 'string') displayInput.documentTitle = displayInput.documentId;
  375. const requiresConfirmation = Boolean(tool.requiresConfirmation || translation.requiresConfirmation);
  376. if (requiresConfirmation) {
  377. return {
  378. handled: true,
  379. toolName: tool.name,
  380. input,
  381. requiresConfirmation: true,
  382. response: `即将${tool.description.replace('。需要用户确认。', '')},请确认。`,
  383. };
  384. }
  385. const result = await executeWebMcpTool(tool.name, input);
  386. return {
  387. handled: true,
  388. toolName: tool.name,
  389. input,
  390. requiresConfirmation: false,
  391. result,
  392. response: friendlyResult(tool.name, displayInput, result),
  393. };
  394. } catch (error) {
  395. console.warn('[WebMCP] 远端转译不可用,回退普通 AI:', error);
  396. return { handled: false };
  397. }
  398. }
  399. const toolName = matched.template.toolName;
  400. const tool = getWebMcpTool(toolName);
  401. if (!tool) return { handled: true, response: `WebMCP 工具不存在:${toolName}`, toolName };
  402. if (!READ_ONLY_TOOLS.has(toolName)) {
  403. return {
  404. handled: true,
  405. toolName,
  406. template: matched.template,
  407. input: matched.input,
  408. requiresConfirmation: true,
  409. response: `该操作需要确认:${matched.template.label}\n${JSON.stringify(matched.input, null, 2)}`,
  410. };
  411. }
  412. const result = await executeWebMcpTool(toolName, matched.input);
  413. return {
  414. handled: true,
  415. toolName,
  416. template: matched.template,
  417. input: matched.input,
  418. requiresConfirmation: false,
  419. result,
  420. response: friendlyResult(toolName, matched.input, result),
  421. };
  422. };
  423. export const confirmAndExecuteWebMcpChatCommand = async (
  424. contentOrCommand: string | Pick<WebMcpChatResult, 'toolName' | 'input' | 'template'> & { documentTitle?: string }
  425. ): Promise<WebMcpChatResult> => {
  426. if (typeof contentOrCommand !== 'string') {
  427. if (!contentOrCommand.toolName || !contentOrCommand.input) return { handled: false };
  428. const result = await executeWebMcpTool(contentOrCommand.toolName, contentOrCommand.input);
  429. return {
  430. handled: true,
  431. toolName: contentOrCommand.toolName,
  432. input: contentOrCommand.input,
  433. template: contentOrCommand.template,
  434. requiresConfirmation: false,
  435. result,
  436. response: friendlyResult(contentOrCommand.toolName, {
  437. ...contentOrCommand.input,
  438. documentTitle: contentOrCommand.documentTitle,
  439. }, result),
  440. };
  441. }
  442. const content = contentOrCommand;
  443. const localCommand = await parseLocalDocumentCommand(content);
  444. if (localCommand.command) {
  445. const { toolName, input, documentTitle } = localCommand.command;
  446. const result = await executeWebMcpTool(toolName, input);
  447. return {
  448. handled: true,
  449. toolName,
  450. input,
  451. requiresConfirmation: false,
  452. result,
  453. response: friendlyResult(toolName, { ...input, documentTitle }, result),
  454. };
  455. }
  456. const matched = matchWebMcpChatTemplate(content);
  457. if (!matched?.template.toolName) {
  458. try {
  459. const translated = await translateInput(content);
  460. const toolCall = translated.translation.toolCall;
  461. if (!toolCall) return { handled: false };
  462. const input = { ...toolCall.arguments };
  463. const result = await executeWebMcpTool(toolCall.name, input);
  464. return {
  465. handled: true,
  466. toolName: toolCall.name,
  467. input: toolCall.arguments,
  468. requiresConfirmation: false,
  469. result,
  470. response: friendlyResult(toolCall.name, input, result),
  471. };
  472. } catch (error) {
  473. return { handled: true, response: `WebMCP 操作执行失败:${error instanceof Error ? error.message : '未知错误'}` };
  474. }
  475. }
  476. const tool = getWebMcpTool(matched.template.toolName);
  477. if (!tool) return { handled: true, response: `WebMCP 工具不存在:${matched.template.toolName}` };
  478. const result = await executeWebMcpTool(matched.template.toolName, matched.input);
  479. return {
  480. handled: true,
  481. toolName: matched.template.toolName,
  482. template: matched.template,
  483. input: matched.input,
  484. requiresConfirmation: false,
  485. result,
  486. response: friendlyResult(matched.template.toolName, matched.input, result),
  487. };
  488. };