import { executeWebMcpTool, getWebMcpTool, type WebMcpToolResult } from './webMcpService'; import { matchWebMcpChatTemplate, webMcpTemplateHelp, type WebMcpChatTemplate, } from './webMcpChatTemplates'; import { getWebMcpTools } from './webMcpService'; import { translateWebMcpInput } from '../share/webmcp'; import { listDocuments } from './documentService'; import { blockService } from './blockService'; import { useEditorStore } from '../stores/editorStore'; export interface WebMcpChatResult { handled: boolean; response?: string; toolName?: string; requiresConfirmation?: boolean; template?: WebMcpChatTemplate; input?: Record; result?: WebMcpToolResult; } interface ExportCandidate { documentId: string; title: string; aliases?: string[]; recordId?: string; } interface LocalDocumentCommand { toolName: string; input: Record; documentTitle: string; } const READ_ONLY_TOOLS = new Set([ 'list_documents', 'get_document', 'search_document', 'get_block', 'get_document_toc', 'get_document_stats', 'list_export_records', ]); const DOCUMENT_CANDIDATE_CACHE_TTL = 15_000; let documentCandidatesCache: { value: ExportCandidate[]; expiresAt: number } | undefined; let documentCandidatesRequest: Promise | undefined; const stringifyResult = (result: WebMcpToolResult): string => { if (!result.ok) return `WebMCP 执行失败:${result.error || '未知错误'}`; return `WebMCP 执行结果:\n${JSON.stringify(result.data, null, 2)}`; }; const getDocumentCandidates = async (): Promise => { const currentDocumentId = useEditorStore.getState().documentId; const cacheContainsCurrentDocument = !currentDocumentId || documentCandidatesCache?.value.some( (candidate) => candidate.documentId === currentDocumentId ); if (documentCandidatesCache && documentCandidatesCache.expiresAt > Date.now() && cacheContainsCurrentDocument) { return documentCandidatesCache.value; } if (documentCandidatesRequest) return documentCandidatesRequest; documentCandidatesRequest = loadDocumentCandidates(); try { const value = await documentCandidatesRequest; documentCandidatesCache = { value, expiresAt: Date.now() + DOCUMENT_CANDIDATE_CACHE_TTL }; return value; } finally { documentCandidatesRequest = undefined; } }; const loadDocumentCandidates = async (): Promise => { const unique = new Map(); const getBlockText = (block: unknown): string => { if (!block || typeof block !== 'object') return ''; const content = (block as { content?: unknown }).content; if (typeof content === 'string') return content.trim(); if (Array.isArray(content)) { return content .map((part) => part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string' ? (part as { text: string }).text : '') .join('') .trim(); } return ''; }; const getFirstBlockText = (blocks: unknown): string => Array.isArray(blocks) ? blocks.map(getBlockText).find(Boolean) || '' : ''; const currentEditor = useEditorStore.getState(); if (currentEditor.documentId) { const currentBlockAliases = currentEditor.blocks .map(getBlockText) .filter(Boolean) .slice(0, 20); unique.set(currentEditor.documentId, { documentId: currentEditor.documentId, title: getFirstBlockText(currentEditor.blocks) || currentEditor.documentId, aliases: currentBlockAliases, }); } try { const documentResult = await listDocuments({ userId: 'default-user', page: 1, pageSize: 100, sortBy: 'updated_at', sortOrder: 'desc', }); await Promise.all(documentResult.documents.map(async (document) => { const detail = await executeWebMcpTool('get_document', { documentId: document.id }); const blocks = detail.ok && detail.data && typeof detail.data === 'object' ? (detail.data as { blocks?: unknown }).blocks : undefined; if (!Array.isArray(blocks)) return; const firstText = getFirstBlockText(blocks); const existing = unique.get(document.id); unique.set(document.id, { ...existing, documentId: document.id, title: firstText || existing?.title || document.id, }); })); } catch (error) { console.warn('[WebMCP] 获取文档名称失败,将继续使用导出记录:', error); } const exportResult = await executeWebMcpTool('list_export_records', { page: 1, pageSize: 100 }); if (exportResult.ok && exportResult.data && typeof exportResult.data === 'object') { const records = (exportResult.data as { records?: unknown }).records; if (Array.isArray(records)) { for (const record of records) { if (!record || typeof record !== 'object') continue; const item = record as Record; if (typeof item.documentId !== 'string' || typeof item.fileName !== 'string') continue; const candidate = unique.get(item.documentId) || { documentId: item.documentId, title: item.fileName.replace(/\.(docx?|DOCX?)$/i, '').replace(/[_-]\d{8,}$/, '').trim(), }; const exportTitle = item.fileName.replace(/\.(docx?|DOCX?)$/i, '').replace(/[_-]\d{8,}$/, '').trim(); if (exportTitle && exportTitle !== candidate.title) { candidate.aliases = [...new Set([...(candidate.aliases || []), exportTitle])]; } if (!candidate.recordId && typeof item.recordId === 'string') candidate.recordId = item.recordId; unique.set(item.documentId, candidate); } } } return [...unique.values()]; }; const normalizeDocumentText = (value: string): string => value .toLocaleLowerCase() .replace(/\.(docx?|DOCX?)$/i, '') .replace(/(?:文档|文件)$/i, '') .replace(/[“”"'「」《》]/g, '') .replace(/\s+/g, '') .trim(); const findDocumentCandidate = ( title: string, candidates: ExportCandidate[] ): { candidate?: ExportCandidate; ambiguous: boolean } => { const normalizedTitle = normalizeDocumentText(title); const isChineseShortName = (value: string): boolean => value.length >= 3 && /^[\u3400-\u9fff]+$/.test(value); const isOrderedChineseMatch = (shortName: string, fullName: string): boolean => { if (!isChineseShortName(shortName)) return false; let shortIndex = 0; for (const character of fullName) { if (character === shortName[shortIndex]) shortIndex += 1; if (shortIndex === shortName.length) return true; } return false; }; const matches = candidates.filter((candidate) => { const candidateTitles = [candidate.title, ...(candidate.aliases || [])].map(normalizeDocumentText); return candidate.documentId === title || candidate.recordId === title || candidateTitles.some((candidateTitle) => candidateTitle === normalizedTitle || candidateTitle.includes(normalizedTitle) || normalizedTitle.includes(candidateTitle) || isOrderedChineseMatch(normalizedTitle, candidateTitle) ); }); return { candidate: matches.length === 1 ? matches[0] : undefined, ambiguous: matches.length > 1 }; }; const getActionHelp = (action: string): string => { if (action === 'download_export_record') return '请告诉我要下载的文档名称,例如:下载产品说明文档。'; if (action === 'export_document') return '请告诉我要导出的文档名称,例如:导出产品说明文档。'; if (action === 'open_document') return '请告诉我要打开的文档名称,例如:打开产品说明文档。'; if (action === 'get_document') return '请告诉我要读取的文档名称,例如:读取产品说明文档。'; return '请告诉我要操作的文档名称和位置,例如:删除产品说明文档第一行、末行,或修改产品说明文档第二段为:新的内容。'; }; const chineseNumerals: Record = { 零: 0, 〇: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10, 百: 100, }; const parseChineseInteger = (value: string): number | undefined => { if (/^\d+$/.test(value)) return Number(value); let section = 0; let number = 0; for (const character of value) { const digit = chineseNumerals[character]; if (digit === undefined) return undefined; if (digit === 10 || digit === 100) { section += (number || 1) * digit; number = 0; } else { number = digit; } } return section + number; }; const parseBlockReference = (value: string): { reference?: string; remainder: string } => { const match = value.match(/(?:第\s*(\d+|[零〇一二两三四五六七八九十百]+)\s*(?:个)?\s*(?:行|段|块|标题|段落|条)|(?:最后|末尾|末)\s*(?:一行|一段|一个块|一块|一条|行|段|块|条)|首行|第一行)\s*$/i); if (!match) return { remainder: value }; if (match[0].includes('最后') || match[0].includes('末尾') || match[0].includes('末')) { return { reference: 'last', remainder: value.slice(0, match.index).trim() }; } if (match[0].includes('首行') || match[0].includes('第一行')) { return { reference: '1', remainder: value.slice(0, match.index).trim() }; } const number = parseChineseInteger(match[1]); return number !== undefined && Number.isInteger(number) && number > 0 ? { reference: String(number), remainder: value.slice(0, match.index).trim() } : { remainder: value }; }; const resolveBlockReference = async (documentId: string, reference: string): Promise => { const blockResult = await blockService.getBlocks(documentId); const blocks = [...blockResult.blocks].sort((left, right) => left.block_order - right.block_order); const block = reference === 'last' ? blocks.at(-1) : blocks[Number(reference) - 1]; return block?.id; }; const parseLocalDocumentCommand = async (content: string): Promise< { command?: LocalDocumentCommand; response?: string; handled: boolean } > => { const text = content.trim(); const actionMatch = text.match(/^(打开|查看|阅读|读取|导出|下载|删除|修改|更新|新增|插入)\s*(.*?)(?:文档|文件)?(?:这个文档|该文档)?$/i); if (!actionMatch) return { handled: false }; const actionText = actionMatch[1]; let remainder = actionMatch[2].trim().replace(/^(?:文档|文件)\s*/, '').trim(); const isBlockAction = /^(删除|修改|更新|新增|插入)$/.test(actionText); let blockId: string | undefined; let blockReference: string | undefined; let blockContent: string | undefined; if (isBlockAction) { const blockMatch = remainder.match(/\b(block-[\w-]+)\b/i); blockId = blockMatch?.[1]; blockContent = remainder.match(/(?:为|改为|内容为|插入|:|:)\s*(.+)$/)?.[1]; remainder = remainder.replace(blockId || '', '').replace(/(?:为|改为|内容为|插入|:|:)\s*.+$/, '').trim(); const parsedReference = parseBlockReference(remainder); blockReference = parsedReference.reference; remainder = parsedReference.remainder; remainder = remainder.replace(/(?:这个|该)?(?:文档|文件)\s*$/i, '').trim(); } const toolName = actionText === '打开' ? 'open_document' : actionText === '导出' ? 'export_document' : actionText === '下载' ? 'download_export_record' : actionText === '读取' || actionText === '查看' || actionText === '阅读' ? 'get_document' : actionText === '删除' ? 'delete_block' : actionText === '新增' || actionText === '插入' ? 'insert_block' : 'update_block'; if (!remainder || (isBlockAction && !blockId && !blockReference)) return { handled: true, response: getActionHelp(toolName) }; const candidates = await getDocumentCandidates(); const { candidate, ambiguous } = findDocumentCandidate(remainder, candidates); if (ambiguous) return { handled: true, response: `找到多份与“${remainder}”相近的文档,请提供更完整的文档名称。` }; if (!candidate) return { handled: true, response: `没有找到名为“${remainder}”的文档,请确认文档名称后重试。` }; if (blockReference) { try { blockId = await resolveBlockReference(candidate.documentId, blockReference); } catch (error) { console.warn('[WebMCP] 获取文档块失败:', error); } if (!blockId) { const position = blockReference === 'last' ? '末尾' : `第 ${blockReference}`; return { handled: true, response: `没有找到“${remainder}”的${position}内容块,请确认位置后重试。` }; } } const input: Record = toolName === 'download_export_record' ? { recordId: candidate.recordId || candidate.documentId } : { documentId: candidate.documentId }; if (blockId) { if (toolName === 'insert_block') input.afterBlockId = blockId; else input.blockId = blockId; } if (toolName === 'update_block' && blockContent) input.content = blockContent; if (toolName === 'insert_block' && blockContent) { input.type = 'paragraph'; input.content = blockContent; } if (isBlockAction && (!input.content && toolName !== 'delete_block')) { return { handled: true, response: `请补充要${toolName === 'insert_block' ? '插入' : '修改'}的内容。` }; } return { handled: true, command: { toolName, input, documentTitle: candidate.title } }; }; const translateInput = async (content: string) => translateWebMcpInput(content, { availableTools: getWebMcpTools().map((tool) => tool.name), documentCandidates: await getDocumentCandidates(), locale: 'zh-CN', }); const isDocumentAction = (content: string): boolean => /(打开|查看|阅读|读取|删除|修改|更新|新增|插入|导出|下载).*(文档|文件|区块|块)/i.test(content); const documentActionHelp = (content: string): string => { if (/(打开|查看|阅读|读取)/i.test(content)) { return '我还不知道你要打开哪份文档,请告诉我文档名称,例如:打开产品说明文档。'; } if (/(删除|修改|更新|新增|插入)/i.test(content)) { return '请提供文档名称和文档块 ID,例如:修改产品说明文档 block-p-50 为:新的内容。'; } if (/(导出)/i.test(content)) return '请告诉我要导出的文档名称,例如:导出产品说明文档。'; if (/(下载)/i.test(content)) return '请提供导出记录 ID,例如:下载导出记录 rec-abc123。'; return '请补充要操作的文档名称或文档块信息。'; }; const friendlyResult = ( toolName: string, input: Record, result: WebMcpToolResult ): string => { if (!result.ok) return `操作未完成:${result.error || '未知错误'}`; const documentId = typeof input.documentId === 'string' ? input.documentId : ''; const blockId = typeof input.blockId === 'string' ? input.blockId : ''; const documentLabel = typeof input.documentTitle === 'string' ? input.documentTitle : documentId; if (toolName === 'open_document') return `已帮您打开“${documentLabel || '指定'}”文档。`; if (toolName === 'get_document') return `已读取“${documentLabel || documentId}”文档内容。`; if (toolName === 'search_document') return `已完成“${documentLabel || documentId}”文档搜索。`; if (toolName === 'update_block') return `已修改“${documentLabel || documentId}”中的区块 ${blockId}。`; if (toolName === 'delete_block') return `已删除“${documentLabel || documentId}”中的区块 ${blockId}。`; if (toolName === 'insert_block') return `已向“${documentLabel || documentId}”插入新的内容块。`; if (toolName === 'export_document') return `已开始导出“${documentLabel || documentId}”文档。`; if (toolName === 'download_export_record') return `已开始下载导出记录 ${String(input.recordId || '')}。`; return stringifyResult(result); }; export const isWebMcpChatCommand = (content: string): boolean => content.trim() === '查看 WebMCP 模板' || matchWebMcpChatTemplate(content) !== null; export const parseWebMcpChatCommand = (content: string) => matchWebMcpChatTemplate(content); export const executeWebMcpChatCommand = async (content: string): Promise => { if (content.trim() === '查看 WebMCP 模板') { return { handled: true, response: webMcpTemplateHelp() }; } const matched = matchWebMcpChatTemplate(content); const localCommand = await parseLocalDocumentCommand(content); if (localCommand.handled && localCommand.command) { const tool = getWebMcpTool(localCommand.command.toolName); if (!tool) return { handled: true, response: `WebMCP 工具不存在:${localCommand.command.toolName}` }; const { toolName, input, documentTitle } = localCommand.command; if (tool.requiresConfirmation) { return { handled: true, toolName, input, requiresConfirmation: true, response: `即将对“${documentTitle}”执行${tool.description.replace('。需要用户确认。', '')},请确认。` }; } const result = await executeWebMcpTool(toolName, input); return { handled: true, toolName, input, result, response: friendlyResult(toolName, { ...input, documentTitle }, result) }; } if (localCommand.handled && localCommand.response) return { handled: true, response: localCommand.response }; if (!matched || !matched.template.toolName) { try { const translated = await translateInput(content); const translation = translated.translation; if (translation.intent === 'clarify') { return { handled: true, response: translation.question || '请补充更多信息后重试。' }; } if (!translation.toolCall) { return isDocumentAction(content) ? { handled: true, response: documentActionHelp(content) } : { handled: false }; } const tool = getWebMcpTool(translation.toolCall.name); if (!tool) return { handled: true, response: `WebMCP 工具不存在:${translation.toolCall.name}` }; const input = { ...translation.toolCall.arguments }; const displayInput = { ...input }; if (!displayInput.documentTitle && typeof displayInput.documentId === 'string') displayInput.documentTitle = displayInput.documentId; const requiresConfirmation = Boolean(tool.requiresConfirmation || translation.requiresConfirmation); if (requiresConfirmation) { return { handled: true, toolName: tool.name, input, requiresConfirmation: true, response: `即将${tool.description.replace('。需要用户确认。', '')},请确认。`, }; } const result = await executeWebMcpTool(tool.name, input); return { handled: true, toolName: tool.name, input, requiresConfirmation: false, result, response: friendlyResult(tool.name, displayInput, result), }; } catch (error) { console.warn('[WebMCP] 远端转译不可用,回退普通 AI:', error); return { handled: false }; } } const toolName = matched.template.toolName; const tool = getWebMcpTool(toolName); if (!tool) return { handled: true, response: `WebMCP 工具不存在:${toolName}`, toolName }; if (!READ_ONLY_TOOLS.has(toolName)) { return { handled: true, toolName, template: matched.template, input: matched.input, requiresConfirmation: true, response: `该操作需要确认:${matched.template.label}\n${JSON.stringify(matched.input, null, 2)}`, }; } const result = await executeWebMcpTool(toolName, matched.input); return { handled: true, toolName, template: matched.template, input: matched.input, requiresConfirmation: false, result, response: friendlyResult(toolName, matched.input, result), }; }; export const confirmAndExecuteWebMcpChatCommand = async ( contentOrCommand: string | Pick & { documentTitle?: string } ): Promise => { if (typeof contentOrCommand !== 'string') { if (!contentOrCommand.toolName || !contentOrCommand.input) return { handled: false }; const result = await executeWebMcpTool(contentOrCommand.toolName, contentOrCommand.input); return { handled: true, toolName: contentOrCommand.toolName, input: contentOrCommand.input, template: contentOrCommand.template, requiresConfirmation: false, result, response: friendlyResult(contentOrCommand.toolName, { ...contentOrCommand.input, documentTitle: contentOrCommand.documentTitle, }, result), }; } const content = contentOrCommand; const localCommand = await parseLocalDocumentCommand(content); if (localCommand.command) { const { toolName, input, documentTitle } = localCommand.command; const result = await executeWebMcpTool(toolName, input); return { handled: true, toolName, input, requiresConfirmation: false, result, response: friendlyResult(toolName, { ...input, documentTitle }, result), }; } const matched = matchWebMcpChatTemplate(content); if (!matched?.template.toolName) { try { const translated = await translateInput(content); const toolCall = translated.translation.toolCall; if (!toolCall) return { handled: false }; const input = { ...toolCall.arguments }; const result = await executeWebMcpTool(toolCall.name, input); return { handled: true, toolName: toolCall.name, input: toolCall.arguments, requiresConfirmation: false, result, response: friendlyResult(toolCall.name, input, result), }; } catch (error) { return { handled: true, response: `WebMCP 操作执行失败:${error instanceof Error ? error.message : '未知错误'}` }; } } const tool = getWebMcpTool(matched.template.toolName); if (!tool) return { handled: true, response: `WebMCP 工具不存在:${matched.template.toolName}` }; const result = await executeWebMcpTool(matched.template.toolName, matched.input); return { handled: true, toolName: matched.template.toolName, template: matched.template, input: matched.input, requiresConfirmation: false, result, response: friendlyResult(matched.template.toolName, matched.input, result), }; };