webMcpService.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import { blockService } from './blockService';
  2. import { documentService } from './documentService';
  3. import { exportRecordService, listExportRecords } from './exportRecordService';
  4. import { exportToWord } from './exportService';
  5. import type { BlockType, CreateBlockRequest, UpdateBlockRequest } from '../types/editor';
  6. import type { DocumentListFilters } from '../types/document';
  7. import { useUIStore } from '../stores/uiStore';
  8. import { finishWebMcpActivity, startWebMcpActivity } from './webMcpActivityService';
  9. import { BLOCK_CREATED_EVENT, BLOCK_DELETED_EVENT, BLOCK_UPDATED_EVENT } from './blockService';
  10. export interface WebMcpToolResult {
  11. ok: boolean;
  12. data?: unknown;
  13. error?: string;
  14. }
  15. export interface WebMcpToolInputSchema {
  16. type: 'object';
  17. properties: Record<string, Record<string, unknown>>;
  18. required?: string[];
  19. additionalProperties?: boolean;
  20. }
  21. export interface WebMcpTool {
  22. name: string;
  23. title: string;
  24. description: string;
  25. inputSchema: WebMcpToolInputSchema;
  26. requiresConfirmation?: boolean;
  27. readOnlyHint?: boolean;
  28. untrustedContentHint?: boolean;
  29. execute: (input: Record<string, unknown>) => Promise<WebMcpToolResult>;
  30. }
  31. interface ModelContext {
  32. registerTool?: (tool: Omit<WebMcpTool, 'requiresConfirmation'>) => void | Promise<void>;
  33. provideContext?: (context: { tools: ReadonlyArray<Omit<WebMcpTool, 'requiresConfirmation'>> }) =>
  34. | void
  35. | Promise<void>;
  36. }
  37. interface WebMcpNavigator extends Navigator {
  38. modelContext?: ModelContext;
  39. }
  40. interface WebMcpDocument extends Document {
  41. modelContext?: ModelContext;
  42. }
  43. export interface WebMcpRegistrationResult {
  44. supported: boolean;
  45. registeredTools: string[];
  46. error?: string;
  47. }
  48. export interface WebMcpExecutionOptions {
  49. confirmed?: boolean;
  50. }
  51. const DEFAULT_USER_ID = import.meta.env.VITE_WEBMCP_USER_ID || 'default-user';
  52. let registrationPromise: Promise<WebMcpRegistrationResult> | undefined;
  53. const text = (data: unknown): WebMcpToolResult => ({ ok: true, data });
  54. const isRecord = (value: unknown): value is Record<string, unknown> =>
  55. typeof value === 'object' && value !== null && !Array.isArray(value);
  56. const failed = (error: unknown): WebMcpToolResult => ({
  57. ok: false,
  58. error: error instanceof Error ? error.message : 'WebMCP 工具执行失败',
  59. });
  60. const requiredString = (input: Record<string, unknown>, key: string): string => {
  61. const value = input[key];
  62. if (typeof value !== 'string' || !value.trim()) {
  63. throw new Error(`${key} 不能为空`);
  64. }
  65. const hasUnsafeCharacter = Array.from(value).some((character) => {
  66. const codePoint = character.codePointAt(0) ?? 0;
  67. return codePoint < 32 || codePoint === 127 || character === '/' || character === '\\';
  68. });
  69. if (value.length > 128 || hasUnsafeCharacter) {
  70. throw new Error(`${key} 无效`);
  71. }
  72. return value.trim();
  73. };
  74. const requiredContent = (input: Record<string, unknown>, key: string): string | object | unknown[] => {
  75. const value = input[key];
  76. if (typeof value === 'string') {
  77. if (!value.trim()) throw new Error(`${key} 不能为空`);
  78. if (value.length > 200_000) throw new Error(`${key} 长度过长`);
  79. return value;
  80. }
  81. if (Array.isArray(value) || isRecord(value)) {
  82. if (JSON.stringify(value).length > 200_000) throw new Error(`${key} 内容过大`);
  83. return value;
  84. }
  85. throw new Error(`${key} 必须是字符串、对象或数组`);
  86. };
  87. const optionalString = (input: Record<string, unknown>, key: string): string | undefined => {
  88. const value = input[key];
  89. if (value === undefined || value === null || value === '') return undefined;
  90. if (typeof value !== 'string') throw new Error(`${key} 必须是字符串`);
  91. if (value.length > 128) throw new Error(`${key} 无效`);
  92. return value.trim();
  93. };
  94. const numberOr = (input: Record<string, unknown>, key: string, fallback: number): number => {
  95. const value = input[key];
  96. if (value === undefined) return fallback;
  97. if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`${key} 必须是数字`);
  98. return value;
  99. };
  100. const contentProperty = {
  101. type: ['string', 'object', 'array'],
  102. description: '块内容:字符串、结构化对象或数组',
  103. maxLength: 200000,
  104. };
  105. const run = async (operation: () => Promise<unknown>): Promise<WebMcpToolResult> => {
  106. try {
  107. return text(await operation());
  108. } catch (error) {
  109. return failed(error);
  110. }
  111. };
  112. const objectSchema = (
  113. properties: Record<string, Record<string, unknown>>,
  114. required: string[] = []
  115. ): WebMcpToolInputSchema => ({
  116. type: 'object',
  117. properties,
  118. ...(required.length ? { required } : {}),
  119. additionalProperties: false,
  120. });
  121. const validateToolInput = (tool: WebMcpTool, input: Record<string, unknown>): string | undefined => {
  122. const schema = tool.inputSchema;
  123. for (const key of schema.required || []) {
  124. if (input[key] === undefined || input[key] === null || input[key] === '') return `${key} 不能为空`;
  125. }
  126. if (schema.additionalProperties === false) {
  127. const unknownKey = Object.keys(input).find((key) => !(key in schema.properties));
  128. if (unknownKey) return `不支持的参数: ${unknownKey}`;
  129. }
  130. for (const [key, definition] of Object.entries(schema.properties)) {
  131. const value = input[key];
  132. if (value === undefined || value === null) continue;
  133. const expectedTypes = Array.isArray(definition.type) ? definition.type : [definition.type];
  134. const actualType = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
  135. if (!expectedTypes.includes(actualType)) {
  136. return `${key} 必须是${expectedTypes.join('、')}`;
  137. }
  138. if (expectedTypes.includes('string') && typeof value === 'string') {
  139. if (definition.minLength !== undefined && value.length < Number(definition.minLength)) return `${key} 长度过短`;
  140. if (definition.maxLength !== undefined && value.length > Number(definition.maxLength)) return `${key} 长度过长`;
  141. } else if (definition.maxLength !== undefined && (Array.isArray(value) || isRecord(value))) {
  142. try {
  143. if (JSON.stringify(value).length > Number(definition.maxLength)) return `${key} 内容过大`;
  144. } catch {
  145. return `${key} 内容无法序列化`;
  146. }
  147. }
  148. if (expectedTypes.includes('number') && typeof value === 'number' && !Number.isFinite(value)) return `${key} 必须是数字`;
  149. if (expectedTypes.includes('number') && typeof value === 'number') {
  150. if (definition.minimum !== undefined && value < Number(definition.minimum)) return `${key} 不能小于 ${definition.minimum}`;
  151. if (definition.maximum !== undefined && value > Number(definition.maximum)) return `${key} 不能大于 ${definition.maximum}`;
  152. }
  153. if (definition.enum && Array.isArray(definition.enum) && !definition.enum.includes(value)) return `${key} 参数值无效`;
  154. }
  155. return undefined;
  156. };
  157. const documentIdProperty = { type: 'string', description: '文档 ID' };
  158. const blockIdProperty = { type: 'string', description: '块 ID' };
  159. const tools: WebMcpTool[] = [
  160. {
  161. name: 'open_document',
  162. title: '打开文档',
  163. description: '在当前网站编辑器中打开指定文档。请使用文档 ID 或导出文件的完整唯一文件名;同名文档需先列出候选。只读操作。',
  164. readOnlyHint: true,
  165. inputSchema: objectSchema({ documentId: documentIdProperty }, ['documentId']),
  166. execute: (input) => run(async () => {
  167. const documentId = requiredString(input, 'documentId');
  168. useUIStore.getState().openDocumentPreview(documentId);
  169. return { documentId, message: '文档已打开' };
  170. }),
  171. },
  172. {
  173. name: 'list_documents',
  174. title: '列出文档',
  175. description: '列出当前用户的文档。只读操作。',
  176. readOnlyHint: true,
  177. inputSchema: objectSchema({
  178. sessionId: { type: 'string', description: '可选,会话 ID' },
  179. page: { type: 'number', minimum: 1 },
  180. pageSize: { type: 'number', minimum: 1, maximum: 100 },
  181. }),
  182. execute: (input) =>
  183. run(() => {
  184. const filters: DocumentListFilters = {
  185. userId: DEFAULT_USER_ID,
  186. page: numberOr(input, 'page', 1),
  187. pageSize: numberOr(input, 'pageSize', 20),
  188. sessionId: optionalString(input, 'sessionId'),
  189. sortBy: 'updated_at',
  190. sortOrder: 'desc',
  191. };
  192. return documentService.list(filters);
  193. }),
  194. },
  195. {
  196. name: 'get_document',
  197. title: '读取文档',
  198. description: '读取指定文档的元数据和全部内容块。只读操作。',
  199. readOnlyHint: true,
  200. untrustedContentHint: true,
  201. inputSchema: objectSchema({ documentId: documentIdProperty }, ['documentId']),
  202. execute: (input) => run(() => documentService.get(requiredString(input, 'documentId'), { includeBlocks: true })),
  203. },
  204. {
  205. name: 'search_document',
  206. title: '搜索文档',
  207. description: '在文档中搜索包含关键词的内容块。只读操作。',
  208. readOnlyHint: true,
  209. untrustedContentHint: true,
  210. inputSchema: objectSchema(
  211. { documentId: documentIdProperty, query: { type: 'string', description: '搜索关键词', maxLength: 2000 }, type: { type: 'string', maxLength: 64 } },
  212. ['documentId', 'query']
  213. ),
  214. execute: (input) =>
  215. run(() => blockService.searchBlocks(requiredString(input, 'documentId'), requiredString(input, 'query'), optionalString(input, 'type'))),
  216. },
  217. {
  218. name: 'get_block',
  219. title: '读取文档块',
  220. description: '读取指定文档块。只读操作。',
  221. readOnlyHint: true,
  222. untrustedContentHint: true,
  223. inputSchema: objectSchema({ documentId: documentIdProperty, blockId: blockIdProperty }, ['documentId', 'blockId']),
  224. execute: (input) => run(() => blockService.getBlock(requiredString(input, 'documentId'), requiredString(input, 'blockId'))),
  225. },
  226. {
  227. name: 'get_document_toc',
  228. title: '读取文档目录',
  229. description: '获取文档目录树。只读操作。',
  230. readOnlyHint: true,
  231. untrustedContentHint: true,
  232. inputSchema: objectSchema({ documentId: documentIdProperty }, ['documentId']),
  233. execute: (input) => run(() => blockService.getTOC(requiredString(input, 'documentId'))),
  234. },
  235. {
  236. name: 'get_document_stats',
  237. title: '读取文档统计',
  238. description: '获取文档内容块统计信息。只读操作。',
  239. readOnlyHint: true,
  240. inputSchema: objectSchema({ documentId: documentIdProperty }, ['documentId']),
  241. execute: (input) => run(() => blockService.getStats(requiredString(input, 'documentId'))),
  242. },
  243. {
  244. name: 'list_export_records',
  245. title: '列出导出记录',
  246. description: '列出当前用户的导出记录。只读操作。',
  247. readOnlyHint: true,
  248. inputSchema: objectSchema({ page: { type: 'number', minimum: 1 }, pageSize: { type: 'number', minimum: 1, maximum: 100 } }),
  249. execute: (input) =>
  250. run(() => listExportRecords({
  251. userId: DEFAULT_USER_ID,
  252. page: numberOr(input, 'page', 1),
  253. pageSize: numberOr(input, 'pageSize', 20),
  254. sortOrder: 'desc',
  255. })),
  256. },
  257. {
  258. name: 'insert_block',
  259. title: '插入内容块',
  260. description: '向文档插入内容块。需要用户确认。',
  261. requiresConfirmation: true,
  262. inputSchema: objectSchema(
  263. {
  264. documentId: documentIdProperty,
  265. type: { type: 'string', enum: ['heading', 'paragraph', 'table', 'image', 'toc'] },
  266. content: contentProperty,
  267. level: { type: 'number', minimum: 0, maximum: 6 },
  268. afterBlockId: { type: 'string', description: '插入到此块之后,可选' },
  269. clientBlockId: { type: 'string', description: '可选,客户端幂等块 ID' },
  270. },
  271. ['documentId', 'type', 'content']
  272. ),
  273. execute: (input) => run(async () => {
  274. const type = requiredString(input, 'type') as BlockType;
  275. const level = input.level === undefined ? 0 : numberOr(input, 'level', 0);
  276. if (type === 'heading' && (level < 1 || level > 6)) {
  277. throw new Error('heading 的 level 必须是 1-6');
  278. }
  279. if (type !== 'heading' && level !== 0) {
  280. throw new Error('非 heading 块的 level 必须是 0');
  281. }
  282. const documentId = requiredString(input, 'documentId');
  283. const response = await blockService.createBlock(documentId, {
  284. type,
  285. content: requiredContent(input, 'content') as CreateBlockRequest['content'],
  286. level,
  287. after_block_id: optionalString(input, 'afterBlockId'),
  288. client_block_id: optionalString(input, 'clientBlockId'),
  289. });
  290. const created = await blockService.getBlock(documentId, response.blockId);
  291. window.dispatchEvent(new CustomEvent(BLOCK_CREATED_EVENT, {
  292. detail: { documentId, block: created.block },
  293. }));
  294. return { ...response, block: created.block, message: 'Block created successfully' };
  295. }),
  296. },
  297. {
  298. name: 'update_block',
  299. title: '修改内容块',
  300. description: '更新文档块内容。需要用户确认。',
  301. requiresConfirmation: true,
  302. inputSchema: objectSchema({ documentId: documentIdProperty, blockId: blockIdProperty, content: contentProperty }, ['documentId', 'blockId', 'content']),
  303. execute: (input) => run(() => {
  304. const documentId = requiredString(input, 'documentId');
  305. const blockId = requiredString(input, 'blockId');
  306. const updates: UpdateBlockRequest = {
  307. content: requiredContent(input, 'content') as UpdateBlockRequest['content'],
  308. };
  309. return blockService.updateBlock(documentId, blockId, updates).then((result) => {
  310. window.dispatchEvent(new CustomEvent(BLOCK_UPDATED_EVENT, {
  311. detail: { documentId, blockId, updates },
  312. }));
  313. return result;
  314. });
  315. }),
  316. },
  317. {
  318. name: 'delete_block',
  319. title: '删除内容块',
  320. description: '删除指定文档块。需要用户确认。',
  321. requiresConfirmation: true,
  322. inputSchema: objectSchema({ documentId: documentIdProperty, blockId: blockIdProperty }, ['documentId', 'blockId']),
  323. execute: (input) => run(() => {
  324. const documentId = requiredString(input, 'documentId');
  325. const blockId = requiredString(input, 'blockId');
  326. return blockService.deleteBlock(documentId, blockId).then(() => {
  327. window.dispatchEvent(new CustomEvent(BLOCK_DELETED_EVENT, {
  328. detail: { documentId, blockId },
  329. }));
  330. return { message: 'Block deleted successfully' };
  331. });
  332. }),
  333. },
  334. {
  335. name: 'export_document',
  336. title: '导出文档',
  337. description: '将文档导出为 Word 文件。需要用户确认。',
  338. requiresConfirmation: true,
  339. inputSchema: objectSchema({ documentId: documentIdProperty }, ['documentId']),
  340. execute: (input) => run(() => exportToWord({ documentId: requiredString(input, 'documentId') })),
  341. },
  342. {
  343. name: 'download_export_record',
  344. title: '下载导出记录',
  345. description: '下载指定导出记录的文件。需要用户确认。',
  346. requiresConfirmation: true,
  347. inputSchema: objectSchema({ recordId: { type: 'string', description: '导出记录 ID' } }, ['recordId']),
  348. execute: (input) => run(async () => {
  349. const recordId = requiredString(input, 'recordId');
  350. await exportRecordService.download(recordId, DEFAULT_USER_ID);
  351. return { recordId, message: '下载已开始' };
  352. }),
  353. },
  354. ];
  355. const browserTools = (): ReadonlyArray<Omit<WebMcpTool, 'requiresConfirmation'>> =>
  356. tools.map((tool) => ({
  357. name: tool.name,
  358. title: tool.title,
  359. description: tool.description,
  360. inputSchema: tool.inputSchema,
  361. readOnlyHint: tool.readOnlyHint,
  362. untrustedContentHint: tool.untrustedContentHint,
  363. execute: (input) => {
  364. if (tool.requiresConfirmation) {
  365. const activityId = startWebMcpActivity(tool.name, input);
  366. const result = { ok: false, error: '该 WebMCP 工具需要当前页面用户确认' };
  367. finishWebMcpActivity(activityId, result);
  368. return Promise.resolve(result);
  369. }
  370. return executeWebMcpTool(tool.name, input);
  371. },
  372. }));
  373. export const getWebMcpTools = (): ReadonlyArray<WebMcpTool> => tools;
  374. export const getWebMcpTool = (name: string): WebMcpTool | undefined => tools.find((tool) => tool.name === name);
  375. export const executeWebMcpTool = async (
  376. name: string,
  377. input: unknown,
  378. options: WebMcpExecutionOptions = {}
  379. ): Promise<WebMcpToolResult> => {
  380. if (!isRecord(input)) {
  381. return { ok: false, error: 'WebMCP 工具参数必须是对象' };
  382. }
  383. const activityId = startWebMcpActivity(name, input);
  384. const tool = getWebMcpTool(name);
  385. if (!tool) {
  386. const result = { ok: false, error: `未知的 WebMCP 工具: ${name}` };
  387. finishWebMcpActivity(activityId, result);
  388. return result;
  389. }
  390. if (tool.requiresConfirmation && !options.confirmed) {
  391. const result = { ok: false, error: '该 WebMCP 工具需要当前页面用户确认' };
  392. finishWebMcpActivity(activityId, result);
  393. return result;
  394. }
  395. const validationError = validateToolInput(tool, input);
  396. if (validationError) {
  397. const result = { ok: false, error: validationError };
  398. finishWebMcpActivity(activityId, result);
  399. return result;
  400. }
  401. try {
  402. const result = await tool.execute(input);
  403. finishWebMcpActivity(activityId, result);
  404. return result;
  405. } catch (error) {
  406. const result = { ok: false, error: error instanceof Error ? error.message : '工具执行失败' };
  407. finishWebMcpActivity(activityId, result);
  408. return result;
  409. }
  410. };
  411. export const registerWebMcpTools = (): Promise<WebMcpRegistrationResult> => {
  412. if (registrationPromise) return registrationPromise;
  413. registrationPromise = (async () => {
  414. const modelContext =
  415. (document as WebMcpDocument).modelContext ?? (navigator as WebMcpNavigator).modelContext;
  416. if (!modelContext) return { supported: false, registeredTools: [], error: '当前浏览器未提供 navigator.modelContext' };
  417. try {
  418. const definitions = browserTools();
  419. if (modelContext.registerTool) for (const tool of definitions) await modelContext.registerTool(tool);
  420. else if (modelContext.provideContext) await modelContext.provideContext({ tools: definitions });
  421. else return { supported: true, registeredTools: [], error: 'WebMCP API 不支持工具注册' };
  422. return { supported: true, registeredTools: tools.map((tool) => tool.name) };
  423. } catch (error) {
  424. console.warn('[WebMCP] 工具注册失败,编辑器将继续运行', error);
  425. return { supported: true, registeredTools: [], error: error instanceof Error ? error.message : '工具注册失败' };
  426. }
  427. })();
  428. return registrationPromise;
  429. };
  430. export default tools;