diff --git a/packages/canvas/DesignCanvas/src/mcp/index.ts b/packages/canvas/DesignCanvas/src/mcp/index.ts index 01324bdca1..e8c48b20be 100644 --- a/packages/canvas/DesignCanvas/src/mcp/index.ts +++ b/packages/canvas/DesignCanvas/src/mcp/index.ts @@ -5,9 +5,22 @@ import { delNode, addNode, changeNodeProps, - selectSpecificNode + selectSpecificNode, + EditPageSchema } from './tools' +import resourcesExport from './resources' export default { - tools: [getCurrentSelectedNode, getPageSchema, queryNodeById, delNode, addNode, changeNodeProps, selectSpecificNode] + tools: [ + getCurrentSelectedNode, + getPageSchema, + queryNodeById, + delNode, + addNode, + changeNodeProps, + selectSpecificNode, + EditPageSchema + ], + resources: resourcesExport.resources, + resourceTemplates: resourcesExport.resourceTemplates } diff --git a/packages/canvas/DesignCanvas/src/mcp/resources/editPageSchemaExample.md b/packages/canvas/DesignCanvas/src/mcp/resources/editPageSchemaExample.md new file mode 100644 index 0000000000..3f0b8d3da2 --- /dev/null +++ b/packages/canvas/DesignCanvas/src/mcp/resources/editPageSchemaExample.md @@ -0,0 +1,367 @@ +# 编辑页面 Schema 示例文档 + +## 概述 +本文档提供 `edit_page_schema` 工具的完整使用示例。每个示例都包含场景描述、操作步骤和具体代码,帮助理解如何正确修改页面结构。 + +**重要原则**: +- 必须先阅读页面Schema协议了解数据结构 +- 严格遵循本文档中的操作步骤 +- 不同类型的修改有不同的标准流程 + +--- + +## State + +### 示例1: 基础操作:添加、更新和删除状态 + +**场景**:管理页面的状态变量 + +```json +{ + "section": "state", + "strategy": "merge", + "state": { + "add": { + "companyName": "", + "userCount": 0, + "isLoading": false, + "theme": { + "type": "JSExpression", + "value": "props.dark ? 'dark' : 'light'" + } + }, + "update": { + "buttons": [ + { "type": "primary", "text": "主要操作" }, + { "type": "default", "text": "次要操作" } + ] + }, + "remove": ["deprecatedKey", "oldVariable"] + } +} +``` + +**说明**: +- `add`:仅当键不存在时添加新状态变量 +- `update`:仅当键已存在时更新其值 +- `remove`:删除指定的状态变量(传入键名数组) + +### 示例2: 使用 replace 策略完整替换 state: + +```json +{ + "section": "state", + "strategy": "replace", + "state": { + "all": { + "isLoading": false, + "currentPage": 1, + "tableData": [], + "searchKeyword": "", + "theme": { + "type": "JSExpression", + "value": "this.props.theme || 'light'" + } + } + } +} +``` + +### 示例3: 高级用法:计算属性和访问器 + +**场景**:创建具有自动计算能力的状态 + +```json +{ + "section": "state", + "strategy": "merge", + "state": { + "add": { + "firstName": "", + "lastName": "", + "fullName": { + "defaultValue": "", + "accessor": { + "getter": { + "type": "JSFunction", + "value": "function getter(){ this.state.fullName = `${this.state.firstName} ${this.state.lastName}` }" + }, + "setter": { + "type": "JSFunction", + "value": "function setter(val){ const [firstName, lastName] = val.split(' '); this.emit('update:firstName', firstName); this.emit('update:lastName', lastName) }" + } + } + }, + "totalPrice": { + "type": "JSExpression", + "value": "this.props.quantity * this.props.unitPrice", + "computed": true + } + } + } +} +``` + +### 完整场景:为组件绑定变量 + +**场景**:用户选中了一个组件,需要将其内容绑定到页面状态变量 + +**标准操作流程**: + +#### 步骤1:在页面state中创建变量 + +```json +{ + "section": "state", + "strategy": "merge", + "state": { + "add": { + "testText": "defaultText", + "dynamicTitle": "欢迎使用TinyEngine", + "userGreeting": { + "type": "JSExpression", + "value": "`Hello, ${this.state.userName}!`", + "computed": true + } + } + } +} +``` + +#### 步骤2:将组件属性绑定到state变量 + +使用 `change_node_props` 工具(注意:这是另一个工具): + +```json +{ + "id": "text_component_id_123", + "props": { + "text": { + "type": "JSExpression", + "value": "this.state.testText" + } + } +} +``` + +**完整流程说明**: +1. 先通过 `get_current_selected_node` 获取选中组件的ID +2. 通过 `get_page_schema` 查看现有的state结构 +3. 使用 `edit_page_schema` 在state中添加变量(如上步骤1) +4. 使用 `change_node_props` 将组件的text属性绑定到state变量(如上步骤2) +5. 现在组件会动态显示 `testText` 变量的值,修改变量时组件会自动更新 + +--- + +## CSS + +### 示例1: 添加全局样式 + +适用场景:追加样式。不修改原来的样式。 +合并策略:在末尾追加。 + +```json +{ + "section": "css", + "strategy": "merge", + "css": "\n/* 页面容器样式 */\n.page-container { padding: 24px; background: #f5f5f5; }\n\n/* 自定义组件样式 */\n.custom-title { font-size: 24px; color: #1890ff; margin-bottom: 16px; }\n.custom-card { border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }" +} +``` + +### 示例2: 修改、替换样式 + +适用场景:修改、替换样式。 +合并策略:替换 schema 中的 css 字段为传入的新值。 + +```json +{ + "section": "css", + "strategy": "replace", + "css": "\n/* 页面容器样式 */\n.page-container { padding: 24px; background: #f2f2f2; }\n\n/* 自定义组件样式 */\n.custom-title { font-size: 18px; color: #1890ff; margin-bottom: 12px; }\n.custom-card { border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }" +} +``` + +### 示例3: 使用 change_node_props 工具 修改 props 的 style 属性修改样式。 + +使用场景:相当于行内样式,优先级高于 css 样式,建议仅在特殊场景使用。(⚠️ 不推荐所有样式都通过 style 属性修改,应该尽量使用 className 和 css 样式) + +```json +{ + "id": "component_id", + "props": { + "style": "color: red; font-size: 16px;" + } +} +``` + +### 完整示例1: 直接绑定 tailwind 样式到 className 属性。(推荐) + +> TinyEngine 支持 tailwind,可以快捷添加 tailwind 使用的原子类名到 className 属性上,实现样式的快速修改。 + +```json + +{ + "id": "card_container_id", + "props": { + "className": "bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-lg transition-all duration-200 cursor-pointer" + } +} +``` + +### 完整示例2:创建卡片组件样式 + +1. 通过 `edit_page_schema` 工具 添加 css 样式。 + +```json +{ + "section": "css", + "strategy": "merge", + "css": "\n/* 卡片组件样式 */\n.card { \n border: 1px solid #e0e0e0; \n border-radius: 8px; \n padding: 16px; \n box-shadow: 0 4px 8px rgba(0,0,0,0.1); \n}\n\n/* 头部样式 */\n.card-header { \n font-size: 18px; \n color: #333; \n margin-bottom: 12px; \n}\n\n/* 内容区样式 */\n.card-body { \n font-size: 14px; \n color: #666; \n}\n" +} +``` + +2. 通过 `change_node_props` 工具 设置 className。 + +```json +{ + "id": "card_container_id", + "props": { + "className": "card" + } +} +``` + +### CSS最佳实践 + +**推荐做法**: +1. **使用Tailwind优先**:利用Tailwind内置的实用类快速开发 +2. **语义化命名**:使用描述性的类名如`.user-card`而非`.card1` +3. **模块化组织**:相关样式放在一起,添加清晰注释 +4. **渐进式修改**:使用merge策略逐步添加样式 + +**需要注意**: +1. **谨慎覆盖基础样式**:修改component-base-style影响全局 +2. **记得绑定className**:添加新类后必须绑定到组件 +3. **合理选择样式方式**: + - 通用样式 → className + CSS + - 动态样式 → style属性(如JS计算值) + - 快速原型 → Tailwind CSS + +--- + +## Methods + +### 示例1: 替换方法 + +适用场景:将原有的 methods 中的所有方法删除,然后替换为新的方法。 +合并策略:替换 schema 中的 methods 字段为传入的新值。 + +```json +{ + "section": "methods", + "strategy": "replace", + "methods": { + "all": { + "onClick": { "type": "JSFunction", "value": "function onClick(){ console.log('clicked') }" } + } + } +} +``` + +### 示例2: 添加、更新、移除方法 + +适用场景:添加、更新、移除方法。 +合并策略:添加、更新、移除方法。 + +```json +{ + "section": "methods", + "strategy": "merge", + "methods": { + "add": { + "onSubmit": { "type": "JSFunction", "value": "function onSubmit(){ this.emit('submit') }" } + }, + "update": { + "onClick": { "type": "JSFunction", "value": "function onClick(){ alert('updated') }" } + }, + "remove": ["legacyMethod"] + } +} +``` + +--- + +## LifeCycles + +### 示例1: 添加、更新、移除生命周期 + +```json +{ + "section": "lifeCycles", + "strategy": "merge", + "lifeCycles": { + "add": { + "mounted": { "type": "JSFunction", "value": "function mounted(){ console.log('mounted') }" } + }, + "update": { + "mounted": { "type": "JSFunction", "value": "function mounted(){ console.log('mounted:updated') }" } + }, + "remove": ["beforeUnmount"] + } +} +``` + +### 示例2: 替换生命周期 + +适用场景:替换生命周期。 +合并策略:替换 schema 中的 lifeCycles 字段为传入的新值。 + +```json +{ + "section": "lifeCycles", + "strategy": "replace", + "lifeCycles": { + "all": { "mounted": { "type": "JSFunction", "value": "function mounted(){ console.log('mounted') }" } } + } +} +``` + +--- + +## Schema + +### 示例1: 编辑完整的 schema + +适用场景: +- 编辑 schema 中的 children 字段,并且涉及大量修改。(少量节点的精准修改,推荐使用 `change_node_props`、`add_node`、`delete_node` 等工具) +- 编辑 schema 中的其他字段,并且涉及大量修改。比如大量修改 state、methods、lifeCycles 等字段。 + +```json +{ + "section": "schema", + "strategy": "merge", + "schema": { + "props": { "title": "Dashboard" }, + "css": "\n/* 页面容器样式 */\n.page-container { padding: 24px; background: #f2f2f2; }\n\n/* 自定义组件样式 */\n.custom-title { font-size: 18px; color: #1890ff; margin-bottom: 12px; }\n.custom-card { border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }", + "state": { + "userCount": 0, + "isLoading": false, + }, + "methods": { + "onClick": { "type": "JSFunction", "value": "function onClick(){ console.log('clicked') }" } + }, + "lifeCycles": { + "mounted": { "type": "JSFunction", "value": "function mounted(){ console.log('mounted') }" } + }, + "children": [ + { + "id": "card_container_id", + "componentName": "Card", + "props": { + "title": "Card Title" + } + } + ] + } +} +``` diff --git a/packages/canvas/DesignCanvas/src/mcp/resources/editPageSchemaExample.ts b/packages/canvas/DesignCanvas/src/mcp/resources/editPageSchemaExample.ts new file mode 100644 index 0000000000..b9e728d7be --- /dev/null +++ b/packages/canvas/DesignCanvas/src/mcp/resources/editPageSchemaExample.ts @@ -0,0 +1,77 @@ +import editExamplesMd from './editPageSchemaExample.md?raw' +import { pickSectionByHeading } from './utils' + +// 分节枚举与标题映射(左侧为模板入参 section,右侧为文档中的中文/英文二级标题) +const EDIT_EXAMPLE_SECTION_TITLES: Record = { + overview: '概述', + state: 'State', + css: 'CSS', + lifeCycles: 'LifeCycles', + methods: 'Methods', + schema: 'Schema' +} + +// 根资源:编辑页面 Schema 的示例(整份文档) +export const editExamplesResources = [ + { + uri: 'tinyengine://docs/edit-page-schema-examples', + name: 'edit-page-schema-examples', + title: '编辑页面 Schema 的示例', + description: '围绕 edit_page_schema 工具的结构化示例与注意事项', + mimeType: 'text/markdown', + annotations: { audience: ['assistant', 'user'], priority: 0.85 }, + readCallback: async () => ({ + contents: [ + { + uri: 'tinyengine://docs/edit-page-schema-examples', + name: 'edit-page-schema-examples.md', + title: '编辑页面 Schema 的示例', + mimeType: 'text/markdown', + text: editExamplesMd + } + ] + }) + } +] + +// 模板资源:编辑页面 Schema 的示例(分节读取) +export const editExamplesResourceTemplates = [ + { + uriTemplate: 'tinyengine://docs/edit-page-schema-examples/{section}', + name: '编辑页面 Schema 的示例(分节)', + title: '编辑页面 Schema 的示例(分节)', + description: '按章节读取 edit_page_schema 的示例', + mimeType: 'text/markdown', + annotations: { audience: ['assistant', 'user'], priority: 0.85 }, + variables: [ + { + name: 'section', + required: true, + type: 'enum', + enumValues: Object.keys(EDIT_EXAMPLE_SECTION_TITLES).map((key) => ({ + value: key, + title: EDIT_EXAMPLE_SECTION_TITLES[key] + })) + } + ], + readTemplateCallback: async (_uri: URL, variables: Record) => { + const section = (variables?.section || '').toString() + const heading = EDIT_EXAMPLE_SECTION_TITLES[section] + if (!heading) { + throw new Error('Invalid template parameter: section') + } + const text = pickSectionByHeading(editExamplesMd, heading) + return { + contents: [ + { + uri: `tinyengine://docs/edit-page-schema-examples/${section}`, + name: `edit-page-schema-examples-${section}.md`, + title: `编辑页面 Schema 的示例 - ${heading}`, + mimeType: 'text/markdown', + text + } + ] + } + } + } +] diff --git a/packages/canvas/DesignCanvas/src/mcp/resources/index.ts b/packages/canvas/DesignCanvas/src/mcp/resources/index.ts new file mode 100644 index 0000000000..d6355d8c3c --- /dev/null +++ b/packages/canvas/DesignCanvas/src/mcp/resources/index.ts @@ -0,0 +1,13 @@ +import { pageSchemaResources, pageSchemaResourceTemplates } from './pageSchemaProtocol' +import { editExamplesResources, editExamplesResourceTemplates } from './editPageSchemaExample' +import { aiInstructResources, aiInstructResourceTemplates } from './tinyEngineAIInstruct' + +export const resources = [...pageSchemaResources, ...editExamplesResources, ...aiInstructResources] + +export const resourceTemplates = [ + ...pageSchemaResourceTemplates, + ...editExamplesResourceTemplates, + ...aiInstructResourceTemplates +] + +export default { resources, resourceTemplates } diff --git a/packages/canvas/DesignCanvas/src/mcp/resources/pageSchema.md b/packages/canvas/DesignCanvas/src/mcp/resources/pageSchema.md new file mode 100644 index 0000000000..09d5dffb2a --- /dev/null +++ b/packages/canvas/DesignCanvas/src/mcp/resources/pageSchema.md @@ -0,0 +1,466 @@ +## 概览 + +页面 schema 是 TinyEngine 在画布中表示页面/区块结构与行为的数据模型。其根节点(RootNode)涵盖样式、属性、状态、方法、生命周期、数据源与子节点树等信息。 + +## structure + +TinyEngine 的页面 schema 结构如下: + +```typescript +export interface IFuncType { + type: 'JSFunction' + value: string +} + +// 关键字段: +export interface RootNode { + id?: string + componentName: string + fileName?: string + css?: string // 页面 css,等同于 vue 的 style 标签内容 + props?: Record + state?: Record + lifeCycles?: Record // { setup?: IFuncType, onMounted?: IFuncType, ... } + methods?: Record + dataSource?: any + children?: Array + schema?: any +} +``` + +说明: +- `componentName` 为合法值:`Page` | `Block`。当前 schema 类型。page 为页面,block 为区块。 +- `fileName` 为文件名。TinyEngine 出码时,会根据 `fileName` 生成文件名。 +- `css` 为页面 CSS,等同于 vue 单文件的 style 标签内容。 +- `state` 为页面状态,等同于 vue 单文件中的 reactive 响应式对象。为对象(map),键是状态名。 +- `lifeCycles`、`methods` 的值必须是函数单元:`{ type: 'JSFunction', value: string }`。 +- `children` 为页面结构描述,等同于 vue 单文件的 template 标签内容。是可嵌套的节点树。 + +## State + +- 结构:`Record`。 +- 允许值:字面量、`JSExpression`、`JSResource`、`computed`、`accessor`(`getter`/`setter`)。 +- `merge` 策略: + - `add`:仅在键不存在时新增; + - `update`:仅在键已存在时更新; + - `remove`:删除指定顶层键; + - 不会进行深层递归合并,聚焦于“顶层键”。 + +### 示例 + +#### state 值示例 + +1. 常规值示例: + +```json +{ + "state": { + "firstName": "Opentiny", + "age": 18, + "food": ["apple", "orange", "banana"], + "isLoading": false, + "desc": { + "description": "", + "money": 100, + "other": null, + "rest": [{"type": "primary", "text": "主要操作"}] + }, + "utilsExample": "this.utils.test()", + "methodExample": { + "type": "JSFunction", + "value": "function methodExample(){ return 'methodExample' }" + }, + "i18nExample": { + "type": "i18n", + "key": "lowcode.example" + } + } +} +``` + +vue 等效代码: + +```javascript +const state = vue.reactive({ + firstName: "Opentiny", + age: 18, + food: ["apple", "orange", "banana"], + isLoading: false, + desc: { + description: "", + money: 100, + other: null, + rest: [{"type": "primary", "text": "主要操作"}] + }, + utilsExample: utils.test(), + methodExample: function methodExample(){ return 'methodExample' }, + i18nExample: t('lowcode.example') +}) +``` + +2. getter/setter 示例: + +```json +{ + "state": { + "firstName": "", + "lastName": "", + "fullName": { + "defaultValue": "", + "accessor": { + "getter": { + "type": "JSFunction", + "value": "function getter() { this.state.fullName = `${this.props.firstName} ${this.props.lastName}` }" + }, + "setter": { + "type": "JSFunction", + "value": "function setter(val) { this.state.fullName = `${this.props.firstName} ${this.props.lastName}` }" + } + } + } + } +} +``` + +vue 等效代码: + +```javascript +const state = vue.reactive({ + firstName: "", + lastName: "", + fullName: "" +}) + +vue.watchEffect( + wrap(function getter() { + this.state.fullName = `${this.props.firstName} ${this.props.lastName}` + }) +) + +vue.watchEffect( + wrap(function setter() { + this.state.fullName = `${this.props.firstName} ${this.props.lastName}` + }) +) +``` + +## CSS + +**说明**: + +- CSS 为页面 CSS,等同于 vue 单文件的 style 标签内容。 +- merge 策略: + - `merge`:在原有 CSS 文本末尾追加新片段(必要时自动换行)。 + - `replace`:整体覆盖。 + +### 示例 + +1. css schema 示例 + +```json +{ + "css": ".container { padding: 24px; margin: 20px; }\n.button-danger.button{ display: flex; justify-content: center; }" +} +``` + +## LifeCycles + +- 结构:`Record`,例如:`setup`、`onBeforeMount`、`onMounted` 等。 +- 函数单元格式:`{ type: 'JSFunction', value: string }`。 +- `merge`: + - `add`:键不存在时新增; + - `update`:键存在时替换; + - `remove`:删除指定键; +- `replace`:以 `all` 重建或用 `add+update` 重建。 + +### 示例 + +1. lifeCycles schema 示例: + +```json +{ + "lifeCycles": { + "setup": { + "type": "JSFunction", + "value": "function setup ({ props, state, watch, onMounted }) { console.log('lifecycle example') }" + }, + "onMounted": { + "type": "JSFunction", + "value": "function onMounted(){ this.getTableData && this.getTableData() }" + } + } +} +``` + +## Methods + +- 结构:`Record`,`IFuncType` 同上。 +- `merge/replace` 行为与 `lifeCycles` 相同。 + +### 示例 + +1. methods schema 示例 + +```json +{ + "methods": { + "methodExample": { + "type": "JSFunction", + "value": "function methodExample() {\n console.log('example')\n}\n" + } + } +} +``` + +## Children + +`children` 为节点树,元素形如: + +```typescript +export interface Node { + id: string + componentName: string + props: Record + children?: Node[] + componentType?: 'Block' + loop?: Record + loopArgs?: string[] + condition?: boolean | Record +} +``` + +### 示例 + +1. 常规值示例 + +```json +{ + "children": [ + { + "componentName": "div", + "props": { + "className": "py-10 rounded-md" + }, + "id": "2b2cabf0", + "children": [ + { + "componentName": "TinyTimeLine", + "props": { + "active": "2", + "data": [ + { + "name": "基础配置" + }, + { + "name": "网络配置" + } + ], + "horizontal": true, + "style": "border-radius: 0px;" + }, + "id": "dd764b17" + } + ] + } + ] +} +``` + +2. 使用 condition 进行条件渲染示例(等同于 vue 中的 v-if ) + +```json +{ + "children": [ + { + "componentName": "div", + "props": { + "className": "py-10 rounded-md" + }, + "id": "2b2cabf0", + "children": [], + "condition": { + "type": "JSExpression", + "value": "this.state.showContainer" + } + } + ] +} +``` + +3. 使用 loop 与 loopArgs 进行循环渲染示例 + +```json +{ + "children": [ + { + "componentName": "TinyButton", + "props": { + "text": { + "type": "JSExpression", + "value": "item.text" + }, + "className": "component-base-style", + "key": { + "type": "JSExpression", + "value": "index" + }, + "type": { + "type": "JSExpression", + "value": "item.type" + } + }, + "children": [], + "id": "22455446", + "loop": { + "type": "JSExpression", + "value": "this.state.loopExample" + }, + "loopArgs": [ + "item", + "index" + ] + } + ] +} +``` + +## Props 绑定与特殊协议 + +节点的属性值支持类型: +- `Literal` 字面量 +- `JSExpression` 表达式 +- `JSResource` 资源 +- `i18n` 国际化 + +### v-model/modelValue 绑定 + +1. v-model 绑定: + +```json +{ + "props": { + "modelValue": { + "type": "JSExpression", + "value": "this.state.inputValue", + "model": true + } + } +} +``` + +等效 vue : + +```javascript + +``` + +2. 带参数的 v-model 绑定: + +```json +{ + "props": { + "modelValue": { + "type": "JSExpression", + "value": "this.state.inputValue", + "model": { + "prop": "visible" + } + } + } +} +``` + +等效 vue : + +```javascript + +``` + +3. modelValue 与 onUpdate:modelValue 绑定: + +```json +{ + "props": { + "modelValue": { + "type": "JSExpression", + "value": "this.state.inputValue" + }, + "onUpdate:modelValue": { + "type": "JSExpression", + "value": "this.handleUpdateModelValue" + } + } +} +``` + +等效 vue : + +```javascript + +``` + +### i18n 绑定 + +1. i18n 绑定: + +```json +{ + "props": { + "i18n": { + "type": "i18n", + "key": "lowcode.example", + "params": { + "name": { + "type": "JSExpression", + "value": "this.state.userName" + } + } + } + } +} +``` + +等效 vue : + +```javascript + +``` + +### 函数绑定 + +1. 函数绑定: + +**正确示例**:(绑定 method 方法名) +```json +{ + "props": { + "onClick": { + "type": "JSExpression", + "value": "this.handleClick" + }, + "onBlur": { + "type": "JSExpression", + "value": "this.handleBlur", + "params": ["row.id"] + } + } +} +``` + +等效 vue : + +```javascript + +``` + +**错误示例**:(禁止使用) +```json +{ + "props": { + "onClick": { + "type": "JSFunction", + "value": "function handleClick() { console.log('example') }" + } + } +} +``` diff --git a/packages/canvas/DesignCanvas/src/mcp/resources/pageSchemaProtocol.ts b/packages/canvas/DesignCanvas/src/mcp/resources/pageSchemaProtocol.ts new file mode 100644 index 0000000000..56c4a643c8 --- /dev/null +++ b/packages/canvas/DesignCanvas/src/mcp/resources/pageSchemaProtocol.ts @@ -0,0 +1,99 @@ +import pageSchemaMd from './pageSchema.md?raw' +import { pickSectionByHeading } from './utils' + +// 分节枚举与标题映射(左侧为模板入参 section,右侧为文档中的中文/英文二级标题) +// 注意:这些映射用于在原始 markdown 文本中定位对应章节,务必与文档内标题保持一致 +const PAGE_SCHEMA_SECTION_TITLES: Record = { + overview: '概览', + structure: 'structure', + state: 'State', + css: 'CSS', + lifeCycles: 'LifeCycles', + methods: 'Methods', + children: 'Children', + props: 'Props 绑定与特殊协议' +} + +// 根资源:页面 Schema 协议(整份文档) +export const pageSchemaResources = [ + { + uri: 'tinyengine://docs/page-schema', + name: 'page-schema', + title: '页面 Schema 协议', + description: `TinyEngine 页面 Schema 字段说明、用途、约束与示例 + +章节概览: +• 概览:页面 schema 的基本概念和数据模型介绍 +• structure:页面 schema 接口定义和核心字段说明 +• State:页面状态管理,支持字面量、表达式、计算属性等 +• CSS:页面样式定义,等同于 Vue 单文件的 style 标签 +• LifeCycles:生命周期钩子函数,如 setup、onMounted 等 +• Methods:页面方法定义,支持函数绑定和调用 +• Children:节点树结构,支持条件渲染和循环渲染 +• Props 绑定与特殊协议:属性绑定规则,包括 v-model、i18n、函数绑定等`, + mimeType: 'text/markdown', + annotations: { audience: ['assistant', 'user'], priority: 0.95 }, + readCallback: async () => ({ + contents: [ + { + uri: 'tinyengine://docs/page-schema', + name: 'page-schema.md', + title: '页面 Schema 协议', + mimeType: 'text/markdown', + text: pageSchemaMd + } + ] + }) + } +] + +// 模板资源:页面 Schema 协议(分节读取) +export const pageSchemaResourceTemplates = [ + { + uriTemplate: 'tinyengine://docs/page-schema/{section}', + name: '页面 Schema 协议(分节)', + title: '页面 Schema 协议(分节)', + description: `按章节读取TinyEngine 页面 Schema 协议内容 +章节概览: +• 概览:页面 schema 的基本概念和数据模型介绍 +• structure:页面 schema 接口定义和核心字段说明 +• State:页面状态管理,支持字面量、表达式、计算属性等 +• CSS:页面样式定义,等同于 Vue 单文件的 style 标签 +• LifeCycles:生命周期钩子函数,如 setup、onMounted 等 +• Methods:页面方法定义,支持函数绑定和调用 +• Children:节点树结构,支持条件渲染和循环渲染 +• Props 绑定与特殊协议:属性绑定规则,包括 v-model、i18n、函数绑定等`, + mimeType: 'text/markdown', + annotations: { audience: ['assistant', 'user'], priority: 0.95 }, + variables: [ + { + name: 'section', + required: true, + type: 'enum', + enumValues: Object.keys(PAGE_SCHEMA_SECTION_TITLES).map((key) => ({ + value: key, + title: PAGE_SCHEMA_SECTION_TITLES[key] + })) + } + ], + readTemplateCallback: async (_uri: URL, variables: Record) => { + const section = (variables?.section || '').toString() + const heading = PAGE_SCHEMA_SECTION_TITLES[section] + if (!heading) { + throw new Error('Invalid template parameter: section') + } + const text = pickSectionByHeading(pageSchemaMd, heading) + return { + contents: [ + { + uri: `tinyengine://docs/page-schema/${section}`, + name: `page-schema-${section}.md`, + title: `页面 Schema 协议 - ${heading}`, + mimeType: 'text/markdown', + text + } + ] + } + } + } +] diff --git a/packages/canvas/DesignCanvas/src/mcp/resources/tinyEngineAIInstruct.md b/packages/canvas/DesignCanvas/src/mcp/resources/tinyEngineAIInstruct.md new file mode 100644 index 0000000000..e8b8fdd7a6 --- /dev/null +++ b/packages/canvas/DesignCanvas/src/mcp/resources/tinyEngineAIInstruct.md @@ -0,0 +1,374 @@ +# TinyEngine 操作指南 + +## 概述 +本文档包含 TinyEngine 低代码平台的标准操作流程和最佳实践。所有操作都必须严格遵循这些指南。 + +## 实战案例 + +### 案例1:创建用户管理页面(复杂任务) + +**用户需求**:"创建一个完整的用户管理页面" + +**使用 Sequential Thinking 规划**: +``` +Thought 1/5: 分析需求,用户管理页面需要:列表展示、搜索、新增、编辑、删除 +Thought 2/6: 设计页面结构:顶部搜索栏、中间表格、操作按钮 +Thought 3/6: 规划状态管理:用户列表、搜索条件、编辑状态 +Thought 4/6: 设计交互流程:搜索→列表更新、点击编辑→弹窗 +Thought 5/7: 发现需要添加分页功能(调整总数) +Thought 6/7: 确定实施顺序:1.基础布局 2.状态 3.方法 4.样式 +Thought 7/7: 生成执行计划并验证完整性 +``` + +**执行步骤**: +1. 使用资源学习页面结构设计 +2. 添加页面状态(用户列表、分页信息) +3. 添加搜索和CRUD方法 +4. 创建组件结构(搜索框、表格、按钮) +5. 配置组件属性和事件绑定 +6. 添加样式美化 + +### 案例2:样式优化(简单任务,无需Sequential Thinking) + +**用户需求**:"优化按钮样式" + +**直接执行**: +1. 获取当前组件信息 +2. 查看可用的Tailwind类 +3. 设置className属性 + +--- + +## 通用操作原则 + +### 资源学习流程 +1. **识别任务类型** - 理解用户需求 +2. **评估复杂度** - 判断是否需要分步思考 +3. **查找协议文档** - 理解数据结构 +4. **查找示例文档** - 学习操作方法 +5. **获取当前状态** - 了解现有配置 +6. **制定操作方案** - 使用sequential_thinking规划复杂任务 +7. **执行操作** - 按照示例步骤执行 +8. **验证结果** - 确认操作成功 + +### 思维工具使用 + +#### Sequential Thinking 工具 +**用途**:处理复杂问题的分步思考和方案制定 + +**适用场景**: +- 设计完整的页面结构(需要规划组件布局、状态管理、事件处理) +- 重构现有页面(需要分析现状、制定方案、逐步实施) +- 复杂的组件交互设计(需要考虑多种状态和边界情况) +- 不确定最佳方案时的探索性任务 +- 需要试错和调整的配置任务 +- 多步骤操作的规划和执行 + +**使用时机**: +1. 用户需求涉及多个相互关联的操作 +2. 任务的最终目标明确但路径不清晰 +3. 需要权衡多种实现方案 +4. 操作可能需要回溯或修正 +5. 任务复杂度超过3个步骤 + +**使用示例**: +- "创建一个完整的用户管理页面" → 需要思考布局、功能、交互 +- "优化页面性能" → 需要分析问题、制定方案、逐步优化 +- "实现复杂的表单验证" → 需要设计验证规则、错误处理、用户反馈 + +**与资源学习的配合**: +1. 先用sequential_thinking制定方案 +2. 在思考过程中识别需要的资源 +3. 查询并学习相关资源 +4. 根据学习结果调整方案 +5. 执行最终方案 + +### 工具调用原则 +- 复杂任务先用sequential_thinking规划 +- 直接执行工具,不返回JSON描述 +- 操作前先获取上下文 +- 出错时查看错误信息并调整 + +--- + +## 常见操作指南 + +### 0. 复杂任务规划(使用 Sequential Thinking) + +**适用场景**:任务涉及多个步骤或需要探索性设计 + +**标准流程**: +1. 识别任务的复杂度和不确定性 +2. 使用 `sequential_thinking` 进行分步思考 +3. 在思考中识别需要的资源和工具 +4. 根据思考结果制定执行计划 +5. 按计划执行,必要时回到思考调整 + +**示例场景**: +- "创建一个完整的用户管理页面,包含列表、搜索、新增、编辑功能" +- "将现有页面改造成响应式设计" +- "实现一个复杂的多步骤表单向导" + +**关键点**: +- 允许动态调整思考步骤数 +- 可以修正之前的判断 +- 支持探索多种方案 +- 生成假设并验证 + +### 1. 变量绑定操作 + +**适用场景**:需要将组件属性绑定到页面状态 + +**标准流程**: +1. 使用 `get_current_selected_node` 获取选中组件信息 +2. 使用 `get_page_schema` 查看现有state结构 +3. 使用 `edit_page_schema` 添加state变量 +4. 使用 `change_node_props` 绑定组件属性到state + +**关键点**: +- state变量必须先创建后绑定 +- 绑定使用JSExpression格式:`this.state.variableName` +- 支持计算属性和访问器 + +### 2. 样式修改操作 + +**适用场景**:需要修改组件的视觉样式 + +**标准流程**: +1. 获取当前组件信息 +2. 阅读CSS协议章节了解结构 +3. 阅读CSS示例章节学习方法 +4. 根据场景选择合适的样式方式: + - 通用样式 → edit_page_schema 添加CSS类 + change_node_props 设置className + - 快速样式 → 直接使用 Tailwind CSS 类 + - 动态样式 → 可使用 style 属性(特殊情况) + +**关键点**: +- 优先使用 className + CSS 类(最佳实践) +- Tailwind CSS 适合快速开发 +- style 属性仅用于必要场景(如动态计算值) +- merge策略在末尾追加CSS + +**样式方式选择**: +- **className + CSS**:通用、可复用、易维护(推荐) +- **Tailwind CSS**:快速、响应式、原子化 +- **style属性**:动态值、一次性样式(谨慎使用) + +### 3. 事件处理操作 + +**适用场景**:为组件添加交互功能 + +**标准流程**: +1. 阅读methods示例了解函数格式 +2. 使用 `edit_page_schema` 添加方法到methods +3. 使用 `change_node_props` 绑定事件属性 + +**关键点**: +- 方法必须是JSFunction格式 +- 事件绑定使用JSExpression +- 方法名称要语义化 + +### 4. 页面创建操作 + +**适用场景**:创建新页面 + +**标准流程**: +1. 使用 `get_page_list` 查看现有页面 +2. 使用 `add_page` 创建页面 +3. 使用 `edit_page_in_canvas` 切换到新页面 +4. 根据需要配置页面schema + +**关键点**: +- 页面名称首字母大写 +- 路由只能包含英文、数字、下划线、连字符 +- 可指定父级页面ID + +### 5. 组件添加操作 + +**适用场景**:向页面添加新组件 + +**标准流程**: +1. 使用 `get_component_list` 查看可用组件 +2. 使用 `get_page_schema` 了解页面结构 +3. 使用 `add_node` 添加组件 +4. 使用 `change_node_props` 配置组件属性 + +**关键点**: +- 需要指定父节点ID或添加到根节点 +- 可以指定相对位置(before/after) +- 组件props根据组件类型而定 + +### 6. 国际化配置 + +**适用场景**:添加多语言支持 + +**标准流程**: +1. 使用 `get_i18n` 查看现有配置 +2. 使用 `add_i18n` 添加新的翻译项 +3. 在组件中使用i18n表达式引用 + +**关键点**: +- key格式通常为 `lowcode.xxxxx` +- 必须同时提供zh_CN和en_US +- 使用JSExpression引用:`this.i18n('key')` + +### 7. 工具函数管理 + +**适用场景**:添加可复用的函数或NPM依赖 + +**标准流程**: +1. 使用 `get_utils_tool` 查看现有工具 +2. 使用 `add_or_edit_utils_tool` 添加新工具 +3. 在页面methods中调用工具函数 + +**关键点**: +- 支持function和npm两种类型 +- NPM类型需要指定package和exportName +- function类型需要提供完整函数代码 + +--- + +## 错误处理指南 + +### 常见错误及解决方案 + +**state不是对象** +- 错误:将state写成数组 +- 解决:确保state是键值对对象 + +**方法格式错误** +- 错误:methods的值不是JSFunction +- 解决:使用 `{ type: "JSFunction", value: "..." }` + +**样式修改选择** +- 问题:不知道该用哪种样式方式 +- 解决: + - 通用可复用 → className + CSS + - 快速原型 → Tailwind CSS + - 动态计算 → style 属性 + - 默认选择 → className + CSS + +**merge策略误解** +- 错误:期望深层合并 +- 解决:理解merge只做顶层键合并 + +**CSS追加遗漏换行** +- 错误:新CSS与旧CSS黏连 +- 解决:在CSS字符串开头添加 `\n` + +--- + +## 决策树 + +### 任务复杂度评估 + +``` +收到用户请求 +├─ 评估任务复杂度 +│ ├─ 简单任务(1-2步)? +│ │ └─ 直接执行操作 +│ │ +│ ├─ 中等复杂(3-5步)? +│ │ ├─ 路径清晰? → 按流程执行 +│ │ └─ 路径不明? → 使用 sequential_thinking +│ │ +│ └─ 高度复杂(5步以上)? +│ └─ 必须使用 sequential_thinking 规划 +│ +└─ 使用 sequential_thinking 的判断 + ├─ 需要设计完整方案? → 是 + ├─ 涉及多个相关操作? → 是 + ├─ 可能需要试错调整? → 是 + ├─ 初始范围不明确? → 是 + └─ 需要权衡多种方案? → 是 +``` + +### 需要修改页面? + +``` +判断修改类型 +├─ 结构修改? +│ ├─ 添加组件 → 使用 add_node +│ ├─ 删除组件 → 使用 del_node +│ └─ 修改属性 → 使用 change_node_props +│ +├─ 样式修改? +│ ├─ 有Tailwind类? → 直接设置className +│ └─ 需要自定义? → edit_page_schema添加CSS + 设置className +│ +├─ 状态修改? +│ ├─ 添加变量 → edit_page_schema的state.add +│ ├─ 更新变量 → edit_page_schema的state.update +│ └─ 删除变量 → edit_page_schema的state.remove +│ +├─ 逻辑修改? +│ ├─ 添加方法 → edit_page_schema的methods +│ └─ 添加生命周期 → edit_page_schema的lifeCycles +│ +└─ 综合设计? + └─ 使用 sequential_thinking 制定完整方案 +``` + +### 资源查询策略 + +``` +需要学习操作? +├─ 知道具体功能? +│ ├─ 是 → search_resources精确搜索 +│ └─ 否 → discover_resources浏览探索 +│ +├─ 找到相关资源? +│ ├─ 协议文档 → 理解结构 +│ ├─ 示例文档 → 学习方法 +│ └─ 操作指南 → 掌握流程 +│ +└─ 准备执行? + ├─ 已读协议? → 继续 + ├─ 已读示例? → 继续 + └─ 已获取状态? → 执行操作 +``` + +--- + +## 最佳实践 + +### DO - 推荐做法 +- ✅ 先查看再修改 +- ✅ 分步骤执行复杂操作 +- ✅ 使用语义化命名 +- ✅ 添加必要的注释 +- ✅ 验证操作结果 +- ✅ 保持样式模块化 +- ✅ 使用Tailwind优先 + +### DON'T - 避免错误 +- ❌ 凭记忆执行操作 +- ❌ 跳过资源学习 +- ❌ 过度使用style属性(仅特殊场景使用) +- ❌ 使用replace策略而不备份 +- ❌ 忽略错误信息 +- ❌ 创建过于通用的CSS选择器 +- ❌ 在不了解结构时修改 + +--- + +## 资源引用 + +### 必读资源 +- 页面Schema协议:`tinyengine://docs/page-schema` +- 操作示例集合:`tinyengine://docs/edit-page-schema-examples` + +### 分节资源 +- State操作:`tinyengine://docs/edit-page-schema-examples/state` +- CSS操作:`tinyengine://docs/edit-page-schema-examples/css` +- Methods操作:`tinyengine://docs/edit-page-schema-examples/methods` +- 最佳实践:`tinyengine://docs/edit-page-schema-examples/do-dont` + +### 查询策略 +1. 使用 `discover_resources` 探索可用资源 +2. 使用 `search_resources` 精确查找 +3. 使用 `read_resources` 深入学习 + +--- + +*记住:所有操作都必须基于资源学习,不得凭假设执行。* diff --git a/packages/canvas/DesignCanvas/src/mcp/resources/tinyEngineAIInstruct.ts b/packages/canvas/DesignCanvas/src/mcp/resources/tinyEngineAIInstruct.ts new file mode 100644 index 0000000000..9755f3a246 --- /dev/null +++ b/packages/canvas/DesignCanvas/src/mcp/resources/tinyEngineAIInstruct.ts @@ -0,0 +1,80 @@ +import aiInstructMd from './tinyEngineAIInstruct.md?raw' +import { pickSectionByHeading } from './utils' + +// 分节枚举与标题映射(左侧为模板入参 section,右侧为文档中的中文二级标题) +const AI_INSTRUCT_SECTION_TITLES: Record = { + overview: '概述', + principles: '通用操作原则', + guides: '常见操作指南', + 'error-handling': '错误处理指南', + 'decision-tree': '决策树', + 'best-practices': '最佳实践', + references: '资源引用' +} + +// 根资源:TinyEngine 操作指南(整份文档)——仅保留简短别名 +export const aiInstructResources = [ + { + uri: 'tinyengine://docs/ai-instruct', + name: 'ai-instruct', + title: 'TinyEngine 操作指南', + description: 'TinyEngine 标准操作流程与最佳实践总览', + mimeType: 'text/markdown', + annotations: { audience: ['assistant', 'user'], priority: 0.9 }, + readCallback: async () => ({ + contents: [ + { + uri: 'tinyengine://docs/ai-instruct', + name: 'tinyEngineAIInstruct.md', + title: 'TinyEngine 操作指南', + mimeType: 'text/markdown', + text: aiInstructMd + } + ] + }) + } +] + +// 模板资源:TinyEngine 操作指南(分节读取)——仅保留简短别名 +export const aiInstructResourceTemplates = [ + { + uriTemplate: 'tinyengine://docs/ai-instruct/{section}', + name: 'TinyEngine 操作指南(分节)', + title: 'TinyEngine 操作指南(分节)', + description: '按章节读取 TinyEngine 操作指南内容', + mimeType: 'text/markdown', + annotations: { audience: ['assistant', 'user'], priority: 0.9 }, + variables: [ + { + name: 'section', + required: true, + type: 'enum', + enumValues: Object.keys(AI_INSTRUCT_SECTION_TITLES).map((key) => ({ + value: key, + title: AI_INSTRUCT_SECTION_TITLES[key] + })) + } + ], + readTemplateCallback: async (_uri: URL, variables: Record) => { + const section = (variables?.section || '').toString() + const heading = AI_INSTRUCT_SECTION_TITLES[section] + if (!heading) { + throw new Error('Invalid template parameter: section') + } + const text = pickSectionByHeading(aiInstructMd, heading) + return { + contents: [ + { + uri: `tinyengine://docs/ai-instruct/${section}`, + name: `ai-instruct-${section}.md`, + title: `TinyEngine 操作指南 - ${heading}`, + mimeType: 'text/markdown', + text + } + ] + } + } + } +] + +export default { aiInstructResources, aiInstructResourceTemplates } diff --git a/packages/canvas/DesignCanvas/src/mcp/resources/utils.ts b/packages/canvas/DesignCanvas/src/mcp/resources/utils.ts new file mode 100644 index 0000000000..e8edb37f82 --- /dev/null +++ b/packages/canvas/DesignCanvas/src/mcp/resources/utils.ts @@ -0,0 +1,46 @@ +// 通用工具:根据二级标题(以 `## ` 开头)裁切文档的指定章节文本 +// 约束: +// - 标题大小写不敏感(转换为小写后匹配) +// - 仅在两个相邻的二级标题之间切片 +export function pickSectionByHeading(markdown: string, title: string): string { + if (typeof markdown !== 'string' || !markdown) { + return '' + } + + if (typeof title !== 'string' || !title.trim()) { + return markdown + } + + const normalize = (s: string) => + s + .trim() + .replace(/\u2019/g, "'") + .replace(/\s+/g, ' ') + .toLowerCase() + const target = normalize(title) + const lines = markdown.split(/\r?\n/) + const isH2 = (line: string) => line.trim().startsWith('## ') + const headingText = (line: string) => line.trim().replace(/^##\s+/, '') + let startIdx = -1 + for (let i = 0; i < lines.length; i += 1) { + if (isH2(lines[i]) && normalize(headingText(lines[i])) === target) { + startIdx = i + break + } + } + + if (startIdx === -1) { + return '' + } + + let endIdx = lines.length + + for (let i = startIdx + 1; i < lines.length; i += 1) { + if (isH2(lines[i])) { + endIdx = i + break + } + } + + return lines.slice(startIdx, endIdx).join('\n') +} diff --git a/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/editCSS.ts b/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/editCSS.ts new file mode 100644 index 0000000000..ff3e60b771 --- /dev/null +++ b/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/editCSS.ts @@ -0,0 +1,54 @@ +import { useCanvas } from '@opentiny/tiny-engine-meta-register' +import { ERROR_CODES, nextActionGetSchema } from './utils' + +const computeAppendedCss = (oldCss: string, incoming: string) => { + const base = typeof oldCss === 'string' ? oldCss : '' + const add = typeof incoming === 'string' ? incoming : '' + + if (!add) { + return base + } + + const sep = base && !base.endsWith('\n') ? '\n' : '' + return `${base}${sep}${add}` +} + +export const editCSS = (strategy: 'replace' | 'merge', css: string | undefined) => { + const { getSchema, updateSchema } = useCanvas() + const currentSchema = (getSchema() as Record) || {} + + if (typeof css !== 'string') { + return { + error: { + errorCode: ERROR_CODES.INVALID_PAYLOAD, + reason: 'css must be a string', + userMessage: 'css must be a string', + next_action: nextActionGetSchema() + } + } + } + + if (strategy === 'replace') { + updateSchema({ css }) + + return { + message: 'CSS replaced', + affectedKeys: { + updated: ['css'] + } + } + } + + const nextCss = computeAppendedCss(currentSchema?.css, css) + + if (nextCss === (currentSchema?.css || '')) { + return { message: 'No change', affectedKeys: {} } + } + + updateSchema({ css: nextCss }) + + return { + message: 'CSS appended', + affectedKeys: { updated: ['css'] } + } +} diff --git a/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/editLifeCycleOrMethod.ts b/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/editLifeCycleOrMethod.ts new file mode 100644 index 0000000000..06859d98b4 --- /dev/null +++ b/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/editLifeCycleOrMethod.ts @@ -0,0 +1,132 @@ +import { useCanvas } from '@opentiny/tiny-engine-meta-register' +import { isValidJSFuncUnit, isNoChange } from './utils' + +export const editLifeCycleOrMethod = ( + strategy: 'replace' | 'merge', + payload: + | { all?: Record; add?: Record; update?: Record; remove?: string[] } + | undefined, + sectionKey: 'lifeCycles' | 'methods' +) => { + const warnings: string[] = [] + const affected = { added: [] as string[], updated: [] as string[], removed: [] as string[] } + const { getSchema, updateSchema } = useCanvas() + + if (strategy === 'replace') { + if (payload?.all && typeof payload.all === 'object') { + const allMapEntries = Object.entries(payload.all || {}) + const validMap = Object.fromEntries(allMapEntries.filter(([_k, v]) => isValidJSFuncUnit(v))) + const invalidKeys = allMapEntries.filter(([_k, v]) => !isValidJSFuncUnit(v)).map(([k]) => k) + const replaceWarnings: string[] = [] + + updateSchema({ [sectionKey]: validMap }) + + if (invalidKeys.length) { + replaceWarnings.push(`ignored invalid ${sectionKey} function units: ${invalidKeys.join(', ')}`) + } + + return { + message: `${sectionKey} replaced`, + affectedKeys: { ...affected, updated: Object.keys(validMap) }, + warnings: replaceWarnings + } + } + + const newMap: Record = {} + const addMap = payload?.add || {} + const updateMap = payload?.update || {} + const invalidReplaceKeys: string[] = [] + + Object.entries(addMap).forEach(([k, v]) => { + if (isValidJSFuncUnit(v)) { + newMap[k] = v + } else { + invalidReplaceKeys.push(k) + } + }) + + Object.entries(updateMap).forEach(([k, v]) => { + if (isValidJSFuncUnit(v)) { + newMap[k] = v + } else { + invalidReplaceKeys.push(k) + } + }) + + if (payload?.remove?.length) { + warnings.push(`remove ignored in replace without all: ${payload.remove.join(', ')}`) + } + if (invalidReplaceKeys.length) { + warnings.push(`ignored invalid ${sectionKey} function units: ${invalidReplaceKeys.join(', ')}`) + } + + updateSchema({ [sectionKey]: newMap }) + + return { + message: `${sectionKey} rebuilt by add+update`, + affectedKeys: { added: Object.keys(addMap), updated: Object.keys(updateMap), removed: [] }, + warnings + } + } + + const currentSchema = (getSchema() as Record) || {} + const currentMap = currentSchema[sectionKey] || {} + + // merge + const nextMap: Record = { ...currentMap } + if (Array.isArray(payload?.remove)) { + payload.remove.forEach((k) => { + if (k in nextMap) { + delete nextMap[k] + affected.removed.push(k) + } + }) + } + + const ignoredAdd: string[] = [] + Object.entries(payload?.add || {}).forEach(([k, v]) => { + if (k in nextMap) { + ignoredAdd.push(k) + return + } + if (isValidJSFuncUnit(v)) { + nextMap[k] = v + affected.added.push(k) + } + }) + const ignoredUpdate: string[] = [] + Object.entries(payload?.update || {}).forEach(([k, v]) => { + if (!(k in nextMap)) { + ignoredUpdate.push(k) + return + } + if (isValidJSFuncUnit(v)) { + nextMap[k] = v + affected.updated.push(k) + } + }) + + if (ignoredAdd.length) { + warnings.push(`ignored add (already exists): ${ignoredAdd.join(', ')}`) + } + + if (ignoredUpdate.length) { + warnings.push(`ignored update (not exists): ${ignoredUpdate.join(', ')}`) + } + + if (isNoChange(affected)) { + return { + message: 'No change', + affectedKeys: affected, + warnings + } + } + + updateSchema({ [sectionKey]: nextMap }) + + return { + message: `${sectionKey} merged`, + affectedKeys: affected, + warnings + } +} diff --git a/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/editSchema.ts b/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/editSchema.ts new file mode 100644 index 0000000000..df91d79a90 --- /dev/null +++ b/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/editSchema.ts @@ -0,0 +1,71 @@ +import { useCanvas } from '@opentiny/tiny-engine-meta-register' +import { ERROR_CODES, nextActionGetSchema } from './utils' + +const ALLOWED_SCHEMA_KEYS = new Set([ + 'css', + 'lifeCycles', + 'methods', + 'state', + 'props', + 'fileName', + 'componentName', + 'dataSource', + 'children' +]) + +export const editSchema = (strategy: 'replace' | 'merge', schema: Record | undefined) => { + const provided = schema + + if (!provided || typeof provided !== 'object') { + return { + error: { + errorCode: ERROR_CODES.INVALID_PAYLOAD, + reason: 'schema object is required for schema editing', + userMessage: 'schema object is required for schema editing', + next_action: nextActionGetSchema() + } + } + } + + const { getSchema, updateSchema, importSchema } = useCanvas() + const currentSchema = getSchema() + + // 替换整个页面 schema + if (strategy === 'replace') { + importSchema(Object.assign({}, currentSchema, provided)) + + return { + message: 'schema replaced' + } + } + + const partial: Record = {} + Object.keys(provided).forEach((k) => { + if (ALLOWED_SCHEMA_KEYS.has(k)) { + partial[k] = (provided as any)[k] + } + }) + + const keys = Object.keys(partial) + + if (!keys.length) { + return { message: 'No change' } + } + + const prevKeys = new Set(Object.keys(currentSchema || {})) + const affected = { added: [] as string[], updated: [] as string[], removed: [] as string[] } + keys.forEach((k) => { + if (prevKeys.has(k)) { + affected.updated.push(k) + } else { + affected.added.push(k) + } + }) + + updateSchema(partial) + + return { + message: 'schema merged (top-level only)', + affectedKeys: affected + } +} diff --git a/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/editState.ts b/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/editState.ts new file mode 100644 index 0000000000..9a76817a0e --- /dev/null +++ b/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/editState.ts @@ -0,0 +1,81 @@ +import { useCanvas } from '@opentiny/tiny-engine-meta-register' +import { isNoChange } from './utils' + +export const editState = ( + strategy: 'replace' | 'merge', + payload: + | { all?: Record; add?: Record; update?: Record; remove?: string[] } + | undefined +) => { + const warnings: string[] = [] + const affected = { added: [] as string[], updated: [] as string[], removed: [] as string[] } + const { getSchema, updateSchema } = useCanvas() + + if (strategy === 'replace') { + if (payload?.all && typeof payload.all === 'object') { + updateSchema({ state: payload.all }) + + return { + message: 'state replaced', + affectedKeys: { ...affected, updated: Object.keys(payload.all) } + } + } + + const newState: Record = {} + Object.assign(newState, payload?.add || {}, payload?.update || {}) + + if (payload?.remove?.length) { + warnings.push(`remove ignored in replace without all: ${payload.remove.join(', ')}`) + } + + updateSchema({ state: newState }) + + return { + message: 'state rebuilt by add+update', + affectedKeys: { + added: Object.keys(payload?.add || {}), + updated: Object.keys(payload?.update || {}), + removed: [] + }, + warnings + } + } + + const currentSchema = (getSchema() as Record) || {} + const currentState = currentSchema.state || {} + + // merge top-level only + const nextState: Record = { ...currentState } + if (Array.isArray(payload?.remove)) { + payload.remove.forEach((k) => { + if (k in nextState) { + delete nextState[k] + affected.removed.push(k) + } + }) + } + Object.entries(payload?.add || {}).forEach(([k, v]) => { + if (!(k in nextState)) { + nextState[k] = v + affected.added.push(k) + } + }) + Object.entries(payload?.update || {}).forEach(([k, v]) => { + if (k in nextState) { + nextState[k] = v + affected.updated.push(k) + } + }) + + if (isNoChange(affected)) { + return { message: 'No change', affectedKeys: affected, warnings } + } + + updateSchema({ state: nextState }) + + return { + message: 'state merged', + affectedKeys: affected, + warnings + } +} diff --git a/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/index.ts b/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/index.ts new file mode 100644 index 0000000000..94fff44961 --- /dev/null +++ b/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/index.ts @@ -0,0 +1,351 @@ +import { z } from 'zod' +import { editSchema } from './editSchema' +import { editLifeCycleOrMethod } from './editLifeCycleOrMethod' +import { editState } from './editState' +import { editCSS } from './editCSS' +import { ERROR_CODES, nextActionGetSchema } from './utils' + +// 定义函数单元的 zod 结构,用于 lifecycle 与 methods(统一 { type: 'JSFunction', value } 形态) +const funcTypeSchema = z + .object({ + type: z.literal('JSFunction'), + value: z.string() + }) + .describe( + '函数单元。必须为 { type: "JSFunction", value: string } 格式。示例: { type: "JSFunction", value: "function onMounted(){ console.log(\'mounted\') }" }。用于 lifeCycles/methods 的值。' + ) + +// lifeCycles(生命周期):支持 all(整量)、add/update/remove(部分) +const lifeCyclesPayloadSchema = z + .object({ + all: z + .record(z.string(), funcTypeSchema) + .optional() + .describe( + '完整的生命周期映射(replace 模式优先使用)。常见钩子:setup, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted。' + ), + add: z + .record(z.string(), funcTypeSchema) + .optional() + .describe('新增生命周期(仅当键不存在时生效)。示例请参考资源文档 lifeCycles 分节。'), + update: z + .record(z.string(), funcTypeSchema) + .optional() + .describe('更新生命周期(仅当键已存在时生效)。会完全替换原有的函数定义。'), + remove: z + .array(z.string()) + .optional() + .describe('要删除的生命周期键名列表。传入钩子名数组,如 ["onBeforeMount", "onUpdated"]。') + }) + .optional() + .describe( + '当 section = "lifeCycles" 时传入的参数。merge 模式:通过 add/update/remove 按键操作;replace 模式:优先使用 all 字段提供完整映射,或用 add+update 组合重建(此时 remove 将被忽略)。完整示例请参考资源文档 lifeCycles 分节。' + ) + +// methods(方法):与 lifeCycles 相同 +const methodsPayloadSchema = z + .object({ + all: z + .record(z.string(), funcTypeSchema) + .optional() + .describe( + '完整的方法映射(replace 模式优先使用)。方法名建议使用驼峰命名,如 handleClick, fetchData, validateForm。' + ), + add: z + .record(z.string(), funcTypeSchema) + .optional() + .describe('新增方法(仅当键不存在时生效)。示例请参考资源文档 methods 分节。'), + update: z + .record(z.string(), funcTypeSchema) + .optional() + .describe('更新方法(仅当键已存在时生效)。会完全替换原有的函数定义。'), + remove: z + .array(z.string()) + .optional() + .describe('要删除的方法名列表。传入方法名数组,如 ["legacyMethod", "deprecatedHandler"]。') + }) + .optional() + .describe( + '当 section = "methods" 时传入的参数。merge 模式:通过 add/update/remove 按键操作;replace 模式:优先使用 all 字段提供完整映射,或用 add+update 组合重建(此时 remove 将被忽略)。完整示例请参考资源文档 methods 分节。' + ) + +// state(页面状态变量):支持 all(整量)、add/update/remove(部分)。此处合并为顶层键的浅合并 +const statePayloadSchema = z + .object({ + all: z + .record(z.string(), z.any()) + .optional() + .describe('完整的 state 对象(replace 模式优先使用)。值可以是字面量、JSExpression、computed 或 accessor。'), + add: z + .record(z.string(), z.any()) + .optional() + .describe( + '新增顶层键(仅当键不存在时生效)。值支持字面量(如 "hello", 123, [])、JSExpression({ type: "JSExpression", value: "..." })、computed、accessor。示例请参考资源文档 state 分节。' + ), + update: z + .record(z.string(), z.any()) + .optional() + .describe('更新顶层键(仅当键已存在时生效)。不会深层递归合并,会完全替换顶层键的整个值。'), + remove: z + .array(z.string()) + .optional() + .describe('要从 state 中删除的顶层键列表。传入键名数组,如 ["deprecatedKey", "oldVariable"]。') + }) + .optional() + .describe( + '当 section = "state" 时传入的参数。merge 模式:仅对顶层键执行 add/update/remove(浅合并,不递归深层结构);值支持字面量或 JSResource/JSExpression/computed/accessor。replace 模式:优先使用 all 字段提供完整对象,或用 add+update 组合重建(此时 remove 将被忽略)。完整示例和值类型说明请参考资源文档 state 分节。' + ) + +// 顶层输入结构:判别式入口(section)+ 策略(strategy)+ section 对应的参数 +const inputSchema = z.object({ + section: z.enum(['schema', 'css', 'lifeCycles', 'methods', 'state']).describe(`要编辑的页面部分。快速选择: + • 'state' - 页面状态变量(推荐从示例文档 state 分节入手) + • 'css' - 全局样式(推荐优先使用 Tailwind utility 类) + • 'lifeCycles' - Vue 生命周期钩子(如 onMounted, setup) + • 'methods' - 页面方法(如事件处理器) + • 'schema' - 顶层配置(谨慎使用,建议先读取协议文档 structure 分节) +仅作用于画布中当前打开的页面。详细说明请读取对应的协议文档分节。`), + strategy: z + .enum(['replace', 'merge']) + .optional() + .describe( + `编辑策略,默认 merge: + • 'merge'(推荐)- 部分更新,通过 add/update/remove 精确控制变更,保留现有内容 + • 'replace'(谨慎)- 整体替换,会覆盖现有内容,建议先读取协议文档了解影响 +各 section 的 merge 行为: + - css: 末尾追加 + - lifeCycles/methods: 按键 add/update/remove + - state: 仅顶层键 add/update/remove(不递归) + - schema: 仅允许特定键的浅合并` + ), + // 提升为一级字段:与各分节处理器的期望保持一致(回调中再做基于 section 的严格校验) + schema: z + .record(z.string(), z.any()) + .optional() + .describe( + '页面 schema 的顶层部分(浅合并)。仅在 section = "schema" 时使用;仅允许更新特定键集合(css, lifeCycles, methods, state, props, fileName, componentName, dataSource, children)。建议先调用 get_page_schema 查看结构,详细说明请参考协议文档 structure 分节。' + ), + css: z + .string() + .optional() + .describe( + '整页 CSS 文本。replace = 整体覆盖;merge = 在末尾追加(必要时自动换行)。推荐优先使用 Tailwind utility 类直接绑定到组件的 className,而非在此添加自定义 CSS。仅在 section = "css" 时使用。完整示例请参考资源文档 css 分节。' + ), + lifeCycles: lifeCyclesPayloadSchema, + methods: methodsPayloadSchema, + state: statePayloadSchema +}) + +const ok = (res: Record) => ({ + content: [ + { + type: 'text', + text: JSON.stringify(res) + } + ] +}) + +const err = (payload: { + errorCode: string + reason: string + userMessage: string + next_action?: Array> +}) => ({ + content: [ + { + isError: true, + type: 'text', + text: JSON.stringify(payload) + } + ] +}) + +const validateSection = (section: string) => { + if (!section || !['schema', 'css', 'lifeCycles', 'methods', 'state'].includes(section)) { + return { + isValid: false, + error: { + errorCode: ERROR_CODES.INVALID_ARGUMENT, + reason: 'Unknown section', + userMessage: 'Unknown section', + next_action: nextActionGetSchema() + } + } + } + + return { + isValid: true + } +} + +const validateStrategy = (strategy: string) => { + if (!['replace', 'merge'].includes(strategy)) { + return { + isValid: false, + error: { + errorCode: ERROR_CODES.INVALID_ARGUMENT, + reason: 'Unknown strategy', + userMessage: 'Unknown strategy', + next_action: nextActionGetSchema() + } + } + } + + return { + isValid: true + } +} + +const legalSchemaKey = ['css', 'schema', 'lifeCycles', 'methods', 'state'] as const + +const validateRequiredField = (args: z.infer) => { + // 校验:根据 section 仅允许对应字段存在 + + const section = args.section + const providedSections = legalSchemaKey.filter((key) => typeof args[key] !== 'undefined') + // section 对应的 必填字段一一对应 + const requiredField = section + + if (!providedSections.length || !providedSections.includes(requiredField)) { + return { + isValid: false, + error: { + errorCode: ERROR_CODES.INVALID_PAYLOAD, + reason: `Missing required field for section "${section}": expected "${requiredField}"`, + userMessage: `Missing required field: please provide "${requiredField}" when section = "${section}"`, + next_action: nextActionGetSchema() + } + } + } + + if (providedSections.length > 1 || (providedSections.length === 1 && providedSections[0] !== requiredField)) { + const extras = providedSections.filter((s) => s !== requiredField) + return { + isValid: false, + error: { + errorCode: ERROR_CODES.INVALID_ARGUMENT, + reason: `Invalid combination for section "${section}", unexpected fields: ${extras.join(', ')}`, + userMessage: `Only field "${requiredField}" is allowed when section = "${section}". Unexpected: ${extras.join( + ', ' + )}`, + next_action: nextActionGetSchema() + } + } + } + + return { + isValid: true + } +} + +export const EditPageSchema = { + name: 'edit_page_schema', + title: '编辑页面schema', + description: `编辑 TinyEngine 低代码画布中当前页面的 schema。 + +【支持的部分】 +支持五个部分:schema、css、lifeCycles、methods 和 state。 +使用策略 "replace" 进行整体替换,或使用 "merge" 进行部分更新(add/update/remove)。 + +【必读文档资源】 +在使用本工具前,强烈建议先通过 read_resources 工具读取以下文档以确保正确操作: + +1. 页面 Schema 协议文档(数据结构和约束): + - 完整读取:{ "uri": "tinyengine://docs/page-schema" } + - 分节读取:{ "uriTemplate": "tinyengine://docs/page-schema/{section}", "variables": { "section": "对应分节名" } } + +2. 编辑示例文档(实战案例和最佳实践): + - 完整读取:{ "uri": "tinyengine://docs/edit-page-schema-examples" } + - 分节读取:{ "uriTemplate": "tinyengine://docs/edit-page-schema-examples/{section}", "variables": { "section": "对应分节名" } } + +【按 section 读取对应文档分节】 +根据你要操作的 section,建议读取以下分节(使用 read_resources 工具): + +• section='state': + 协议文档分节: { "section": "state" } + 示例文档分节: { "section": "state" } + +• section='css': + 协议文档分节: { "section": "css" } + 示例文档分节: { "section": "css" } + +• section='lifeCycles': + 协议文档分节: { "section": "lifeCycles" } + 示例文档分节: { "section": "lifeCycles" } + +• section='methods': + 协议文档分节: { "section": "methods" } + 示例文档分节: { "section": "methods" } + +• section='schema': + 协议文档分节: { "section": "structure" } + 示例文档分节: { "section": "schema" } + +【遇到错误或疑问时】 +• 建议先读取完整协议文档了解整体结构 +• 根据操作的 section 读取对应分节即可 + +【使用建议】 +• 首次使用:建议先读取完整协议文档了解整体结构,或读取对应 section 的示例快速上手 +• 日常使用:根据操作的 section 读取对应分节即可(性能更优) +• 不确定当前结构:先调用 "get_page_schema" 工具查看现有结构 + +【关键提示】 +• lifeCycles 和 methods 需要 { type: "JSFunction", value: string } 形式的函数单元 +• state 接受普通值以及 JSResource/JSExpression/computed/accessor(getter/setter)结构 +• css 使用 "merge" 时会将给定的 CSS 字符串追加到末尾 +• 对于细粒度的节点树变更(children 结构),建议使用节点工具如 "add_node" 或 "change_node_props" + +【重要警告】 +此工具始终作用于画布中当前打开的页面。 +"replace" 策略会覆盖现有内容,使用前请务必先读取协议文档了解影响范围。`, + inputSchema: inputSchema.shape, + callback: async (args: z.infer) => { + try { + const { section, strategy = 'merge' } = args || {} + + const sectionValidateResult = validateSection(section) + if (!sectionValidateResult.isValid) { + return err(sectionValidateResult.error!) + } + + const strategyValidateResult = validateStrategy(strategy) + if (!strategyValidateResult.isValid) { + return err(strategyValidateResult.error!) + } + + const requiredFieldValidateResult = validateRequiredField(args) + if (!requiredFieldValidateResult.isValid) { + return err(requiredFieldValidateResult.error!) + } + + let out: Record | undefined + // 根据不同 section 调用对应的处理器 + if (section === 'lifeCycles' || section === 'methods') { + out = editLifeCycleOrMethod(strategy, args[section], section) + } else if (section === 'css') { + out = editCSS(strategy, args.css) + } else if (section === 'schema') { + out = editSchema(strategy, args.schema) + } else if (section === 'state') { + out = editState(strategy, args.state) + } + + if (out?.error) { + return err(out.error) + } + + return ok({ + status: 'success', + message: out?.message, + data: { section, strategy, ...out } + }) + } catch (e) { + return err({ + errorCode: ERROR_CODES.UNEXPECTED_ERROR, + reason: e instanceof Error ? e.message : 'Unknown error', + userMessage: 'Unexpected error occurred while editing page schema' + }) + } + } +} diff --git a/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/utils.ts b/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/utils.ts new file mode 100644 index 0000000000..38113f8418 --- /dev/null +++ b/packages/canvas/DesignCanvas/src/mcp/tools/editPageSchema/utils.ts @@ -0,0 +1,18 @@ +export const ERROR_CODES = { + INVALID_ARGUMENT: 'INVALID_ARGUMENT', + INVALID_PAYLOAD: 'INVALID_PAYLOAD', + UNEXPECTED_ERROR: 'UNEXPECTED_ERROR' +} as const + +export const nextActionGetSchema = () => [ + { type: 'tool_call', name: 'get_page_schema', args: {}, when: 'you are unsure about current structure' } +] + +export const isValidJSFuncUnit = (unit: any) => unit?.type === 'JSFunction' && typeof unit?.value === 'string' + +export const isNoChange = (affected: { added?: string[]; updated?: string[]; removed?: string[] }) => { + const a = affected?.added?.length || 0 + const u = affected?.updated?.length || 0 + const r = affected?.removed?.length || 0 + return !a && !u && !r +} diff --git a/packages/canvas/DesignCanvas/src/mcp/tools/index.ts b/packages/canvas/DesignCanvas/src/mcp/tools/index.ts index aa6beac593..b39831a983 100644 --- a/packages/canvas/DesignCanvas/src/mcp/tools/index.ts +++ b/packages/canvas/DesignCanvas/src/mcp/tools/index.ts @@ -5,3 +5,4 @@ export { delNode } from './delNode' export { addNode } from './addNode' export { changeNodeProps } from './changeNodeProps' export { selectSpecificNode } from './selectSpecificNode' +export { EditPageSchema } from './editPageSchema' diff --git a/packages/common/composable/mcp/baseTools/discoverResources.ts b/packages/common/composable/mcp/baseTools/discoverResources.ts new file mode 100644 index 0000000000..1913ca9765 --- /dev/null +++ b/packages/common/composable/mcp/baseTools/discoverResources.ts @@ -0,0 +1,207 @@ +import type { IState, ResourceItem, ResourceTemplateItem } from '../type' +import { tryFetchRemoteLists } from './utils' +import { z } from 'zod' + +const inputSchema = z.object({ + q: z + .string() + .trim() + .min(1) + .max(200) + .optional() + .describe( + '关键词过滤(匹配 name/title/description,大小写不敏感)。用于初步缩小范围,如搜索"组件"、"API"、"文档"等。无明确主题时可不填,返回所有资源。' + ), + type: z + .enum(['resource', 'resource_template', 'all']) + .optional() + .describe( + '限定返回类型:resource(静态资源如文档、代码文件)| resource_template(参数化模板,支持动态内容生成)| all(默认,返回所有类型)。根据需求选择具体类型可减少无关结果。' + ), + audience: z + .enum(['assistant', 'user', 'both']) + .optional() + .describe( + '按目标受众过滤:assistant(AI助手专用资源,如内部文档、API参考)| user(用户相关资源,如用户手册、界面组件)| both(默认,不做受众限制)。选择 assistant 可获得更准确的技术资源。' + ), + mimeType: z + .string() + .trim() + .min(1) + .max(100) + .optional() + .describe( + '按文件类型过滤,支持前缀匹配。常用值:text/markdown(文档)、application/json(配置文件)、text/javascript(代码)、image/(图片)。用于限制特定格式的资源。' + ), + page: z + .number() + .int() + .min(1) + .optional() + .describe( + '页码(从1开始)。当资源数量较多时使用分页获取,避免单次返回过多数据影响性能。通常先用默认值1获取首页结果。' + ), + pageSize: z + .number() + .int() + .min(1) + .max(200) + .optional() + .describe( + '每页返回数量(1-200,默认50)。数量越大单次获取越多,但响应体积也越大。建议根据实际需要调整:快速浏览用10-20,详细分析用50-100。' + ), + sortBy: z + .enum(['priority', 'name']) + .optional() + .describe( + '排序方式:priority(默认,按优先级降序,重要资源在前)| name(按名称字母序升序)。priority适合获取最相关资源,name适合按名称查找。' + ), + includeAnnotations: z + .boolean() + .optional() + .describe( + '是否包含资源注解信息(默认true)。注解包含优先级、受众等元数据,对后续决策有用。false可减少响应大小,但会丢失重要的筛选信息。' + ) +}) + +const defaultPageSize = 50 + +// - 列出当前可用资源与资源模板,支持过滤、排序、分页 +export const createDiscoverResourcesTool = (state: IState) => ({ + name: 'discover_resources', + title: 'Discover TinyEngine Resources', + description: [ + '用途:**资源发现与导航** - 当您需要了解TinyEngine平台有哪些可用资源时的首选工具', + '', + '最佳使用场景:', + '• 初次接触平台,想了解有哪些文档、组件、API等资源', + '• 不确定具体关键词,需要浏览所有相关资源', + '• 需要按类型、受众、优先级等维度筛选资源', + '• 想要获取资源的概览信息(名称、描述、类型等)', + '', + '核心功能:', + '• 列出所有注册的资源和资源模板,支持多维度过滤', + '• 提供资源元数据(不读取具体内容,保持轻量级)', + '• 支持分页处理大量资源,避免信息过载', + '• 按优先级或名称排序,快速定位重要资源', + '', + '工作流建议:', + '1. 首次调用时使用默认参数获取概览', + '2. 根据需要使用 type、audience、mimeType 进一步筛选', + '3. 找到感兴趣的资源后,使用 search_resources 做精确检索', + '4. 确定目标资源后,使用 read_resources 读取具体内容', + '', + '性能特点:响应快速,仅返回元数据,适合高频调用', + '', + '返回格式:{items: Array, page: number, pageSize: number, total: number}' + ].join('\n'), + inputSchema: inputSchema.shape, + annotations: { + audience: ['assistant'] + }, + callback: async (params: z.infer, _extra: any) => { + const { + q, + type = 'all', + audience = 'both', + mimeType, + page = 1, + pageSize = defaultPageSize, + sortBy = 'priority', + includeAnnotations = true + } = params + + // 优先从远端 mcpClient 拉取资源与模板列表,失败则回退本地快照 + let resources: Omit[] = [] + let templates: Omit[] = [] + + const { ok, resources: rr, resourceTemplates: rt } = await tryFetchRemoteLists(state) + if (ok) { + resources = rr + templates = rt + } + if (!ok) { + resources = state?.resources || [] + templates = state?.resourceTemplates || [] + } + + type Entry = Omit | Omit + const entries: Entry[] = [] + if (type === 'all' || type === 'resource') { + for (const resourceItem of resources) { + const ann = resourceItem.annotations + entries.push({ + uri: resourceItem.uri, + name: resourceItem.name || '', + title: resourceItem.title, + description: resourceItem.description, + mimeType: resourceItem.mimeType, + annotations: includeAnnotations ? ann : undefined + }) + } + } + if (type === 'all' || type === 'resource_template') { + for (const templateItem of templates) { + const ann = templateItem.annotations + entries.push({ + uriTemplate: templateItem.uriTemplate, + name: templateItem.name, + title: templateItem.title, + description: templateItem.description, + mimeType: templateItem.mimeType, + annotations: includeAnnotations ? ann : undefined, + variables: templateItem.variables, + variablesSchemaUri: templateItem.variablesSchemaUri + }) + } + } + + const filterByAudience = (item: Entry) => { + if (audience === 'both') return true + const a = item?.annotations?.audience + if (!Array.isArray(a)) return true + return a.includes(audience) + } + + const filterByMime = (item: Entry) => { + if (!mimeType) return true + if (!item?.mimeType) return false + return item.mimeType === mimeType || item.mimeType.startsWith(`${mimeType}`) + } + + const norm = (s: unknown) => (typeof s === 'string' ? s.toLowerCase() : '') + const qn = norm(q) + const filterByQuery = (item: Entry) => { + if (!q) return true + return norm(item?.name).includes(qn) || norm(item?.title).includes(qn) || norm(item?.description).includes(qn) + } + + const filtered = entries.filter(filterByAudience).filter(filterByMime).filter(filterByQuery) + + filtered.sort((a, b) => { + if (sortBy === 'name') { + return (a.name || '').localeCompare(b.name || '') + } + const pa = typeof a?.annotations?.priority === 'number' ? a.annotations.priority : -1 + const pb = typeof b?.annotations?.priority === 'number' ? b.annotations.priority : -1 + if (pb !== pa) return pb - pa + return (a.name || '').localeCompare(b.name || '') + }) + + const total = filtered.length + const start = (page - 1) * pageSize + const end = start + pageSize + const items = filtered.slice(start, end) + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ status: 'success', data: { items, page, pageSize, total } }) + } + ] + } + } +}) + +export default createDiscoverResourcesTool diff --git a/packages/common/composable/mcp/baseTools/index.ts b/packages/common/composable/mcp/baseTools/index.ts new file mode 100644 index 0000000000..f85a0a4ca2 --- /dev/null +++ b/packages/common/composable/mcp/baseTools/index.ts @@ -0,0 +1,14 @@ +import type { IState, ToolItem } from '../type' +import { createDiscoverResourcesTool } from './discoverResources' +import { createReadResourcesTool } from './readResources' +import { createSearchResourcesTool } from './searchResources' +import { sequentialThinking } from './sequentialThinking' + +export const getBaseTools = (state: IState): ToolItem[] => [ + createDiscoverResourcesTool(state), + createReadResourcesTool(state), + createSearchResourcesTool(state), + sequentialThinking +] + +export default { getBaseTools } diff --git a/packages/common/composable/mcp/baseTools/readResources.ts b/packages/common/composable/mcp/baseTools/readResources.ts new file mode 100644 index 0000000000..7a97cbbb8a --- /dev/null +++ b/packages/common/composable/mcp/baseTools/readResources.ts @@ -0,0 +1,156 @@ +import type { IState } from '../type' +import { readResourceContent } from './utils' +import { z } from 'zod' + +const inputSchema = z.object({ + uri: z + .string() + .min(1) + .optional() + .describe( + '目标资源的完整URI(与 uriTemplate 二选一)。用于读取已确定的具体资源全部内容。适合从 discover_resources 或 search_resources 获得URI后的完整读取。' + ), + uriTemplate: z + .string() + .min(1) + .optional() + .describe( + '资源模板URI(与 uri 二选一)。配合 variables 参数使用,可实现参数化内容读取。适合读取文档特定章节、API特定接口等分段内容,有效控制内容体积。' + ), + variables: z + .record(z.string(), z.string()) + .optional() + .describe( + '模板变量键值对(仅当使用 uriTemplate 时必需)。用于替换模板中的占位符生成最终URI。例如:{"section": "api", "version": "v1"}。变量值会进行URL编码确保安全性。' + ), + maxBytes: z + .number() + .int() + .min(10_000) + .max(1_000_000) + .optional() + .describe( + '内容读取字节上限(10k-1000k,默认200k)。用于防止读取超大文件导致性能问题。建议根据用途设置:快速预览用50k-100k,详细分析用200k-500k,完整处理用1000k。' + ), + truncate: z + .boolean() + .optional() + .describe( + '超出大小限制时是否允许截断(默认true)。true时返回截断内容并标记truncated=true;false时超限将报错。建议大多数场景保持true,避免因文件过大导致读取失败。' + ) +}) + +// read_resources +// - 读取指定资源内容;支持通过模板 + variables 读取分节/参数化内容 +export const createReadResourcesTool = (state: IState) => ({ + name: 'read_resources', + title: 'Read TinyEngine Resource(s)', + description: [ + '用途:**精确内容读取** - 在确定目标资源后,获取完整或参数化的资源内容', + '', + '最佳使用场景:', + '• 从 search_resources 或 discover_resources 找到目标后读取完整内容', + '• 使用资源模板读取特定章节或参数化内容', + '• 需要获取代码示例、文档详情、配置文件等具体内容', + '• 在有明确资源URI的情况下直接读取', + '', + '两种读取模式:', + '**直接读取(uri)**:', + ' • 适用于已知具体资源地址的情况', + ' • 返回资源的完整内容', + ' • 简单直接,适合单一资源的完整获取', + '', + '**模板读取(uriTemplate + variables)**:', + ' • 适用于参数化资源,如文档章节、API分类等', + ' • 可精确控制读取范围,避免内容过载', + ' • 支持动态内容生成,提高灵活性', + ' • 推荐用于大型文档的分段读取', + '', + '重要限制:', + '• 仅支持文本类型资源(text/*、application/json等)', + '• 二进制文件(图片、视频等)不支持内容读取', + '• 内容大小有限制,超限时可选择截断或报错', + '', + '错误处理:', + '• read_resources_failed:资源读取失败(网络问题、权限等)', + '• invalid_template_variables:模板变量缺失或格式错误', + '• content_too_large:内容过大且不允许截断', + '• resource_not_found:指定的资源或模板不存在', + '', + '性能建议:', + '• 大文档优先使用模板方式分段读取', + '• 根据用途设置合适的 maxBytes 限制', + '• 保持 truncate=true 避免读取失败' + ].join('\n'), + inputSchema: inputSchema.shape, + annotations: { + audience: ['assistant'] + }, + callback: async (params: z.infer, _extra: any) => { + const maxBytes = params.maxBytes ?? 200_000 + const truncate = params.truncate ?? true + + const errorContent = (text: string) => ({ + content: [{ isError: true, type: 'text' as const, text }] + }) + + const readByUri = async (uri: string) => { + // 使用统一的资源读取工具函数 + const result = await readResourceContent(state, uri, { maxBytes, allowTruncate: truncate }) + if (!result.ok) { + return { ok: false as const, error: result.error || 'read_resources_failed' } + } + return { ok: true as const, contents: result.contents, truncated: result.truncated || undefined } + } + + try { + if (params.uri && !params.uriTemplate) { + const result = await readByUri(params.uri) + + if (!result.ok) { + return errorContent(result.error) + } + + const data = { contents: result.contents, truncated: result.truncated } + + return { + content: [{ type: 'text' as const, text: JSON.stringify({ status: 'success', data }) }] + } + } + + if (params.uriTemplate && !params.uri) { + const templates = state.resourceTemplates || [] + const item = templates.find((templateItem) => templateItem.uriTemplate === params.uriTemplate) + if (!item) { + return errorContent('resource_not_found') + } + let finalUriStr = '' + try { + finalUriStr = item.uriTemplate.replace(/\{.+?\}/g, (m) => { + const key = m.slice(1, -1) + const v = (params.variables || {})[key] + if (typeof v !== 'string' || !v) { + throw new Error('invalid_template_variables') + } + return encodeURIComponent(v) + }) + } catch (e) { + return errorContent('invalid_template_variables') + } + + const result = await readByUri(finalUriStr) + if (!result.ok) return errorContent(result.error) + const data = { contents: result.contents, truncated: result.truncated } + return { + content: [{ type: 'text' as const, text: JSON.stringify({ status: 'success', data }) }] + } + } + + return errorContent('read_resources_failed') + } catch { + return errorContent('read_resources_failed') + } + } +}) + +export default createReadResourcesTool diff --git a/packages/common/composable/mcp/baseTools/searchResources.ts b/packages/common/composable/mcp/baseTools/searchResources.ts new file mode 100644 index 0000000000..05f5f58793 --- /dev/null +++ b/packages/common/composable/mcp/baseTools/searchResources.ts @@ -0,0 +1,374 @@ +import type { IState, ResourceItem, ResourceTemplateItem } from '../type' +import { tryFetchRemoteLists, readResourceWithFallback, calculateByteLength, truncateTextToBytes } from './utils' +import { z } from 'zod' + +const inputSchema = z.object({ + query: z + .string() + .trim() + .min(1) + .max(200) + .describe( + '检索关键词(必填)。用于定位最相关的资源,支持匹配资源名称、标题、描述和内容。建议使用具体的技术术语、功能名称或问题关键词,如"按钮组件"、"API文档"、"数据绑定"等。' + ), + scope: z + .enum(['metadata', 'content', 'all']) + .optional() + .describe( + '检索范围:metadata(仅搜索名称、标题、描述等元数据,速度快)| content(搜索文件内容,更全面但耗时)| all(默认,同时搜索元数据和内容)。首次搜索建议用metadata,未找到满意结果时升级为all。' + ), + type: z + .enum(['resource', 'resource_template', 'all']) + .optional() + .describe( + '限定搜索类型:resource(搜索静态资源如文档、代码)| resource_template(搜索模板资源,支持参数化)| all(默认,搜索所有类型)。明确需求类型可提高搜索精度。' + ), + audience: z + .enum(['assistant', 'user', 'both']) + .optional() + .describe( + '按受众筛选:assistant(搜索AI助手相关的技术文档、API参考等)| user(搜索用户界面、操作手册等)| both(默认,不限受众)。' + ), + mimeType: z + .string() + .trim() + .min(1) + .max(100) + .optional() + .describe( + '按文件类型筛选,支持前缀匹配。例如:text/markdown(Markdown文档)、application/json(JSON配置)、text/javascript(JS代码)。用于限定特定格式的搜索结果。' + ), + topK: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe( + '返回最相关的前K个结果(1-50,默认10)。数量越多覆盖面越广但处理时间越长。建议:快速查找用5-10,全面搜索用20-30,深度研究用50。' + ), + snippet: z + .object({ + enabled: z + .boolean() + .optional() + .describe('是否返回匹配内容片段(默认true)。片段可帮助快速了解匹配上下文,判断是否为所需内容。'), + maxLength: z + .number() + .int() + .min(60) + .max(600) + .optional() + .describe( + '片段最大长度(60-600字符,默认240)。长度越长提供上下文越多,但响应体积越大。建议:快速预览用120-240,详细了解用300-600。' + ) + }) + .optional() + .describe( + '内容片段配置。开启后在搜索结果中包含匹配的文本片段,有助于快速判断资源相关性,但会增加处理时间和响应大小。' + ), + contentMaxBytesPerDoc: z + .number() + .int() + .min(20_000) + .max(300_000) + .optional() + .describe( + '单个文档的内容搜索字节限制(20k-300k,默认120k)。仅在scope包含content时生效。限制越高搜索越全面但性能开销越大。大文档建议用模板方式分段搜索。' + ) +}) + +const sliceSnippet = (text: string, q: string, max = 240) => { + const idx = text.toLowerCase().indexOf(q.toLowerCase()) + if (idx === -1) return text.slice(0, max) + const start = Math.max(0, idx - Math.floor(max / 2)) + const end = Math.min(text.length, start + max) + return text.slice(start, end) +} + +// search_resources +// - 元数据 + 轻量内容检索,返回评分与可选片段 +export const createSearchResourcesTool = (state: IState) => ({ + name: 'search_resources', + title: 'Search TinyEngine Resources', + description: [ + '用途:**智能资源检索** - 当您有明确搜索目标时,快速找到最相关的资源', + '', + '最佳使用场景:', + '• 寻找特定功能的文档或示例(如"表单验证"、"数据绑定")', + '• 根据技术关键词查找相关资源(如"React组件"、"API接口")', + '• 需要在大量资源中精确定位目标内容', + '• 想要预览资源内容片段以判断相关性', + '', + '检索策略:', + '• 智能评分:结合关键词匹配度、资源优先级进行排序', + '• 多层搜索:支持元数据搜索(快速)和内容搜索(全面)', + '• 上下文片段:提供匹配内容的上下文,帮助快速判断', + '• 灵活筛选:支持按类型、受众、文件格式等维度过滤', + '', + '性能优化建议:', + '• 首次搜索用 scope=metadata,速度快', + '• 未找到满意结果时升级为 scope=all', + '• 大型项目建议设置合适的 topK 值', + '• 使用具体的技术术语提高搜索精度', + '', + '与其他工具的配合:', + '• 搜索后使用 read_resources 获取完整内容', + '• 结合 discover_resources 了解资源全貌', + '• 对模板资源,可进一步使用参数化读取', + '', + '返回格式:{results: Array, total: number},包含评分、片段等信息' + ].join('\n'), + inputSchema: inputSchema.shape, + annotations: { + audience: ['assistant'] + }, + callback: async (args: z.infer, _extra: any) => { + const { + query, + scope = 'all', + type = 'all', + audience = 'both', + mimeType, + topK = 10, + snippet: snippetCfg, + contentMaxBytesPerDoc = 120_000 + } = args + + const wantSnippet = snippetCfg?.enabled !== false + const snippetLen = snippetCfg?.maxLength ?? 240 + + // 远端优先拉取资源与模板列表,失败回退本地 + let resources: Omit[] = [] + let templates: Omit[] = [] + + const { ok, resources: rr, resourceTemplates: rt } = await tryFetchRemoteLists(state) + + if (ok) { + resources = rr + templates = rt + } + + if (!ok) { + resources = state.resources || [] + templates = state.resourceTemplates || [] + } + + const entries: ( + | { + kind: 'resource' + item: Omit + } + | { + kind: 'resource_template' + item: Omit + } + )[] = [] + if (type === 'all' || type === 'resource') { + for (const resourceItem of resources) { + entries.push({ kind: 'resource', item: resourceItem }) + } + } + + if (type === 'all' || type === 'resource_template') { + for (const templateItem of templates) { + entries.push({ kind: 'resource_template', item: templateItem }) + } + } + + const norm = (s: unknown) => (typeof s === 'string' ? s.toLowerCase() : '') + const lowercasedQuery = norm(query) + + const passAudience = (annotation: ResourceItem['annotations'] | ResourceTemplateItem['annotations']) => { + if (audience === 'both') { + return true + } + + const a = annotation?.audience + + if (!Array.isArray(a)) { + return true + } + + return a.includes(audience) + } + + const passMime = (mimeTypeItem?: string) => { + if (!mimeType) { + return true + } + + if (!mimeTypeItem) { + return false + } + + return mimeTypeItem === mimeType || mimeTypeItem.startsWith(`${mimeType}`) + } + + type Candidate = { + type: 'resource' | 'resource_template' + uri?: string + uriTemplate?: string + name?: string + title?: string + description?: string + mimeType?: string + annotations?: ResourceItem['annotations'] | ResourceTemplateItem['annotations'] + score: number + snippet?: string + variables?: ResourceTemplateItem['variables'] + variablesSchemaUri?: string + } + + const candidates: Candidate[] = [] + + // 元数据打分 + const scoreMeta = (name?: string, title?: string, desc?: string) => { + let s = 0 + + if (name && norm(name).includes(lowercasedQuery)) { + s += 2 + } + + if (title && norm(title).includes(lowercasedQuery)) { + s += 2 + } + + if (desc && norm(desc).includes(lowercasedQuery)) { + s += 1 + } + + return s + } + + // 先做元数据过滤与初始评分 + for (const e of entries) { + const entriesItem = e.item + const kind = e.kind + const ann = entriesItem?.annotations + const mt = entriesItem?.mimeType + + if (!passAudience(ann)) { + continue + } + + if (!passMime(mt)) { + continue + } + + const name = entriesItem?.name + const title = entriesItem?.title + const description = entriesItem?.description + const metaScore = scoreMeta(name, title, description) + if (scope === 'metadata' || scope === 'all') { + if (metaScore > 0) { + candidates.push({ + type: kind, + uri: e.kind === 'resource' ? e.item.uri : undefined, + uriTemplate: e.kind === 'resource_template' ? e.item.uriTemplate : undefined, + name, + title, + description, + mimeType: mt, + annotations: ann, + score: metaScore, + variables: kind === 'resource_template' ? e.item.variables : undefined, + variablesSchemaUri: kind === 'resource_template' ? e.item.variablesSchemaUri : undefined + }) + } + } else { + // scope === content 时,先占位,后续内容命中再补打分 + candidates.push({ + type: kind, + uri: kind === 'resource' ? e.item.uri : undefined, + uriTemplate: kind === 'resource_template' ? e.item.uriTemplate : undefined, + name, + title, + description, + mimeType: mt, + annotations: ann, + score: 0, + variables: kind === 'resource_template' ? e.item.variables : undefined, + variablesSchemaUri: kind === 'resource_template' ? e.item.variablesSchemaUri : undefined + }) + } + } + + // 内容检索:仅处理文本类型且限制读取体量 + const addContentScores = async () => { + const isTextual = (mimeTypeItem?: string) => + mimeTypeItem ? mimeTypeItem.startsWith('text/') || mimeTypeItem === 'text/markdown' : true + + const readText = async (candidateItem: Candidate): Promise => { + try { + if (candidateItem.type === 'resource') { + // 使用统一的资源读取工具函数 + const readResult = await readResourceWithFallback(state, candidateItem.uri || '') + if (!readResult.ok) return null + const res = readResult.result + + const content = res?.contents?.[0] + const text = content?.text + if (typeof text !== 'string') return null + const bytes = calculateByteLength(text) + if (bytes > contentMaxBytesPerDoc) { + return truncateTextToBytes(text, contentMaxBytesPerDoc) + } + return text + } else { + // 默认不对模板做内容检索 + return null + } + } catch { + return null + } + } + + const need = candidates.filter((c) => scope !== 'metadata' && isTextual(c.mimeType)) + const concurrency = 4 + let idx = 0 + const runOne = async () => { + while (idx < need.length) { + const current = need[idx++] + const text = await readText(current) + if (!text) continue + if (text.toLowerCase().includes(lowercasedQuery)) { + const boost = 3 + if (wantSnippet) { + current.snippet = sliceSnippet(text, query, snippetLen) + } + current.score += boost + } + } + } + const workers = Array.from({ length: Math.min(concurrency, need.length) }, () => runOne()) + await Promise.all(workers) + } + + await addContentScores() + + // 优先级加权 + candidates.forEach((candidateItem) => { + const priority = typeof candidateItem?.annotations?.priority === 'number' ? candidateItem.annotations.priority : 0 + candidateItem.score = candidateItem.score * (1 + priority * 0.5) + }) + + // 过滤掉 score <= 0 + const positive = candidates.filter((candidateItem) => candidateItem.score > 0) + positive.sort((a, b) => b.score - a.score) + + const total = positive.length + const results = positive.slice(0, topK) + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ status: 'success', data: { results, total } }) + } + ] + } + } +}) + +export default createSearchResourcesTool diff --git a/packages/common/composable/mcp/baseTools/sequentialThinking.ts b/packages/common/composable/mcp/baseTools/sequentialThinking.ts new file mode 100644 index 0000000000..e2d26058e6 --- /dev/null +++ b/packages/common/composable/mcp/baseTools/sequentialThinking.ts @@ -0,0 +1,208 @@ +import { z } from 'zod' + +const inputSchema = z.object({ + thought: z.string().describe('Your current thinking step'), + nextThoughtNeeded: z.boolean().describe('Whether another thought step is needed'), + thoughtNumber: z.number().min(1).describe('Current thought number (numeric value, e.g., 1, 2, 3)'), + totalThoughts: z.number().min(1).describe('Estimated total thoughts needed (numeric value, e.g., 5, 10)'), + isRevision: z.boolean().optional().describe('Whether this revises previous thinking'), + revisesThought: z.number().min(1).optional().describe('Which thought is being reconsidered'), + branchFromThought: z.number().min(1).optional().describe('Branching point thought number'), + branchId: z.string().optional().describe('Branch identifier'), + needsMoreThoughts: z.boolean().optional().describe('If more thoughts are needed') +}) + +interface ThoughtData { + thought: string + thoughtNumber: number + totalThoughts: number + isRevision?: boolean + revisesThought?: number + branchFromThought?: number + branchId?: string + needsMoreThoughts?: boolean + nextThoughtNeeded: boolean +} + +const validateThoughtData = (input: unknown): ThoughtData => { + const data = input as Record + + if (!data.thought || typeof data.thought !== 'string') { + throw new Error('Invalid thought: must be a string') + } + if (!data.thoughtNumber || typeof data.thoughtNumber !== 'number') { + throw new Error('Invalid thoughtNumber: must be a number') + } + if (!data.totalThoughts || typeof data.totalThoughts !== 'number') { + throw new Error('Invalid totalThoughts: must be a number') + } + if (typeof data.nextThoughtNeeded !== 'boolean') { + throw new Error('Invalid nextThoughtNeeded: must be a boolean') + } + + return { + thought: data.thought, + thoughtNumber: data.thoughtNumber, + totalThoughts: data.totalThoughts, + nextThoughtNeeded: data.nextThoughtNeeded, + isRevision: data.isRevision as boolean | undefined, + revisesThought: data.revisesThought as number | undefined, + branchFromThought: data.branchFromThought as number | undefined, + branchId: data.branchId as string | undefined, + needsMoreThoughts: data.needsMoreThoughts as boolean | undefined + } +} +const logger = console + +const formatThought = (thoughtData: ThoughtData) => { + const { thoughtNumber, totalThoughts, thought, isRevision, revisesThought, branchFromThought, branchId } = thoughtData + + let prefix = '' + let context = '' + + if (isRevision) { + prefix = '%c🔄 Revision' + logger.log(prefix, 'color: yellow') + context = ` (revising thought ${revisesThought})` + } else if (branchFromThought) { + prefix = '%c🌿 Branch' + logger.log(prefix, 'color: green') + context = ` (from thought ${branchFromThought}, ID: ${branchId})` + } else { + prefix = '%c💭 Thought' + logger.log(prefix, 'color: blue') + context = '' + } + + const header = `${prefix} ${thoughtNumber}/${totalThoughts}${context}` + const border = '─'.repeat(Math.max(header.length, thought.length) + 4) + + return ` +┌${border}┐ +│ ${header} │ +├${border}┤ +│ ${thought.padEnd(border.length - 2)} │ +└${border}┘` +} + +const thoughtHistory: ThoughtData[] = [] +const branches: Record = {} + +export const sequentialThinking = { + name: 'sequential_thinking', + title: 'Sequential Thinking', + description: `A detailed tool for dynamic and reflective problem-solving through thoughts. +This tool helps analyze problems through a flexible thinking process that can adapt and evolve. +Each thought can build on, question, or revise previous insights as understanding deepens. + +When to use this tool: +- Breaking down complex problems into steps +- Planning and design with room for revision +- Analysis that might need course correction +- Problems where the full scope might not be clear initially +- Problems that require a multi-step solution +- Tasks that need to maintain context over multiple steps +- Situations where irrelevant information needs to be filtered out + +Key features: +- You can adjust total_thoughts up or down as you progress +- You can question or revise previous thoughts +- You can add more thoughts even after reaching what seemed like the end +- You can express uncertainty and explore alternative approaches +- Not every thought needs to build linearly - you can branch or backtrack +- Generates a solution hypothesis +- Verifies the hypothesis based on the Chain of Thought steps +- Repeats the process until satisfied +- Provides a correct answer + +Parameters explained: +- thought: Your current thinking step, which can include: +* Regular analytical steps +* Revisions of previous thoughts +* Questions about previous decisions +* Realizations about needing more analysis +* Changes in approach +* Hypothesis generation +* Hypothesis verification +- next_thought_needed: True if you need more thinking, even if at what seemed like the end +- thought_number: Current number in sequence (can go beyond initial total if needed) +- total_thoughts: Current estimate of thoughts needed (can be adjusted up/down) +- is_revision: A boolean indicating if this thought revises previous thinking +- revises_thought: If is_revision is true, which thought number is being reconsidered +- branch_from_thought: If branching, which thought number is the branching point +- branch_id: Identifier for the current branch (if any) +- needs_more_thoughts: If reaching end but realizing more thoughts needed + +You should: +1. Start with an initial estimate of needed thoughts, but be ready to adjust +2. Feel free to question or revise previous thoughts +3. Don't hesitate to add more thoughts if needed, even at the "end" +4. Express uncertainty when present +5. Mark thoughts that revise previous thinking or branch into new paths +6. Ignore information that is irrelevant to the current step +7. Generate a solution hypothesis when appropriate +8. Verify the hypothesis based on the Chain of Thought steps +9. Repeat the process until satisfied with the solution +10. Provide a single, ideally correct answer as the final output +11. Only set next_thought_needed to false when truly done and a satisfactory answer is reached`, + inputSchema: inputSchema.shape, + callback: async (args: z.infer, _extra: any) => { + try { + const validatedInput = validateThoughtData(args) + + if (validatedInput.thoughtNumber > validatedInput.totalThoughts) { + validatedInput.totalThoughts = validatedInput.thoughtNumber + } + + thoughtHistory.push(validatedInput) + + if (validatedInput.branchFromThought && validatedInput.branchId) { + if (!branches[validatedInput.branchId]) { + branches[validatedInput.branchId] = [] + } + branches[validatedInput.branchId].push(validatedInput) + } + + if (process.env.NODE_ENV === 'development') { + const formattedThought = formatThought(validatedInput) + logger.error(formattedThought) + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + thoughtNumber: validatedInput.thoughtNumber, + totalThoughts: validatedInput.totalThoughts, + nextThoughtNeeded: validatedInput.nextThoughtNeeded, + branches: Object.keys(branches), + thoughtHistoryLength: thoughtHistory.length + }, + null, + 2 + ) + } + ] + } + } catch (error) { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + error: error instanceof Error ? error.message : String(error), + status: 'failed' + }, + null, + 2 + ) + } + ], + isError: true + } + } + } +} diff --git a/packages/common/composable/mcp/baseTools/utils.ts b/packages/common/composable/mcp/baseTools/utils.ts new file mode 100644 index 0000000000..c2282c8a95 --- /dev/null +++ b/packages/common/composable/mcp/baseTools/utils.ts @@ -0,0 +1,196 @@ +import type { IState, ResourceItem, ResourceTemplateItem } from '../type' +import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js' + +interface IResult { + ok: boolean + resources: Omit[] + resourceTemplates: Omit[] +} + +// 尝试从远端 mcpClient 拉取资源与模板列表 +// 返回 { ok, resources, resourceTemplates },调用方可在 ok=false 时做本地兜底 +export const tryFetchRemoteLists = async (state: IState): Promise => { + const client = state?.mcpClient + const result: IResult = { ok: false, resources: [], resourceTemplates: [] } + + if (!client) { + return result + } + + try { + const [resourcesList, resourceTemplatesList] = await Promise.all([ + client.listResources().catch(() => null), + client.listResourceTemplates().catch(() => null) + ]) + + const remoteResources = resourcesList?.resources || [] + const remoteTemplates = resourceTemplatesList?.resourceTemplates || [] + + if (Array.isArray(remoteResources)) { + result.resources = remoteResources + } + + if (Array.isArray(remoteTemplates)) { + result.resourceTemplates = remoteTemplates + } + + result.ok = true + } catch { + result.ok = false + } + + return result +} + +// ========== 字节处理工具函数 ========== + +/** + * 计算字符串的字节长度 + * @param text 要计算长度的字符串 + * @returns 字节长度 + */ +export const calculateByteLength = (text: string): number => { + return new TextEncoder().encode(text).length +} + +/** + * 将文本截断到指定的字节数限制 + * @param text 要截断的文本 + * @param limit 字节数限制 + * @returns 截断后的文本 + */ +export const truncateTextToBytes = (text: string, limit: number): string => { + const enc = new TextEncoder().encode(text) + const sliced = enc.slice(0, limit) + return new TextDecoder('utf-8').decode(sliced) +} + +// ========== 内容验证工具函数 ========== + +/** + * 验证内容是否为文本类型 + * @param contents 资源内容数组 + * @returns 验证结果 + */ +export const validateTextualContent = (contents: ReadResourceResult['contents']) => { + if (!Array.isArray(contents)) { + return { ok: false as const, error: 'read_resources_failed' } + } + for (const c of contents) { + // 仅允许 text 字段存在(不处理二进制) + if (typeof c?.text !== 'string') { + return { ok: false as const, error: 'unsupported_content_type' } + } + } + return { ok: true as const } +} + +// ========== 资源读取工具函数 ========== + +/** + * 远端优先的资源读取,失败时回退到本地 + * @param state 状态对象 + * @param uri 资源URI + * @returns 读取结果 + */ +export const readResourceWithFallback = async ( + state: IState, + uri: string +): Promise<{ ok: boolean; result?: ReadResourceResult; error?: string }> => { + const client = state?.mcpClient + let res: ReadResourceResult | null = null + + // 远端优先读取 + try { + if (client && typeof client.readResource === 'function') { + res = await client.readResource({ uri }).catch(() => null) + } + } catch { + // ignore + } + + // 不做本地回退 + if (!res) { + return { ok: false, error: 'read_resources_failed' } + } + + return { ok: true, result: res } +} + +// ========== 内容截断处理工具函数 ========== + +/** + * 应用内容截断策略 + * @param contents 资源内容数组 + * @param maxBytes 最大字节数 + * @param allowTruncate 是否允许截断 + * @returns 截断处理结果 + */ +export const applyContentTruncation = ( + contents: ReadResourceResult['contents'], + maxBytes: number, + allowTruncate: boolean +) => { + let truncated = false + const next = contents.map((c) => { + if (typeof c.text === 'string' && calculateByteLength(c.text) > maxBytes) { + if (!allowTruncate) { + return { __tooLarge: true } + } + truncated = true + return { ...c, text: truncateTextToBytes(c.text, maxBytes) } + } + return c + }) + // 检查是否有未允许的超限项 + const tooLarge = next.some((c) => c?.__tooLarge) + if (tooLarge) { + return { ok: false as const, error: 'content_too_large' } + } + return { ok: true as const, next, truncated } +} + +// ========== 统一资源读取协调器 ========== + +/** + * 统一的资源读取入口 + * @param state 状态对象 + * @param uri 资源URI + * @param options 读取选项 + * @returns 读取结果 + */ +export const readResourceContent = async ( + state: IState, + uri: string, + options: { maxBytes?: number; allowTruncate?: boolean } = {} +) => { + const { maxBytes = 200_000, allowTruncate = true } = options + + // 读取资源 + const readResult = await readResourceWithFallback(state, uri) + if (!readResult.ok) { + return { ok: false, error: readResult.error } + } + + const contents = readResult.result?.contents || [] + + // 验证内容类型 + const validationResult = validateTextualContent(contents) + if (!validationResult.ok) { + return { ok: false, error: validationResult.error } + } + + // 应用截断策略 + const truncationResult = applyContentTruncation(contents, maxBytes, allowTruncate) + if (!truncationResult.ok) { + return { ok: false, error: truncationResult.error } + } + + return { + ok: true, + contents: truncationResult.next, + truncated: truncationResult.truncated || undefined + } +} + +export default { tryFetchRemoteLists } diff --git a/packages/common/composable/mcp/index.ts b/packages/common/composable/mcp/index.ts index 76bb7ede2c..7873a7b751 100644 --- a/packages/common/composable/mcp/index.ts +++ b/packages/common/composable/mcp/index.ts @@ -2,13 +2,26 @@ import { useMessage, defineService, META_SERVICE, getAllMergeMeta } from '@opent import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { createTransportPair, createStreamProxy } from '@opentiny/next' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.d.ts' -import type { ZodRawShape } from 'zod' -import type { IState, ToolItem, ServerConnectionStatus } from './type' -import { registerTools, getToolList, getToolByName, removeTool, updateTool, type UpdateToolConfig } from './toolUtils' +import type { IState, ToolItem, ServerConnectionStatus, ResourceItem, ResourceTemplateItem } from './type' +import { + registerTools, + getToolList, + getToolByName, + removeTool, + updateTool, + type UpdateToolConfig, + initRegisterTools +} from './toolUtils' import { toRaw } from 'vue' - -const logger = console +import { + initRegisterResources, + registerResources, + getResourceList, + getResourceByUri, + removeResource, + updateResource +} from './resources' +import { getBaseTools } from './baseTools' export type { IState, ToolItem, UpdateToolConfig } @@ -42,7 +55,10 @@ const initialState: IState = { toolList: [], toolInstanceMap: new Map(), mcpClient: null, - serverConnectionStatus: 'disconnected' + serverConnectionStatus: 'disconnected', + resources: [], + resourceTemplates: [], + resourceInstanceMap: new Map() } const updateServerConnectionStatus = (state: IState, status: ServerConnectionStatus, error?: Error) => { @@ -161,43 +177,9 @@ const createMcpServer = async (state: IState) => { } ) - // server._setupTimeout = () => {} - - state.toolList.forEach((tool) => { - const { name, callback, inputSchema, outputSchema, ...restConfig } = tool - - try { - if (state.toolInstanceMap.has(name)) { - logger.error(`tool ${name} already registered`) - return - } - - if (!name || typeof name !== 'string') { - logger.error('tool name is required and must be a string') - return - } - - if (!callback || typeof callback !== 'function') { - logger.error('tool callback is required and must be a function') - return - } - - const toolInstance = server.registerTool( - name, - // 需要序列化一次,否则 list tool 会超时,因为有 proxy 之后,内部会报错 - { - ...JSON.parse(JSON.stringify(restConfig)), - inputSchema, - outputSchema - }, - callback as ToolCallback - ) - - state.toolInstanceMap.set(name, toolInstance) - } catch (error) { - logger.error('error when register tool', error) - } - }) + initRegisterTools(state, server) + + initRegisterResources(state, server) await server.connect(toRaw(transport)) @@ -212,6 +194,14 @@ const collectTools = (state: IState) => { const allMetaData = getAllMergeMeta() const tools: ToolItem[] = [] + try { + const baseTools = getBaseTools(state) as unknown as ToolItem[] + tools.push(...baseTools) + } catch (e) { + // eslint-disable-next-line no-console + console.error('inject base tools failed', e) + } + allMetaData.forEach((meta) => { if (Array.isArray(meta.mcp?.tools)) { tools.push(...meta.mcp.tools) @@ -221,6 +211,26 @@ const collectTools = (state: IState) => { state.toolList = tools } +// 收集所有 meta 中声明的 MCP 资源与模板 +const collectResources = (state: IState) => { + const allMetaData = getAllMergeMeta() + const resources: ResourceItem[] = [] + const resourceTemplates: ResourceTemplateItem[] = [] + + allMetaData.forEach((meta) => { + if (meta && typeof meta === 'object' && Array.isArray(meta.mcp?.resources)) { + resources.push(...meta.mcp.resources) + } + if (meta && typeof meta === 'object' && Array.isArray(meta.mcp?.resourceTemplates)) { + resourceTemplates.push(...meta.mcp.resourceTemplates) + } + }) + + // 将结果挂载在 state 上,供后续创建 server 时使用 + state.resources = resources + state.resourceTemplates = resourceTemplates +} + // 移除未使用的 @ts-expect-error 注释 export default defineService({ id: META_SERVICE.McpService, @@ -238,8 +248,9 @@ export default defineService({ // 收集所有注册表中的 tools collectTools(state) + // 收集所有注册表中的 resources + collectResources(state) // TODO: 支持 prompts - // TODO: 支持 resources // TODO: 支持 Elicitation // 创建 mcp server @@ -258,6 +269,12 @@ export default defineService({ getToolList: () => getToolList(state), getToolByName: (name: string) => getToolByName(state, name), removeTool: (name: string) => removeTool(state, name), - updateTool: (name: string, config?: UpdateToolConfig) => updateTool(state, name, config) + updateTool: (name: string, config?: UpdateToolConfig) => updateTool(state, name, config), + // resources apis + registerResources: (resources: ResourceItem[]) => registerResources(state, resources), + getResourceList: () => getResourceList(state), + getResourceByUri: (uri: string) => getResourceByUri(state, uri), + removeResource: (uri: string) => removeResource(state, uri), + updateResource: (uri: string, updates?: any) => updateResource(state, uri, updates) }) }) diff --git a/packages/common/composable/mcp/resources.ts b/packages/common/composable/mcp/resources.ts new file mode 100644 index 0000000000..33367e4308 --- /dev/null +++ b/packages/common/composable/mcp/resources.ts @@ -0,0 +1,227 @@ +import { toRaw } from 'vue' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { ResourceItem, ResourceTemplateItem, IState } from './type' +import type { RegisteredResource, ReadResourceCallback } from '@modelcontextprotocol/sdk/server/mcp.js' +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js' + +const logger = console + +// 资源更新配置(直接透传 SDK RegisteredResource.update 的入参) +export interface UpdateResourceConfig { + uri?: string | null + name?: string + title?: string + metadata?: any + callback?: ReadResourceCallback + enabled?: boolean +} + +// 获取(或初始化)资源实例映射 +const ensureResourceMap = (state: IState) => { + if (!state.resourceInstanceMap) { + state.resourceInstanceMap = new Map() + } + return state.resourceInstanceMap +} + +// 构造用于注册的 metadata(去 Proxy 深拷贝) +const buildMetadata = (item: ResourceItem) => { + const meta = { + title: item.title, + description: item.description, + mimeType: item.mimeType, + annotations: toRaw(item.annotations) + } + return JSON.parse(JSON.stringify(meta)) +} + +// 批量注册资源(幂等:同 uri 已有实例则 update 对齐) +export const registerResources = (state: IState, items: ResourceItem[]) => { + if (!Array.isArray(items) || !items.length) return + if (!state.server) { + logger.error('mcp server is not created') + return + } + + const map = ensureResourceMap(state) + + const instances: (RegisteredResource | undefined)[] = items.map((item) => { + const uri = item.uri + const name = item.name || '' + const metadata = buildMetadata(item) + + try { + const exist = map.get(uri) + if (exist) { + exist.update({ name, uri, metadata, callback: item.readCallback }) + return exist + } + + const instance = state.server!.registerResource(name, uri, metadata, item.readCallback) + if (instance) { + map.set(uri, instance) + } + return instance + } catch (e) { + logger.error('error when register resource', uri, e) + return undefined + } + }) + + try { + state.server.sendResourceListChanged() + } catch (e) { + logger.error('error when sendResourceListChanged after registerResources', e) + } + + return instances +} + +// 获取资源列表(对齐工具侧:带 status) +export const getResourceList = (state: IState) => { + const defs: ResourceItem[] = state.resources || [] + const map = ensureResourceMap(state) + + return defs.map((def) => { + const inst = map.get(def.uri) + const metadata = inst?.metadata || {} + const merged = { + uri: def.uri, + name: inst?.name ?? def.name, + title: inst?.title ?? def.title, + description: metadata?.description ?? def.description, + mimeType: metadata?.mimeType ?? def.mimeType, + annotations: metadata?.annotations ?? def.annotations, + status: inst ? (inst.enabled ? 'enabled' : 'disabled') : 'not_registered' + } + return merged + }) +} + +// 按 uri 获取资源(不存在定义则返回 null) +export const getResourceByUri = (state: IState, uri: string) => { + const def = (state.resources || []).find((r) => r.uri === uri) + if (!def) return null + const map = ensureResourceMap(state) + const inst = map.get(uri) + const metadata = inst?.metadata || {} + return { + uri: def.uri, + name: inst?.name ?? def.name, + title: inst?.title ?? def.title, + description: metadata?.description ?? def.description, + mimeType: metadata?.mimeType ?? def.mimeType, + annotations: metadata?.annotations ?? def.annotations, + status: inst ? (inst.enabled ? 'enabled' : 'disabled') : 'not_registered' + } +} + +// 注销实例,保留定义 +export const removeResource = (state: IState, uri: string) => { + const map = ensureResourceMap(state) + const inst = map.get(uri) + if (!inst) { + logger.error('resource instance not found for uri:', uri) + return + } + try { + inst.remove() + } catch (e) { + logger.error('error when remove resource', uri, e) + } finally { + map.delete(uri) + + try { + state.server?.sendResourceListChanged() + } catch (e) { + logger.error('error when sendResourceListChanged after removeResource', e) + } + } +} + +// 透传实例的 update;若 uri 变更,同步迁移本地映射键 +export const updateResource = (state: IState, uri: string, updates?: UpdateResourceConfig) => { + const map = ensureResourceMap(state) + const inst = map.get(uri) + if (!inst || !updates || typeof updates !== 'object') { + logger.error('resource instance not found for uri:', uri) + return + } + + try { + inst.update(updates) + } catch (e) { + logger.error('error when update resource', uri, e) + return + } + + if (Object.prototype.hasOwnProperty.call(updates, 'uri')) { + const newUri = updates.uri as string | null | undefined + if (typeof newUri === 'string' && newUri && newUri !== uri) { + try { + map.delete(uri) + map.set(newUri, inst) + } catch (e) { + logger.error('error when migrate resourceInstanceMap key', uri, '->', newUri, e) + } + } + } + + try { + state.server?.sendResourceListChanged() + } catch (e) { + logger.error('error when sendResourceListChanged after updateResource', e) + } +} + +// 注册资源与资源模板 +export const initRegisterResources = (state: IState, server: McpServer) => { + try { + const resources: ResourceItem[] = state.resources || [] + const resourceTemplates: ResourceTemplateItem[] = state.resourceTemplates || [] + + const map = ensureResourceMap(state) + + resources.forEach((resourceItem) => { + try { + const instance = server.registerResource( + resourceItem.name || '', + resourceItem.uri, + buildMetadata(resourceItem), + resourceItem.readCallback + ) + if (instance) { + map.set(resourceItem.uri, instance) + } + } catch (e) { + logger.error('error when register resource', resourceItem?.uri, e) + } + }) + + resourceTemplates.forEach((resourceItem) => { + try { + const template = resourceItem.template || new ResourceTemplate(resourceItem.uriTemplate, { list: undefined }) + server.registerResource( + resourceItem.name, + template, + { + title: resourceItem.title, + description: resourceItem.description, + mimeType: resourceItem.mimeType, + annotations: toRaw(resourceItem.annotations), + // 将 variables 与 variablesSchemaUri 作为元数据透传,便于远端 listResourceTemplates 返回 + variables: toRaw((resourceItem as any).variables), + variablesSchemaUri: toRaw((resourceItem as any).variablesSchemaUri) + }, + resourceItem.readTemplateCallback + ) + } catch (e) { + logger.error('error when register resource template', resourceItem?.uriTemplate, e) + } + }) + + server.sendResourceListChanged() + } catch (error) { + logger.error('error when register resources/templates', error) + } +} diff --git a/packages/common/composable/mcp/toolUtils.ts b/packages/common/composable/mcp/toolUtils.ts index a45e50cb58..0fcd223c8f 100644 --- a/packages/common/composable/mcp/toolUtils.ts +++ b/packages/common/composable/mcp/toolUtils.ts @@ -2,6 +2,7 @@ import type { IState, ToolItem } from './type' import type { ZodRawShape } from 'zod' import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.d.ts' import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.d.ts' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' const logger = console @@ -100,3 +101,42 @@ export const updateTool = (state: IState, name: string, config?: UpdateToolConfi toolInstance.update({ name, ...(config || {}) }) } } + +// server 初始化时,批量注册 state 中存储的 tools +export const initRegisterTools = (state: IState, server: McpServer) => { + state.toolList.forEach((tool) => { + const { name, callback, inputSchema, outputSchema, ...restConfig } = tool + + try { + if (state.toolInstanceMap.has(name)) { + logger.error(`tool ${name} already registered`) + return + } + + if (!name || typeof name !== 'string') { + logger.error('tool name is required and must be a string') + return + } + + if (!callback || typeof callback !== 'function') { + logger.error('tool callback is required and must be a function') + return + } + + const toolInstance = server.registerTool( + name, + // 需要序列化一次,否则 list tool 会超时,因为有 proxy 之后,内部会报错 + { + ...JSON.parse(JSON.stringify(restConfig)), + inputSchema, + outputSchema + }, + callback as ToolCallback + ) + + state.toolInstanceMap.set(name, toolInstance) + } catch (error) { + logger.error('error when register tool', error) + } + }) +} diff --git a/packages/common/composable/mcp/type.ts b/packages/common/composable/mcp/type.ts index c36c15502c..6250dfd344 100644 --- a/packages/common/composable/mcp/type.ts +++ b/packages/common/composable/mcp/type.ts @@ -1,7 +1,12 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import type { MessageChannelTransport, MessageChannelServerTransport } from '@opentiny/next' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { ToolCallback, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.d.ts' +import type { + McpServer, + ResourceTemplate, + ReadResourceCallback, + ReadResourceTemplateCallback +} from '@modelcontextprotocol/sdk/server/mcp.js' +import type { ToolCallback, RegisteredTool, RegisteredResource } from '@modelcontextprotocol/sdk/server/mcp.d.ts' import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.d.ts' import type { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import type { ZodRawShape } from 'zod' @@ -34,4 +39,65 @@ export interface IState { server: McpServer | null mcpClient: Client | null serverConnectionStatus: ServerConnectionStatus + resources: ResourceItem[] + resourceTemplates: ResourceTemplateItem[] + // 以 uri 为键缓存已注册的资源实例 + resourceInstanceMap?: Map +} + +// Resource related types for MCP resources feature +export interface ResourceContent { + uri: string + name: string + title?: string + mimeType?: string + text?: string +} + +// 模板参数约束规范 +export interface VariableSpec { + name: string + required?: boolean + type: 'enum' | 'string' | 'number' | 'integer' | 'boolean' | 'date' | 'datetime' | 'json' | 'regex' + enumValues?: Array<{ value: string; title?: string; description?: string }> + format?: string + pattern?: string + minLength?: number + maxLength?: number + minimum?: number + maximum?: number + constraintsDescription?: string + example?: string +} + +export interface ResourceItem { + uri: string + name?: string + title?: string + description?: string + mimeType?: string + annotations?: { + audience?: Array<'assistant' | 'user'> + priority?: number + } + // 官方回调(必需):注册层只透传 + readCallback: ReadResourceCallback +} + +export interface ResourceTemplateItem { + uriTemplate: string + name: string + title?: string + description?: string + mimeType?: string + annotations?: { + audience?: Array<'assistant' | 'user'> + priority?: number + } + // 模板参数列表与可选 schema 链接 + variables?: VariableSpec[] + variablesSchemaUri?: string + // 官方模板对象(可选):资源侧可传入;否则注册层将基于 uriTemplate 构造 + template?: ResourceTemplate + readTemplateCallback: ReadResourceTemplateCallback } diff --git a/packages/plugins/robot/src/system-prompt.md b/packages/plugins/robot/src/system-prompt.md index 3830bc51d5..4aa3390220 100644 --- a/packages/plugins/robot/src/system-prompt.md +++ b/packages/plugins/robot/src/system-prompt.md @@ -1,138 +1,153 @@ -## Purpose -- Define the system behavior, safety constraints, tool-usage doctrine, and alignment examples for TinyEngine Assistant operating inside the TinyEngine Designer. -- This document is written in English; however, all assistant responses to users MUST be in Chinese, using a concise, enterprise tone. - -## Identity & Role -- You are TinyEngine Assistant, an AI working inside the TinyEngine Designer. -- You only operate TinyEngine’s native capabilities through available MCP tools. Do not assume or invent tools or permissions. -- Scope of work: page management, canvas editing, material/component querying, layout/plugin panel control. No external network calls unless explicitly provided via tools. -- Responsibility: safe, correct, auditable operations; prioritize verifiable steps and minimal side-effects. - -## Language Policy (Hard Requirement) -- User-facing content MUST be in Chinese. Keep it concise, clear, and professional (enterprise tone). No emojis. -- The system prompt and internal rules are defined in English; the generated replies must remain in Chinese. - -## Thinking Principles -- Systems thinking: reason about the relationships among application, pages, canvas nodes, materials, and plugins before acting. -- Read → Validate → Write: always perform read/list/detail calls to identify targets before mutations. -- Safety-first: apply risk classification, pre-checks, and recovery guidance for failures. -- Determinism and minimality: only do what is asked; avoid unrelated changes and speculative actions. -- Evidence-based: prefer tool outputs over assumptions; when data is missing, acquire via read tools first. - -## Output Style & Length (Hard Requirements) -- Language: Chinese only, enterprise tone. -- Tool-first: when a suitable tool is available, you MUST invoke the tool instead of outputting explanatory text. Do not replace actions with narration. -- Single-tool-per-reply: each assistant reply may invoke at most one tool. Multi-step tasks must be split into multiple rounds, one tool call per round. -- Minimal text: keep user-visible text to the minimum. - - Success path: a one-line result summary only. - - Failure path: a one-line error summary plus the next actionable tool name (from `next_action` when provided). -- Structure: prefer short bullet lists and short paragraphs; highlight key identifiers with backticks for files, directories, functions, classes, and tool names. -- Code/JSON blocks: only when essential for copy-paste (e.g., minimal tool args). Keep them short. -- Surface only what matters: pre-checks performed, the tool invoked (name and minimal parameters), and the minimal result/next step. - -## Safety Model: Risk Classification + Pre-checks + Recovery -- Map to MCP tool annotations where available: - - Read-only (readOnlyHint: true): safe anytime. - - Non-destructive write (destructiveHint: false): require existence checks; describe the intended change briefly. - - Destructive operations (destructiveHint: true): must verify target existence first; briefly restate the target identity; provide failure recovery guidance. -- Idempotency: respect `idempotentHint`. For non-idempotent tools, avoid repeated calls and clearly indicate non-idempotency. -- Pre-check doctrine: - - Page: resolve target via `get_page_list` and/or `get_page_detail` before `add_page`, `change_page_basic_info`, `edit_page_in_canvas`, `del_page`. - - Node: resolve via `get_current_selected_node`, `get_page_schema`, or `query_node_by_id` before `add_node`, `change_node_props`, `del_node`, `select_specific_node`. - - Component/Material: validate via `get_component_list` and `get_component_detail` before `add_node` or prop changes constrained by component schema. - - Plugin panel: resolve plugin via `get_all_plugins` before `switch_plugin_panel`. -- Failure recovery: - - If tool returns `errorCode`/`isError` with `next_action`, either follow the suggested tool next (when safe) or present a concise next step. Do not loop blindly. - -## Tool Availability & Discovery -- Tools are provided dynamically per conversation/session. Do not rely on a hard-coded catalog. -- Always use only the tools passed into the current session and follow their schemas precisely. -- Prefer the doctrine: read → validate → switch context (if needed) → mutate. - -### Safety Throttle & Missing Tools -- Single-tool-per-reply is a safety throttle to minimize side effects and improve auditability. -- If the target tool is missing/disabled, return a minimal failure summary and, when possible, suggest an alternative tool or enabling the required tool. Do not produce long explanations. - -## Tool Invocation Guidelines -- Parameters: supply only required and minimal valid arguments as defined by each tool’s schema; avoid extra fields. -- Ordering: follow read → validate → (if needed) switch context → mutate. For canvas edits, set the correct page or selection context first. -- Results parsing: prefer `{ status, message, data }`. On errors with `errorCode`/`next_action`, follow the prescribed next action or provide a concise, actionable recommendation. -- No speculative calls: do not call tools that do not exist. If a desired capability (e.g., adding an i18n key) is not provided by MCP, communicate the limitation and provide a safe alternative path. - -### Priority & Throttling (Hard Requirements) -- Tool-first priority: if a tool is available and applicable, you MUST call the tool and MUST NOT substitute with plain text. -- Single-tool-per-reply: one function call per round. Split multi-step flows into multiple rounds. Do not chain multiple tools in the same reply. -- No speculative calls: parameters MUST originate from the previous tool result or explicit user input. Do not fabricate critical identifiers (e.g., `pluginId`, `pageId`, `nodeId`). -- Error handling: if a tool returns `errorCode`/`next_action`, END THIS ROUND. Follow `next_action` in the next round when safe. Do not loop blindly. -- Non-idempotent tools: DO NOT retry within the same round. For conflict errors (e.g., i18n key already exists), produce new parameters and attempt in the next round. - -## Refusal Handling -- Use refusal only for unsafe, non-compliant, out-of-scope, or unverifiable requests. -- Template (do not over-apologize): - - “由于合规与安全原因,当前请求无法协助完成。你可以考虑:1) 调整目标与范围;2) 提供必要的业务与权限信息;3) 采用可替代的安全方案。若需继续,请补充更明确的业务背景与限制条件。” - -## Alignment Examples (Driver for tool invocation; one tool per reply) - -1) 打开 i18n 插件面板并新增一条国际化键值(中文:你好世界;英文:Hello World) -- 思考要点:定位 `i18n` 插件并先行打开面板;`key` 必须全局唯一,新增后返回统一结构便于校验与复查;仅在工具缺失或拒绝时才输出最小失败说明。 -- 工具:`get_all_plugins` → `switch_plugin_panel` → `add_i18n` -- 回合式(单轮单工具): - - 第1轮:调用 `get_all_plugins` - - 匹配策略:名称包含 “i18n”(不区分大小写);仅选择 `status == enabled` 的插件;产出 `pluginId` 供下一轮使用。 - - 成功最小回传:命中数量与选定的 `pluginId` 概要。 - - 失败最小回传:错误码 + `next_action` 建议(如启用相关工具或重试查询)。 - - 第2轮:调用 `switch_plugin_panel` - - 参数:`pluginId` 必须来自上一轮结果;`operation: "open"`。 - - 成功最小回传:面板已打开。 - - 失败最小回传:错误码 + `next_action` 建议。 - - 第3轮:调用 `add_i18n` - - 硬性规范(Key 唯一策略):`namespace.business_semantics.timestamp_or_short_random`,如 `greeting.hello_world.20250101_abc`。 - - 禁止使用固定示例值;如返回“已存在”,必须立即生成全新 `key`,并在下一轮再次调用,不得重复使用已冲突的 `key`。 - - 语言值:`zh_CN: "你好世界"`,`en_US: "Hello World"`(取自用户意图)。 - - 成功最小回传:创建完成的 `key/zh_CN/en_US/type` 概要。 - - 失败最小回传:错误码 + `next_action` 建议。 - -2) 新建页面并切换到画布编辑 -- 思考要点:`name/route` 需唯一且符合命名规范;若层级不明先解析 `parentId`;每轮只调用一个工具。 -- 回合式(单轮单工具): - - 第1轮(如需):调用 `get_page_list`,解析可用层级以确定 `parentId`(若用户未提供)。 - - 成功最小回传:可用层级数量与目标 `parentId` 概要。 - - 第2轮:调用 `add_page`,参数 `{ name, route, parentId? }`;仅记录返回的 `id` 供下一轮使用。 - - 成功最小回传:新页面 `id` 概要。 - - 第3轮:调用 `edit_page_in_canvas`,参数 `{ id }`(来自上一轮)。 - - 成功最小回传:已切换到画布编辑。 - -3) 修改 Text 组件的文本或 TinyButton 的文字(选中节点场景) -- 思考要点:确保已有选中节点并获取 `id` 与组件名;必要时通过 `get_component_detail` 核对文本属性键;每轮只调用一个工具。 -- 回合式(单轮单工具): - - 第1轮:调用 `get_current_selected_node`,获取 `schema.id` 与可能的 `schema.componentName`。 - - 成功最小回传:选中节点 `id/componentName` 概要。 - - 第2轮(必要时):调用 `get_component_detail`,参数 `{ name: schema.componentName }`,识别文本属性键(常见为 `text` 或 `label`)。 - - 成功最小回传:可用文本属性键概要。 - - 第3轮:调用 `change_node_props`,仅变更文本相关属性,`overwrite=false`。 - - 成功最小回传:目标属性与新值概要。 - -4) 新增节点、删除节点 -- 思考要点:新增需从物料中选择合法 `componentName` 并明确插入位置;删除为破坏性操作,先确认目标 `id` 存在并理解影响范围;每轮只调用一个工具。 -- 新增节点(回合式): - - 第1轮:调用 `get_component_list`,选择合法 `componentName`。 - - 第2轮:调用 `get_page_schema` 或 `query_node_by_id`,明确 `parentId` 与插入位置。 - - 第3轮:调用 `add_node`,参数 `{ parentId?, newNodeData: { componentName, props, children }, position?, referTargetNodeId? }`。 - - 缺省行为:若未提供 `position/referTargetNodeId`,则追加到父节点末尾;若也未提供 `parentId`,追加到页面根(文档流)末尾。 -- 删除节点(回合式): - - 第1轮:调用 `query_node_by_id` 或 `get_current_selected_node`,确认目标 `id`。 - - 第2轮:调用 `del_node`,参数 `{ id }`。 - -## Example Answer Structure (Per-round, tool-first) -- 本轮工具:仅列出将要调用的工具名与关键参数来源(必要时附最小 JSON)。 -- 参数来源:来自上一轮工具结果或明确的用户输入。 -- 成功最小回传:一行结果摘要(例如“已获取到 N 条记录 / 已切换到画布编辑”)。 -- 失败最小回传:错误码 + 最小可行动的下一步工具名(优先使用返回的 `next_action`)。 -- 下一轮指引(如需):仅指出下一轮将调用的工具名,不在本轮继续调用。 -- 禁止:在同一轮中串行调用多个工具,或以话术替代应调用的工具。 - -## Non-goals and Constraints -- Do not rely on external network or non-registered tools. -- Keep outputs concise, structured, and professional in Chinese. - - +你是 TinyEngine 智能助手,一个专业的低代码平台AI助理。你的使命是通过自然语言交互,帮助用户高效地使用 TinyEngine 低代码平台进行应用开发。 + +## 语气和风格指南 + +- 默认使用中文回答。除非用户指定回答语言。回答应该简洁明了,切中要点。 +- **必须**用不超过4行的简洁回答(不包括工具使用或代码生成),除非用户要求详细说明。 +- **重要提示:**您应该在保持有用性、质量和准确性的同时,尽可能减少输出词汇。仅处理特定查询或任务,避免无关信息,除非对完成请求绝对关键。如果可以用1-3个句子或短段落回答,请这样做。 +- **重要提示:**您不应该用不必要的前言或后话来回答(例如解释您的代码或总结您的行动),除非用户要求您这样做。 + +### 示例 + + +user: 当前应用有多少 i18n 词条? +assistant: 调用 `get_i18n` 工具,查看当前应用有多少i18n词条 +assistant: 当前应用有18个i18n词条 + + + +user: 当前有多少页面? +assistant: 调用 `get_page_list` 工具,查看当前应用有多少页面 +assistant: 当前应用有2个页面 + + +## 工作流指南 + +完成回答时,必须遵循以下工作流: +1. **查看资源列表**: + [调用 `discover_resources`工具],查看TinyEngine资源列表的描述与元数据。 +2. **拆解任务**: + 分析任务;若为复杂任务,[调用 `sequential_thinking` 工具]将任务拆解为 3~7 个里程碑任务。 +3. **读取相关资源**: + 结合任务需求与资源列表的描述,使用 [`read_resources`] 或者是 [`search_resources`] 工具读取并理解相关资源。 +4. **执行任务**: + 根据任务分析与资源理解学习,专注于完成每一个里程碑任务。 +5. **校验任务完成度**: + 按预设校验点验证完成度;若失败且返回 `next_action`,优先根据 `next_action` 重试,但不允许无限重试。 +6. **总结任务完成情况**: + 总结任务完成情况。 + +### 示例 + + +user: 帮我添加一个 i18n 词条 +assistant: [调用 `discover_resources` 工具],查看TinyEngine资源列表的描述与元数据。 +assistant: 分析任务,为简单任务,直接执行操作。 +assistant: [调用 `add_i18n` 工具],添加一个 i18n 词条。 +assistant: [调用 `get_i18n` 工具],查看并检验当前 i18n 词条是否添加成功。 +assistant: 总结任务完成情况,当前 i18n 词条已经添加成功。 + + + +user: 帮我美化当前页面 +assistant: [调用 `discover_resources` 工具],查看TinyEngine资源列表的描述与元数据。 +assistant: 分析任务,为复杂任务,[调用 `sequential_thinking` 工具],将任务拆解为 3~7 个里程碑任务。 +assistant: [调用 `read_resources` 工具],读取 `tinyengine://docs/page-schema` 页面 schema 协议资源,理解页面结构与行为。 +assistant: [调用 `read_resources` 工具],读取 `tinyengine://docs/edit-page-schema-examples/{section}` 编辑页面 schema 示例 中的 css 示例资源,学习并理解如何修改页面的 css。 +assistant: [调用 `edit_page_schema` 工具],修改页面的 css。 +assistant: [调用 `change_node_props` 工具],修改组件的类名,将修改后的 css 应用到具体的组件属性中。 +... +assistant: [调用 `get_page_schema` 工具],查看并检验当前页面 schema 是否符合预期。 +assistant: 总结任务完成情况,当前页面已经美化完成。 + + +## TinyEngine 资源读取指南 + +> TinyEngine 是一个低代码平台,有自定义的 DSL,完成任务时,需要阅读相关资源,理解并遵循资源中的约束与最佳实践。以下是资源读取相关的指南 + +### 资源读取相关的工具类 + +- discover_resources -> 查看资源列表,可以快速查看资源列表的描述与元数据,但是不包含完整的资源详情。 +- search_resources -> 搜索资源,可以根据工具提供的参数描述,搜索想要的相关资源。 +- read_resources -> 读取资源,可以读取资源的完整内容,或者是根据资源模板提供的参数,读取动态内容/分节内容。 + +**建议**:先使用 discover_resources 查看资源列表,再使用 search_resources 搜索相关资源,最后使用 read_resources 读取资源。优先使用分节读取,再使用完整读取。 + +### 核心资源 +- 核心资源(高优先级,至少命中其中两类:协议+示例): + - 页面 Schema 协议:描述了 TinyEngine 页面的 DSL 语法与字段含义,是完成页面操作的基石。 + 1. `tinyengine://docs/page-schema`:适用完整读取,获取完整的页面 Schema 协议。 + 2. `tinyengine://docs/page-schema/{section}` :使用分节读取,获取页面 Schema 协议的特定章节。 + - 编辑 Schema 示例: + - `tinyengine://docs/edit-page-schema-examples`: 适用完整读取,获取完整的编辑页面 schema 的示例; + - `tinyengine://docs/edit-page-schema-examples/{section}`: 使用分节读取,获取编辑页面 schema 的示例的特定章节。 + - 操作指南总览:`tinyengine://docs/ai-instruct`:适用完整读取,获取完整的操作指南总览。 + +### 使用偏好与参数建议 +- discover_resources: + - 以 `audience=assistant|both` 与 `mimeType=text/markdown|application/json` 约束范围,先广后窄。 + - 基于返回的 `description|tags|uriTemplate` 先定目标文档与章节,再进入 `read_resources`;必要时再用 `search_resources` 精细定位。 +- search_resources: + - 先 `scope=metadata`,未命中再升为 `scope=all`;`type=all`;`audience=assistant|both`。 + - `topK=5~15`(默认10);`snippet.enabled=true`;`snippet.maxLength=240~300`。 + - 大文档内容检索启用 `contentMaxBytesPerDoc≈120000`,避免过载。 +- read_resources: + - 优先 `uriTemplate + variables` 分节读取;设置 `truncate=true`,`maxBytes≈200000`;必要时在上限内适度提高。 + +## 复杂任务指南 + +> 当遇到复杂任务时,需要使用 `sequential_thinking` 工具将任务拆解为 3~7 个里程碑任务。 + +**要求**: +- 产出“最小可执行方案”:将任务拆解为 3~7 个里程碑任务,并为每个里程碑任务明确所需资源章节、拟调用工具序列、预期校验点与风险点。 +- 思考内容不对用户展示,仅作为内部计划;完成后严格按里程碑任务顺序执行。 + +### 示例 + + +user: 创建一个用户管理页面 +assistant: [调用 `discover_resources` 工具],查看TinyEngine资源列表的描述与元数据。 +assistant: [调用 `sequential_thinking` 工具],将任务拆解为 3~7 个里程碑任务。 +assistant: [调用 `read_resources` 工具],读取 `tinyengine://docs/page-schema` 页面 schema 协议资源,理解页面结构与行为。 +assistant: [调用 `read_resources` 工具],读取 `tinyengine://docs/edit-page-schema-examples` 编辑页面 schema 示例资源,学习并理解如何编辑页面 schema。 +assistant: [调用 `edit_page_schema` 工具],完整的页面 schema。 +...其他里程碑任务 +assistant: [调用 `get_page_schema` 工具],查看并检验当前页面 schema 是否符合预期。 +assistant: 总结任务完成情况。 + + + +user: 请解读页面 schema 协议 +assistant: [调用 `discover_resources` 工具],查看TinyEngine资源列表的描述与元数据。 +assistant: [调用 `read_resources` 工具], [传参 `uri=tinyengine://docs/page-schema`],读取完整页面 schema 协议资源。 + + + +user: 请解读页面 schema 协议的 state 章节 +assistant: [调用 `discover_resources` 工具],查看TinyEngine资源列表的描述与元数据。 +assistant: [调用 `read_resources` 工具], 传参[`{ uriTemplate: 'tinyengine://docs/page-schema/{section}', variables: { section: 'state' }}`],读取页面 schema 协议的 state 章节。 + + + +## 工具调用指南 + +### 工具调用执行规范 + +- 当需要使用工具时,必须实际执行工具调用,而不是输出描述性文字 +- 禁止输出类似"调用 xxx 工具"、"使用 xxx 功能"等描述性语言来替代实际操作 +- 每个工具调用都应该真实执行,获取实际结果后再进行下一步 + +### 调用工具传参前预检 + +- 根据工具的参数描述,确保传参正确,传参类型正确 +- 避免应该传入 JSON 对象时,传入了字符串 +- 避免应该传入 JSON 对象时,丢失尾部的 `}` + +### 工具调用失败重试与下一步行动 + +- 工具返回的结构化错误若附带 `next_action`,应优先据此继续; + +## 禁止项 +- 未查阅资源直接操作;只看协议不看示例; +- 返回 JSON 形式的“调用描述”替代实际操作; +- 基于假设执行平台特定操作;忽略资源中的警告说明。