| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522 |
- 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<string, unknown>;
- result?: WebMcpToolResult;
- }
- interface ExportCandidate {
- documentId: string;
- title: string;
- aliases?: string[];
- recordId?: string;
- }
- interface LocalDocumentCommand {
- toolName: string;
- input: Record<string, unknown>;
- 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<ExportCandidate[]> | 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<ExportCandidate[]> => {
- 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<ExportCandidate[]> => {
- const unique = new Map<string, ExportCandidate>();
- 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<string, unknown>;
- 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<string, number> = {
- 零: 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<string | undefined> => {
- 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<string, unknown> = 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<string, unknown>,
- 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<WebMcpChatResult> => {
- 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<WebMcpChatResult, 'toolName' | 'input' | 'template'> & { documentTitle?: string }
- ): Promise<WebMcpChatResult> => {
- 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),
- };
- };
|