diff --git a/designer-demo/public/mock/bundle.json b/designer-demo/public/mock/bundle.json index 3d03b34252..fb4449b409 100644 --- a/designer-demo/public/mock/bundle.json +++ b/designer-demo/public/mock/bundle.json @@ -13936,7 +13936,7 @@ "zh_CN": "表单" }, "screenshot": "", - "snippetName": "tiny-form", + "snippetName": "TinyForm", "icon": "form", "schema": { "componentName": "TinyForm", @@ -14220,7 +14220,7 @@ }, "icon": "grid", "screenshot": "", - "snippetName": "tinyGrid", + "snippetName": "TinyGrid", "schema": { "componentName": "TinyGrid", "props": { @@ -14302,7 +14302,7 @@ "zh_CN": "走马灯" }, "screenshot": "", - "snippetName": "tiny-carousel", + "snippetName": "TinyCarousel", "icon": "carousel", "schema": { "componentName": "TinyCarousel", diff --git a/designer-demo/src/composable/http/index.js b/designer-demo/src/composable/http/index.js index 633ccb1bc1..141c885c7b 100644 --- a/designer-demo/src/composable/http/index.js +++ b/designer-demo/src/composable/http/index.js @@ -16,6 +16,7 @@ const procession = { let loginVM = null const showError = (url, message) => { + if (message === 'canceled') return // 取消请求场景不报错 globalNotify({ type: 'error', title: '接口报错', diff --git a/docs/advanced-features/new-ai-plugin-usage.md b/docs/advanced-features/new-ai-plugin-usage.md index e4dafb2c2f..e2fb85d00e 100644 --- a/docs/advanced-features/new-ai-plugin-usage.md +++ b/docs/advanced-features/new-ai-plugin-usage.md @@ -1,41 +1,66 @@ -# 新版AI插件使用指南 +# TinyEngine AI插件使用指南 -随着TinyEngine低代码平台的不断升级,AI插件也迎来了重大更新。新版AI插件(v2.8以上版本)采用了全新的TinyRobot组件库界面,同时通过使用OpenTiny Next SDK 集成了MCP(Model Context Protocol)工具使用能力,使AI能够调用平台各插件提供的工具能力,实现更强大的自动化操作功能。 +随着TinyEngine低代码平台的不断升级,AI插件迎来了全面升级。新版AI插件集成了现代化的聊天界面,提供Agent模式和Chat模式双重体验,支持上传图片生成页面,支持MCP(Model Context Protocol)工具调用能力,让AI辅助开发更加智能、强大。 ## 一、功能概览 新版AI插件具备以下核心功能: -1. **全新UI界面**:采用TinyRobot组件库,提供更现代化的聊天体验,支持Markdown语法、支持全屏模式等 -2. **智能对话**:支持与AI进行自然语言交互,完成复杂任务 -3. **Next SDK 与 MCP工具集成**:可以调用平台插件提供的各种工具,如修改属性、修改样式、创建页面等 +### 1.1 全新Agent智能搭建模式,支持模式切换 + +**Agent模式(智能搭建)** +- 支持上传图片或自然语言搭建页面 +- 流式页面渲染,实时看到页面效果 +- 通过对话创建组件、调整样式、修改属性 +- 适合页面搭建、界面调整等场景 + +**Chat模式(智能对话)** +- 传统问答式交互体验 +- 支持代码生成、文档编写、技术咨询 +- 支持调用平台MCP工具完成特定任务 + +### 1.2 核心特性 + +- **现代化界面**:全新的聊天界面,支持Markdown渲染、代码高亮 +- **智能工具调用**:可以调用平台各种工具,如创建页面、修改组件等 +- **思考模式**:支持推理模型的深度思考,提供更准确的解决方案 +- **多模型支持**:兼容各种主流AI模型 +- **会话管理**:支持多个对话会话,自动保存历史记录 ## 二、界面介绍 ### 2.1 主界面 -在编辑器界面左下角插件栏,您可以看到 AI 助手的图标。点击图标即可打开主界面对话窗口。 +在编辑器界面右上角插件栏,您可以看到 AI 助手的图标。点击图标即可打开主界面对话窗口。 ![打开AI助手](./imgs/ai-assistant-open.png) 主界面包含以下元素: +- **顶部工具栏**:包含设置模型、会话管理、全屏切换等功能 - **欢迎区域**:显示AI助手的欢迎信息 - **提示项**:快速开始的常用问题示例 - **对话区域**:显示用户与AI的对话历史 -- **输入区域**:用户输入问题的地方 -- **MC工具按钮**:管理和配置MCP工具的入口 +- **输入区域**:用户输入问题区域、上传文件区域,同时包含模式切换、MCP工具、深度思考等按钮 ![AI插件主界面](./imgs/ai-assistant-interface.png) -### 2.2 设置 -点击顶部的设置图标,可以进行以下配置: -- 选择 AI 模型:支持多种大型语言模型 -- 设置 API Token:配置访问 AI 服务所需的认证信息 +### 2.2 模式切换 -![AI设置面板](./imgs/ai-assistant-settings.png) +新版AI插件支持两种工作模式: + +#### Agent模式(默认) +- **文件上传**:支持上传图片生成页面 +- **智能页面搭建**:AI可以直接修改页面Schema,实时更新画布 +- **实时预览**:修改即时生效,无需手动刷新 +- **适用场景**:页面搭建、组件配置、样式调整等 -注意:切换 AI 模型会开启新的会话。 +#### Chat模式 +- **对话交互**:传统的问答式对话体验 +- **工具调用**:支持调用MCP工具完成特定任务 +- **适用场景**:咨询问答、代码生成、文档编写等 + +可以通过底部的模式切换按钮在两种模式间切换。 ### 2.3 MCP工具管理 @@ -45,187 +70,478 @@ MCP工具管理面板允许用户: - 查看启用的MCP服务器 - 启用/禁用特定的MCP工具 -- 添加新的MCP服务器(即将开放) -## 三、基础使用流程 +### 2.4 深度思考 +启用推理模型的深度思考功能,AI会自动进行深度思考,提供更准确的解决方案。 + + +### 2.5 模型设置面板 + +点击顶部的设置图标,可以进入设置面板,包含以下两个配置页签: + +#### 模型选择 + +- **默认助手模型**:选择AI对话使用的主模型,支持按服务商筛选。模型列表会标注能力标签(工具调用、视觉理解) +- **快速模型**:用于代码补全、话题命名等场景,建议选择轻量模型(如flash类型或8b/14b模型)以获得更快响应 +- 选择模型后会显示当前模型所属的服务名称及API Key配置状态 + +#### 模型服务 + +- **查看服务列表**:展示所有已配置的模型服务,包含Base URL、模型数量、API Key状态 +- **配置内置服务**:为内置服务(如OpenAI、DeepSeek等)配置API Key +- **添加自定义服务**:支持添加兼容OpenAI格式的自定义模型服务,需配置: + - **服务名称**:自定义的服务标识 + - **Base URL**:API地址(支持末尾缺省`/chat/completions`,`#`结尾强制使用输入地址) + - **API Key**:访问认证信息 + - **模型配置**:添加一个或多个模型,设置模型名称、显示名称及能力(工具调用、视觉理解、快速模型) +- **编辑/删除服务**:可编辑自定义服务的全部配置,或删除自定义服务 + +![AI设置面板](./imgs/ai-assistant-settings.png) + +**注意**:切换AI模型会开启新的会话;若选择的模型未配置API Key,会提示前往模型服务页签进行配置。 + +### 2.6 会话管理 + +![会话管理](./imgs/ai-assistant-session-management.png) +用户会话会保存到浏览器缓存中,点击顶部的会话管理图标可以查看当前会话历史 + +会话管理面板允许用户: +- 查看当前会话历史 +- 点击会话可以切换当前会话(图标区分Agent与Chat模式) +- 删除特定的会话 + +## 三、快速开始 + +### 3.1 打开AI插件 + +在TinyEngine编辑器界面左下角插件栏,点击AI助手图标即可打开对话窗口。 + +### 3.2 首次配置 +首次使用时,需要进行简单配置: -### 3.1 设置模型接口 +1. **选择AI模型**:点击右上角设置按钮,选择合适的AI模型 +2. **配置API Token**:输入对应AI服务的API密钥 +3. **选择工作模式**:根据需要选择Agent模式或Chat模式 -1. [可选] 配置自定义的OpenAI兼容格式的大模型接口 +### 3.3 开始使用 -通过AI插件的`customCompatibleAIModels`选项,支持自定义添加OpenAI兼容格式大模型 +配置完成后,您可以: +- **直接对话**:在输入框中输入问题,按回车发送 +- **使用提示**:点击界面上的快速提示开始对话 +- **上传文件**:在Agent模式下可以上传图片生成页面 +- **切换模式**:随时在Agent和Chat模式间切换 + +### 3.4 进阶配置(可选) + +对于开发者,可以进行更多自定义配置: +通过在注册表中添加options配置项,可以配置AI模型、上下文功能开关等。 ```javascript // registry.js -[META_APP.Robot]: { - options: { - customCompatibleAIModels: [ +export default { + // ...... + [META_APP.Robot]: { + options: { + // enableResourceContext: false, // 提示词上下文携带资源插件图片,默认true + // enableRagContext: true, // 提示词上下文携带查询到的知识库内容,默认false + // customCompatibleAIModels: [] // 自定义AI模型(OpenAI兼容格式) + } + }, + // ...... +} +``` + + +#### 自定义AI模型 +```javascript +// 在项目配置中添加OpenAI兼容格式自定义模型,也支持删除内置模型服务和模型 +customCompatibleAIModels: [ + // ==================== 示例 1:删除整个服务 ==================== + // 删除 DeepSeek 服务 + { + provider: 'deepseek', + _remove: true + }, + + // ==================== 示例 2:修改现有服务 ==================== + // 修改阿里云百炼服务:删除部分模型 + 添加新模型 + 覆盖配置 + { + provider: 'qwen', + // 可选:修改服务的显示名称 + label: '阿里云百炼 (自定义)', + // 可选:修改服务的 baseUrl + // baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + models: [ + // 删除不需要的模型 + { name: 'qwen-turbo', _remove: true }, + { name: 'qwen-plus', _remove: true }, + + // 添加新模型 + { + label: 'Qwen Max', + name: 'qwen-max', + capabilities: { + toolCalling: true, + vision: true, + reasoning: true + } + }, + + // 覆盖已有模型的配置 { - label: 'DeepSeek', - value: 'https://api.deepseek.com/v1', - model: [ - { label: 'deepseek-chat', value: 'deepseek-chat'}, - { label: 'deepseek-reasoner', value: 'deepseek-reasoner'} - ] + label: 'Qwen2.5-72B (推荐)', + name: 'qwen2.5-72b-instruct', + capabilities: { + toolCalling: true, + vision: false, + reasoning: false, + compact: false + } + } + ] + }, + + // ==================== 示例 3:添加全新的 OpenAI 服务 ==================== + { + provider: 'openai', // AI模型提供商 + label: 'OpenAI', // AI模型名称 + baseUrl: 'https://api.openai.com/v1', // AI模型API地址 + allowEmptyApiKey: false, // 是否允许API Token为空 + models: [ + { + label: 'GPT-4o', // AI模型名称 + name: 'gpt-4o', // AI模型名称 + capabilities: { + toolCalling: true, // 是否支持工具调用 + vision: true, // 是否支持视觉理解 + reasoning: true // 是否支持推理 + } + }, + { + label: 'GPT-4o Mini', + name: 'gpt-4o-mini', + capabilities: { + toolCalling: true, + vision: true, + compact: true + } + }, + { + label: 'o1-preview', + name: 'o1-preview', + capabilities: { + toolCalling: false, + vision: false, + reasoning: true + } } ] } -}, +] ``` -2. 配置本地的MCP工具调用使用的AI模型接口Proxy, 方便本地调试,以百炼为例: +#### 自定义Agent模式上下文功能 + +- enableResourceContext: 该参数配置是否在提示词上下文携带资源插件图片,AI会在生成的页面中自动合适的图片资源,默认开启 +- enableRagContext: 该参数配置是否在提示词上下文携带查询到的知识库内容,可以根据场景在后端知识库中添加文档知识,以优化AI的生成效果,默认关闭 + + +#### 其他配置 +- encryptServiceApiKey: 该参数配置是否加密服务API密钥 +- modeImplementation: 该参数配置Agent模式/Chat模式的实现,支持自定义Agent模式/Chat模式的实现 + + +#### 本地开发代理(可选) ```javascript -// vite.config.js -const originProxyConfig = baseConfig.server.proxy -baseConfig.server.proxy = { +// vite.config.js - 用于本地开发调试 +proxy: { '/app-center/api/chat/completions': { - target: 'https://dashscope.aliyuncs.com', + target: 'https://api.deepseek.com', changeOrigin: true, - rewrite: path => path.replace('/app-center/api/', '/compatible-mode/v1/'), + rewrite: path => path.replace('/app-center/api/chat/completions', '/v1/chat/completions') }, - ...originProxyConfig, + '/app-center/api/ai/chat': { + target: 'https://api.deepseek.com', + changeOrigin: true, + rewrite: path => path.replace('/app-center/api/ai/chat', '/v1/chat/completions') + } } ``` -### 3.2 打开AI插件 +## 四、开始对话 -点击设计器左下角的AI图标即可打开AI插件对话框 +### 4.1 Agent模式 -### 3.3 配置AI模型 +图片生成页面: +首先切换到Agent模式,然后点击上传图片按钮,上传您想要生成页面的图片。 -首次使用或切换AI模型时,需要进行配置: +![图片生成页面](./imgs/ai-assistant-image-to-page.png) -1. 点击右上角设置按钮 -2. 选择合适的AI模型 -3. 输入对应的API Token -### 3.4 开始对话 +自然语言生成页面: +切换到Agent模式,然后直接在输入框中输入您想要生成页面的描述,AI会根据您的描述生成页面。 -在输入框中输入问题,按回车或点击发送按钮即可开始对话 +![自然语言生成页面](./imgs/ai-assistant-text-to-page.png) -## 四、MCP工具使用 -### 4.1 MCP工具概览 +### 4.2 Chat模式 -MCP(Model Connector Protocol)是新版AI插件的核心功能之一。通过MCP,AI可以调用平台中各个插件提供的工具能力,例如: -- 创建新页面 -- 修改组件属性 -- 修改样式设置 -- 查询页面列表 -- 添加国际化内容 +Chat模式支持传统的问答式交互体验,AI会根据您的问题提供答案。同时支持调用MCP工具完成特定任务。 -### 4.2 启用MCP工具 +![Chat模式](./imgs/ai-assistant-chat.png) -1. 点击输入框左侧的"MCP"按钮打开工具管理面板 -2. 查看可用的MCP服务器和工具 -3. 启用需要的工具 +## 五、MCP工具使用 -![启用MCP工具](./imgs/ai-assistant-enable-mcp-tools.png) +### 5.1 什么是MCP工具 -### 4.3 使用MCP工具 +MCP(Model Context Protocol)工具让AI能够调用平台的各种功能,实现真正的智能操作: -当启用MCP工具后,AI在对话过程中会自动判断是否需要调用相关工具。例如: +- **页面管理**:创建、删除、修改页面 +- **组件操作**:添加、配置、调整组件 +- **样式设置**:修改CSS样式和布局 +- **数据查询**:获取项目信息、页面列表等 +- **资源管理**:处理图片、文件等资源 -1. 用户询问:"帮我创建一个用户列表页面" -2. AI识别需要调用"创建页面"工具 -3. 自动执行创建页面操作 -4. 返回操作结果给用户 +### 5.2 如何启用工具 -工具调用中(以查询天气Mock工具为例): -![MCP工具调用示例中](./imgs/ai-assistant-tool-execution1.png) -工具调用完成后会返回最终结果,也可以点击展开工具调用参数与结果: -![MCP工具调用示例结果示例](./imgs/ai-assistant-tool-execution2.png) +1. **打开工具面板**:点击输入框旁的"MCP"按钮 +2. **查看可用工具**:浏览平台提供的各种工具 +3. **启用所需工具**:勾选您需要使用的工具 +4. **开始使用**:AI会在需要时自动调用这些工具 -## 五、典型使用场景 +![MCP工具管理面板](./imgs/ai-assistant-mcp-tools-management.png) -### 5.1 页面搭建场景 (即将开放) +### 5.3 工具调用示例 -用户可以通过自然语言描述来生成页面: +AI会根据您的需求自动选择和调用合适的工具: +**示例对话:** ``` -用户:帮我创建一个包含用户信息表单的页面,需要有姓名、邮箱、手机号字段 -AI:好的,我将为您创建一个包含用户信息表单的页面... -[执行创建页面操作] -AI:已完成页面创建,您可以在页面列表中查看新创建的表单页面 +用户:帮我查看当前项目有哪些页面 +AI:我来为您查询项目中的页面信息... +[自动调用页面查询工具] +AI:当前项目包含以下页面:首页、用户管理、设置页面... ``` -### 5.2 组件属性修改场景 +**工具执行过程:** +- 🔄 AI识别需求并选择工具 +- ⚙️ 自动调用相应的平台功能 +- 📊 展示执行结果和详细信息 +- ✅ 可展开查看调用参数和返回值 + +![工具调用过程示例](./imgs/ai-assistant-tool-execution-process.png) + +## 六、典型使用场景 + +### 6.1 Agent模式 - 智能页面搭建 + +Agent模式让您可以通过对话直接操作页面,实时看到效果。 -用户可以通过对话修改画布中组件的属性: +#### 创建新组件 +``` +用户:在页面中添加一个用户信息表单,包含姓名、邮箱、手机号字段 +AI:页面正在生成中,请稍等片刻... +[实时更新页面布局] +[完成后插件界面会更新提示和状态] +``` + +#### 修改组件属性 +``` +用户:将页面中的按钮文字改为"提交表单",颜色设为主色 +AI:页面正在生成中,请稍等片刻... +[实时更新页面布局] +[完成后插件界面会更新提示和状态] +``` +#### 调整布局和样式 ``` -用户:将当前选中按钮的文字改为"提交",颜色改为蓝色 -AI:好的,我将为您修改按钮属性... -[执行修改属性操作] -AI:已完成按钮属性修改 +用户:调整表单布局,让字段按两列显示,添加一些间距 +AI:页面正在生成中,请稍等片刻... +[实时更新页面布局] +[完成后插件界面会更新提示和状态] ``` -### 5.3 样式调整场景 +### 6.2 Chat模式 - 智能对话交互 -用户可以通过自然语言调整组件样式: +Chat模式适合传统的问答式交互和工具调用。 +#### 代码生成和解释 ``` -用户:把页面标题的文字大小调整为24px,居中显示 -AI:好的,我将为您调整标题样式... -[执行修改样式操作] -AI:已完成标题样式调整 +用户:帮我写一个 Vue3 的表单验证组件 +AI:我来为您创建一个Vue3表单验证组件... +[返回完整的Vue3组件代码和使用说明] ``` -## 六、其他功能 +#### MCP工具调用 +``` +用户:查询当前项目的所有页面 +AI:我来查询项目中的页面信息... +[调用MCP工具获取页面列表] +AI:当前项目包含以下页面:... +``` + +### 6.3 思考模式 - 深度推理 + +对于支持推理能力的模型(如DeepSeek Reasoner),可以启用思考模式: + +``` +用户:设计一个复杂的电商购物车页面,需要考虑用户体验和性能优化 +AI:[开始深度思考] +让我仔细分析这个需求... +首先,电商购物车页面需要考虑以下几个方面: +1. 用户体验方面... +2. 性能优化方面... +3. 功能设计方面... +[经过深度思考后提供详细的设计方案] +[最后AI会提供一个完整的页面设计,包括组件、布局、样式等] +``` + +## 七、实用功能 + +### 7.1 会话管理 + +- **多会话支持**:可以同时进行多个不同主题的对话 +- **历史记录**:自动保存所有对话内容,随时查看 +- **智能标题**:根据对话内容自动生成有意义的标题 +- **快速搜索**:在历史对话中快速找到需要的内容 + +### 7.2 工具调用 + +- **自动识别**:AI会根据您的需求自动选择合适的工具 +- **可视化结果**:工具执行结果以易读的方式展示 +- **操作记录**:清晰记录每个工具的调用过程和参数 +- **错误重试**:当工具执行失败时,可以重新尝试 + +### 7.3 智能提示 + +- **快速开始**:提供常用场景的示例问题,点击即可使用 +- **上下文提示**:根据当前页面情况提供相关建议 +- **功能引导**:帮助新用户快速上手各项功能 + +### 7.4 界面体验 + +- **富文本支持**:支持Markdown格式,可以显示表格、列表、代码等 +- **代码高亮**:自动识别并高亮显示各种编程语言 +- **全屏模式**:可以将对话窗口放大到全屏,获得更好的使用体验 + +![AI插件全屏模式](./imgs/ai-assistant-fullscreen.png) + +## 八、注意事项 + +### 8.1 使用前准备 + +1. **保存提醒**:在使用Agent模式修改页面前,请确保当前页面已保存 +2. **网络要求**:需要稳定的网络连接支持流式数据传输 +3. **API配置**:确保正确配置了AI模型的API Token和基础URL +4. **浏览器兼容性**:建议使用现代浏览器,支持最新的Web标准 + +### 8.2 模式选择建议 + +- **Agent模式**:适用于页面搭建、组件修改、样式调整等直接操作场景 +- **Chat模式**:适用于咨询问答、代码生成、文档编写等对话场景 +- **思考模式**:仅在使用支持推理的模型时启用,会增加响应时间 + +### 8.3 性能优化 + +- **消息历史**:定期清理过多的对话历史以保持性能 +- **流式处理**:在网络较慢时可能出现延迟,属于正常现象 +- **工具调用**:复杂的工具调用可能需要较长时间,请耐心等待 + +## 九、故障排除 + +### 9.1 连接问题 + +**现象**:无法连接AI服务或请求超时 + +**解决方案**: +- 检查网络连接是否稳定 +- 确认API Token和基础URL配置正确 +- 检查代理配置是否正确(本地开发环境) +- 查看控制台网络请求错误信息 + +### 9.2 Agent模式问题 + +**现象**:页面更新失败或显示异常 + +**解决方案**: +- 检查当前页面是否已保存 +- 确认页面格式正确 +- 查看控制台错误信息 +- 尝试新建会话并创建新空白页面再重试 + +### 9.3 MCP工具问题 + +**现象**:MCP工具无法调用或调用失败 + +**解决方案**: +- 检查MCP工具管理面板中的工具状态 +- 查看控制台MCP服务错误信息 +- 尝试重新启用对应的工具 + +### 9.4 性能问题 -### 6.1 会话管理 +**现象**:界面卡顿或响应缓慢 -- **新建会话**:点击右上角的"新建会话"按钮可以开始新的对话 +**解决方案**: +- 清理过多的对话历史 +- 关闭不必要的浏览器标签页 +- 检查浏览器内存使用情况 +- 尝试刷新页面或重启浏览器 -### 6.2 提示项快速开始 +### 9.5 紧急恢复 -界面提供了几个提示项帮助用户快速开始: -- MCP工具使用 -- 页面搭建场景 -- 学习/知识型场景 +如果遇到严重问题,可以尝试以下步骤: -点击任一提示项可以快速发送对应的示例问题。 +1. **清除缓存**:清除浏览器缓存和LocalStorage +2. **重置配置**:在设置面板中重新配置AI模型 +3. **重启服务**:重启开发服务器(本地开发环境) +4. **联系支持**:如问题依然存在,请在[GitHub](https://github.com/opentiny/tiny-engine/issues)提交问题 -### 6.3 Markdown 支持 +## 十、总结 -AI 助手支持 Markdown 格式的消息渲染,可以更好地展示: -- 代码片段 -- 表格 -- 列表 -- 等其他富文本内容 +### 10.1 为什么选择新版AI插件 -### 6.4 全屏模式 +新版AI插件相比以往版本带来了显著改进: -点击右上角的展开按钮,可以进入全屏模式,获得更大的对话空间。 +**更强大的功能** +- 多模态支持,支持上传图片或者自然预研搭建页面 +- 双模式设计满足不同使用需求:既可以直接搭建页面,也可以进行技术咨询 +- 智能工具调用让AI能够实际操作平台功能,而不仅仅是对话 +- 思考模式提供更深入的分析和解决方案 -![全屏模式](./imgs/ai-assistant-fullscreen.png) +**更好的体验** +- 现代化的界面设计,支持富文本显示和代码高亮 +- 实时响应,无需等待,边说边看到结果 +- 完善的会话管理,不用担心丢失重要对话 -## 七、注意事项 +**更简单的使用** +- 无需复杂配置,开箱即用 +- 智能提示帮助快速上手 +- 支持多种AI模型,灵活选择 -1. **保存提醒**:在使用AI插件前,请确保当前页面或区块已保存 -2. **网络要求**:使用AI功能需要稳定的网络连接 -3. **API Token**:需要配置有效的API Token才能正常使用AI功能 -4. **MCP工具依赖**:部分MCP工具需要相应的插件支持才能正常工作 +### 10.2 实际价值 -## 八、故障排除 +使用新版AI插件,您可以: -### 8.1 无法连接AI服务 +- **大幅提升开发效率**:原本需要手动点击多次的操作,现在只需一句话 +- **降低学习成本**:新手也能快速上手,无需深入学习复杂功能 +- **减少出错概率**:AI辅助生成的代码更加规范和可靠 -- 检查网络连接是否正常 -- 确认API Token是否正确配置 -- 检查AI服务是否正常运行 +### 10.3 未来更新计划 -### 8.2 MCP工具无法使用 +我们正在不断改进和完善AI插件,即将推出: -- 确认相关插件是否已安装并启用 -- 检查MCP工具是否已启用 -- 查看控制台是否有错误信息 +**后续更新** +- 优化AI生成能力,生成更准确、更稳定的页面UI效果 +- 更好的组件支持 +- 结合模板实现更好的效果 +- 优化性能,让响应更快、体验更流畅 -### 8.3 功能异常 +**长期愿景** +- 应用级的代码生成 +- 更智能的代码生成和优化建议 -- 尝试刷新页面 -- 清除浏览器缓存 -- 联系技术支持 +--- -通过以上介绍,您应该能够熟练使用新版AI插件的各项功能。随着平台的不断发展,AI插件将会支持更多强大的功能,帮助您更高效地进行低代码开发。 +新版AI插件不仅仅是一个工具,更是您的智能开发助手。它将帮助您在TinyEngine平台上实现更高效、更智能的低代码开发体验。现在就开始使用吧! diff --git a/docs/extension-capabilities-tutorial/ai-plugin-configuration.md b/docs/extension-capabilities-tutorial/ai-plugin-configuration.md index 90b91e934d..18913de1e0 100644 --- a/docs/extension-capabilities-tutorial/ai-plugin-configuration.md +++ b/docs/extension-capabilities-tutorial/ai-plugin-configuration.md @@ -16,7 +16,7 @@ ### 前端代码改动 -在`tiny-engine/packages/plugins/robot/src/js/useRobot.ts`文件改动如下 +在`tiny-engine/packages/plugins/robot/src/composables/useRobot.ts`文件改动如下 ![](./imgs/ai-image23.png) diff --git a/packages/canvas/container/src/components/CanvasResize.vue b/packages/canvas/container/src/components/CanvasResize.vue index 820a0ecf47..32b11e90a0 100644 --- a/packages/canvas/container/src/components/CanvasResize.vue +++ b/packages/canvas/container/src/components/CanvasResize.vue @@ -116,6 +116,8 @@ export default { watch(() => useLayout().rightFixedPanelsStorage.value, setScale, { flush: 'post' }) + watch(() => useLayout().layoutState.toolbars.render, setScale, { flush: 'post' }) + watch( () => useLayout().getSettingState().render, (value) => { diff --git a/packages/common/js/completion.js b/packages/common/js/completion.js index 2ce64a8e08..d8a1aee624 100644 --- a/packages/common/js/completion.js +++ b/packages/common/js/completion.js @@ -10,14 +10,7 @@ * */ import { ref } from 'vue' -import { - useCanvas, - useResource, - useRobot, - getMergeMeta, - getMetaApi, - META_SERVICE -} from '@opentiny/tiny-engine-meta-register' +import { useCanvas, useResource, getMergeMeta, getMetaApi, META_SERVICE } from '@opentiny/tiny-engine-meta-register' import completion from './completion-files/context.md?raw' const keyWords = [ @@ -195,7 +188,7 @@ const generateBaseReference = () => { } const fetchAiInlineCompletion = (codeBeforeCursor, codeAfterCursor) => { - const { completeModel, apiKey, baseUrl } = useRobot().robotSettingState?.selectedModel || {} + const { completeModel, apiKey, baseUrl } = getMetaApi(META_SERVICE.Robot).getSelectedQuickModelInfo() || {} if (!completeModel || !apiKey || !baseUrl) { return } @@ -218,7 +211,7 @@ const fetchAiInlineCompletion = (codeBeforeCursor, codeAfterCursor) => { { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}` + Authorization: `Bearer ${apiKey || ''}` } } ) diff --git a/packages/configurator/src/select-icon-configurator/SelectIconConfigurator.vue b/packages/configurator/src/select-icon-configurator/SelectIconConfigurator.vue index 231506f04d..42cd457444 100644 --- a/packages/configurator/src/select-icon-configurator/SelectIconConfigurator.vue +++ b/packages/configurator/src/select-icon-configurator/SelectIconConfigurator.vue @@ -54,11 +54,11 @@ export default { iconSearchValue: '', icon: { name: props.modelValue, - component: props.modelValue && SvgICons[props.modelValue]() + component: props.modelValue && SvgICons[props.modelValue]?.() }, defaultIcon: { name: props.modelValue, - component: props.modelValue && SvgICons[props.modelValue]() + component: props.modelValue && SvgICons[props.modelValue]?.() } }) diff --git "a/packages/design-core/assets/\347\256\255\345\244\264-\345\220\221\345\267\246.svg" b/packages/design-core/assets/back.svg similarity index 100% rename from "packages/design-core/assets/\347\256\255\345\244\264-\345\220\221\345\267\246.svg" rename to packages/design-core/assets/back.svg diff --git a/packages/design-core/assets/intelligent-construction.svg b/packages/design-core/assets/intelligent-construction.svg index e81d17759f..d4d2df3d39 100644 --- a/packages/design-core/assets/intelligent-construction.svg +++ b/packages/design-core/assets/intelligent-construction.svg @@ -1,13 +1,4 @@ - - - Created with Pixso. - - - - - - - - - + + + diff --git a/packages/layout/src/DesignSettings.vue b/packages/layout/src/DesignSettings.vue index 5458365d62..7bbe2c29bd 100644 --- a/packages/layout/src/DesignSettings.vue +++ b/packages/layout/src/DesignSettings.vue @@ -201,7 +201,6 @@ export default { display: flex; flex-direction: column; position: absolute; - top: var(--base-top-panel-height); right: var(--base-nav-panel-width); z-index: 999; diff --git a/packages/layout/src/Main.vue b/packages/layout/src/Main.vue index b8ff93568e..0a3cd64899 100644 --- a/packages/layout/src/Main.vue +++ b/packages/layout/src/Main.vue @@ -154,6 +154,7 @@ export default { display: flex; flex-flow: row nowrap; z-index: 4; + position: relative; } :deep(.monaco-editor .suggest-widget) { border-width: 0; diff --git a/packages/layout/src/composable/useLayout.ts b/packages/layout/src/composable/useLayout.ts index f5958f0c09..6f4a943d6c 100644 --- a/packages/layout/src/composable/useLayout.ts +++ b/packages/layout/src/composable/useLayout.ts @@ -68,6 +68,7 @@ export interface ILayoutState { settings: ISettings toolbars: { visiblePopover: boolean + render: string } pageStatus: any } @@ -133,7 +134,8 @@ const layoutState = reactive({ showDesignSettings: true }, toolbars: { - visiblePopover: false + visiblePopover: false, + render: '' }, pageStatus: { state: '', diff --git a/packages/plugins/materials/src/composable/useMaterial.ts b/packages/plugins/materials/src/composable/useMaterial.ts index d5987dc2ac..74dd150ce5 100644 --- a/packages/plugins/materials/src/composable/useMaterial.ts +++ b/packages/plugins/materials/src/composable/useMaterial.ts @@ -506,6 +506,7 @@ const getComponentList = () => { const getComponentDetail = (name) => { const data = resource.get(name) + if (!data) return null const props = data.schema.properties .map((item) => { diff --git a/packages/plugins/resource/src/ResourceList.vue b/packages/plugins/resource/src/ResourceList.vue index 7fbbf51c44..e384382451 100644 --- a/packages/plugins/resource/src/ResourceList.vue +++ b/packages/plugins/resource/src/ResourceList.vue @@ -427,6 +427,7 @@ export default { const params = addSourceData.value.map((item) => { return { name: item.name, + description: item.description || '', resourceGroupId: state.group.id, resourceData: item?.resourceData || '', resourceUrl: item?.resourceUrl || '', diff --git a/packages/plugins/robot/assets/failed.svg b/packages/plugins/robot/assets/failed.svg new file mode 100644 index 0000000000..2c96071046 --- /dev/null +++ b/packages/plugins/robot/assets/failed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/plugins/robot/assets/success.svg b/packages/plugins/robot/assets/success.svg new file mode 100644 index 0000000000..1572769068 --- /dev/null +++ b/packages/plugins/robot/assets/success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/plugins/robot/index.ts b/packages/plugins/robot/index.ts index ef4fba0da0..dd4fb6e549 100644 --- a/packages/plugins/robot/index.ts +++ b/packages/plugins/robot/index.ts @@ -14,10 +14,12 @@ import entry from './src/Main.vue' import metaData from './meta' import './src/styles/vars.less' import '@opentiny/tiny-robot/dist/style.css' -import { RobotService } from './src/js/index' +import { RobotService } from './src/metas' export default { ...metaData, entry, metas: [RobotService] } + +export * from './src/types' diff --git a/packages/plugins/robot/meta.js b/packages/plugins/robot/meta.js index ee2bbb126d..740dc50506 100644 --- a/packages/plugins/robot/meta.js +++ b/packages/plugins/robot/meta.js @@ -6,6 +6,15 @@ export default { icon: { default: 'AI' }, - renderType: 'icon' + renderType: 'icon', + customCompatibleAIModels: [], // 模型配置 + enableResourceContext: true, // 提示词上下文携带资源插件图片 + enableRagContext: false, // 提示词上下文携带查询到的知识库内容 + encryptServiceApiKey: false, // 是否加密服务API密钥 + modeImplementation: { + // 支持通过注册表传入chat和agent模式的实现 + // chat: useCustomChatMode + // agent: useCustomAgentMode + } } } diff --git a/packages/plugins/robot/package.json b/packages/plugins/robot/package.json index c050b84229..ede5665e9e 100644 --- a/packages/plugins/robot/package.json +++ b/packages/plugins/robot/package.json @@ -25,18 +25,19 @@ "license": "MIT", "homepage": "https://opentiny.design/tiny-engine", "dependencies": { - "@opentiny/tiny-engine-meta-register": "workspace:*", "@opentiny/tiny-engine-common": "workspace:*", + "@opentiny/tiny-engine-meta-register": "workspace:*", "@opentiny/tiny-engine-utils": "workspace:*", - "@opentiny/tiny-robot": "0.3.0-rc.0", - "@opentiny/tiny-robot-kit": "0.3.0-rc.0", - "@opentiny/tiny-robot-svgs": "0.3.0-rc.0", + "@opentiny/tiny-robot": "0.3.0", + "@opentiny/tiny-robot-kit": "0.3.0", + "@opentiny/tiny-robot-svgs": "0.3.0", "@opentiny/tiny-schema-renderer": "1.0.0-beta.6", - "fast-json-patch": "~3.1.1", + "@vueuse/core": "^9.13.0", "dompurify": "^3.0.1", + "fast-json-patch": "~3.1.1", "highlight.js": "^11.11.1", - "markdown-it": "^14.1.0", - "jsonrepair": "3.13.0" + "jsonrepair": "3.13.0", + "markdown-it": "^14.1.0" }, "devDependencies": { "@opentiny/tiny-engine-vite-plugin-meta-comments": "workspace:*", diff --git a/packages/plugins/robot/src/BuildLoadingRenderer.vue b/packages/plugins/robot/src/BuildLoadingRenderer.vue deleted file mode 100644 index 7643b888de..0000000000 --- a/packages/plugins/robot/src/BuildLoadingRenderer.vue +++ /dev/null @@ -1,58 +0,0 @@ -
- loading -
-
页面生成中,请稍等片刻
-
{{ renderContent }}
-
-
- - - - - diff --git a/packages/plugins/robot/src/Main.vue b/packages/plugins/robot/src/Main.vue index 05cb6c1e06..97d5ad373c 100644 --- a/packages/plugins/robot/src/Main.vue +++ b/packages/plugins/robot/src/Main.vue @@ -8,1005 +8,285 @@ > -
- + + - - diff --git a/packages/plugins/robot/src/icon-prompt/page-icon.vue b/packages/plugins/robot/src/components/icons/page-icon.vue similarity index 94% rename from packages/plugins/robot/src/icon-prompt/page-icon.vue rename to packages/plugins/robot/src/components/icons/page-icon.vue index ae82278def..bd54f58f23 100644 --- a/packages/plugins/robot/src/icon-prompt/page-icon.vue +++ b/packages/plugins/robot/src/components/icons/page-icon.vue @@ -7,8 +7,6 @@ xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" > - Created with Pixso. - - - diff --git a/packages/plugins/robot/src/icon-prompt/study-icon.vue b/packages/plugins/robot/src/components/icons/study-icon.vue similarity index 93% rename from packages/plugins/robot/src/icon-prompt/study-icon.vue rename to packages/plugins/robot/src/components/icons/study-icon.vue index b851005195..40a0d1245a 100644 --- a/packages/plugins/robot/src/icon-prompt/study-icon.vue +++ b/packages/plugins/robot/src/components/icons/study-icon.vue @@ -7,8 +7,6 @@ xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" > - Created with Pixso. - - - diff --git a/packages/plugins/robot/src/components/renderers/AgentRenderer.vue b/packages/plugins/robot/src/components/renderers/AgentRenderer.vue new file mode 100644 index 0000000000..41400accaf --- /dev/null +++ b/packages/plugins/robot/src/components/renderers/AgentRenderer.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/packages/plugins/robot/src/components/renderers/ImgRenderer.vue b/packages/plugins/robot/src/components/renderers/ImgRenderer.vue new file mode 100644 index 0000000000..76de912b25 --- /dev/null +++ b/packages/plugins/robot/src/components/renderers/ImgRenderer.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/packages/plugins/robot/src/components/renderers/LoadingRenderer.vue b/packages/plugins/robot/src/components/renderers/LoadingRenderer.vue new file mode 100644 index 0000000000..86f4891786 --- /dev/null +++ b/packages/plugins/robot/src/components/renderers/LoadingRenderer.vue @@ -0,0 +1,3 @@ + diff --git a/packages/plugins/robot/src/mcp/MarkdownRenderer.vue b/packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue similarity index 100% rename from packages/plugins/robot/src/mcp/MarkdownRenderer.vue rename to packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue diff --git a/packages/plugins/robot/src/components/renderers/index.ts b/packages/plugins/robot/src/components/renderers/index.ts new file mode 100644 index 0000000000..c0ff88e1d4 --- /dev/null +++ b/packages/plugins/robot/src/components/renderers/index.ts @@ -0,0 +1,4 @@ +export { default as LoadingRenderer } from './LoadingRenderer.vue' +export { default as ImgRenderer } from './ImgRenderer.vue' +export { default as AgentRenderer } from './AgentRenderer.vue' +export { default as MarkdownRenderer } from './MarkdownRenderer.vue' diff --git a/packages/plugins/robot/src/composables/core/pageUpdater.ts b/packages/plugins/robot/src/composables/core/pageUpdater.ts new file mode 100644 index 0000000000..c82db51355 --- /dev/null +++ b/packages/plugins/robot/src/composables/core/pageUpdater.ts @@ -0,0 +1,73 @@ +import { jsonrepair } from 'jsonrepair' +import * as jsonpatch from 'fast-json-patch' +import { utils } from '@opentiny/tiny-engine-utils' +import { useCanvas, useHistory } from '@opentiny/tiny-engine-meta-register' +import { useThrottleFn } from '@vueuse/core' +import useModelConfig from './useConfig' +import { ChatMode } from '../../types/mode.types' +import { fixMethods, schemaAutoFix, getJsonObjectString, isValidFastJsonPatch, jsonPatchAutoFix } from '../../utils' + +const { deepClone } = utils + +const logger = console + +const setSchema = (schema: object) => { + const { importSchema, setSaved } = useCanvas() + importSchema(schema) + setSaved(false) +} + +const _updatePageSchema = (streamContent: string, currentPageSchema: object, isFinial: boolean = false) => { + const { getSelectedModelInfo } = useModelConfig() + if (getSelectedModelInfo().config?.chatMode !== ChatMode.Agent) { + return + } + + // 解析流式返回的schema patch + let content = getJsonObjectString(streamContent) + let jsonPatches = [] + try { + if (!isFinial) { + content = jsonrepair(content) + } + jsonPatches = JSON.parse(content) + } catch (error) { + if (isFinial) { + logger.error('parse json patch error:', error) + } + return { isError: true, error } + } + + // 过滤有效的json patch + if (!isFinial && !isValidFastJsonPatch(jsonPatches)) { + return { isError: true, error: 'format error: not a valid json patch.' } + } + const validJsonPatches = jsonPatchAutoFix(jsonPatches, isFinial) + + // 生成新schema + const originSchema = deepClone(currentPageSchema) + const newSchema = validJsonPatches.reduce((acc: object, patch: any) => { + try { + return jsonpatch.applyPatch(acc, [patch], false, false).newDocument + } catch (error) { + if (isFinial) { + logger.error('apply patch error:', error, patch) + } + return acc + } + }, originSchema) + + // schema纠错 + fixMethods(newSchema.methods) + schemaAutoFix(newSchema.children) + + // 更新Schema + setSchema(newSchema) + if (isFinial) { + useHistory().addHistory() + } + + return { schema: newSchema, isError: false } +} + +export const updatePageSchema = useThrottleFn(_updatePageSchema, 200, true) diff --git a/packages/plugins/robot/src/composables/core/useConfig.ts b/packages/plugins/robot/src/composables/core/useConfig.ts new file mode 100644 index 0000000000..e425490e23 --- /dev/null +++ b/packages/plugins/robot/src/composables/core/useConfig.ts @@ -0,0 +1,468 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +/* metaService: engine.plugins.robot.useRobot */ +import { reactive, readonly } from 'vue' +import { DEFAULT_LLM_MODELS } from '../../constants' +import { getRobotServiceOptions } from '../../utils' +import { ChatMode } from '../../types/mode.types' +import type { ModelConfig, ModelService, RobotSettings, SelectedModelInfo } from '../../types/setting.types' +import apiService from '../../services/api' + +const SETTING_STORAGE_KEY = 'tiny-engine-robot-settings' +const SETTING_VERSION = 2 // 新版本号 + +const robotSettingState = reactive({ + version: SETTING_VERSION, + defaultModel: { + serviceId: '', + modelName: '' + }, + quickModel: { + serviceId: '', + modelName: '' + }, + services: [], + chatMode: ChatMode.Agent, + enableThinking: false +}) + +const getAIModelOptions = () => { + const customAIModels = getRobotServiceOptions()?.customCompatibleAIModels || [] + if (!customAIModels.length) { + return DEFAULT_LLM_MODELS + } + return mergeAIModelOptions(DEFAULT_LLM_MODELS, customAIModels) // eslint-disable-line +} + +// 初始化内置服务 +const initBuiltInServices = (): ModelService[] => { + return getAIModelOptions().map((service: any) => ({ + id: service.provider, + provider: service.provider, + label: service.label, + baseUrl: service.baseUrl, + apiKey: '', + allowEmptyApiKey: service.allowEmptyApiKey ?? false, + isBuiltIn: true, + models: service.models + })) +} + +// 初始化默认配置 +const initDefaultSettings = (): RobotSettings => { + const builtInServices = initBuiltInServices() + const firstService = builtInServices[0] + const firstModel = firstService?.models[0] + + return { + version: SETTING_VERSION, + defaultModel: { + serviceId: firstService?.id || '', + modelName: firstModel?.name || '' + }, + quickModel: { + serviceId: '', + modelName: '' + }, + services: builtInServices, + chatMode: ChatMode.Agent, + enableThinking: false + } +} + +// 数据迁移:从旧版本格式迁移到新版本 +const migrateOldSettings = (oldSettings: any): RobotSettings | null => { + if (!oldSettings || oldSettings.version === SETTING_VERSION) { + return null + } + + const { activeName, existModel, customizeModel, chatMode, enableThinking } = oldSettings + const builtInServices = initBuiltInServices() + const services: ModelService[] = [...builtInServices] + + // 迁移旧的配置到services中 + if (activeName === 'existingModels' && existModel) { + const service = services.find((s) => s.baseUrl === existModel.baseUrl) + if (service) { + service.apiKey = existModel.apiKey || '' + } + } + + // 迁移自定义模型 + if (activeName === 'customize' && customizeModel?.baseUrl) { + const customService: ModelService = { + id: `custom_${Date.now()}`, + provider: 'custom', + label: '自定义服务', + baseUrl: customizeModel.baseUrl, + apiKey: customizeModel.apiKey || '', + allowEmptyApiKey: false, + isBuiltIn: false, + models: [ + { + name: customizeModel.model || 'default', + label: customizeModel.model || '默认模型', + capabilities: {} + } + ] + } + if (customizeModel.completeModel) { + customService.models.push({ + name: customizeModel.completeModel, + label: customizeModel.completeModel, + capabilities: { compact: true } + }) + } + services.push(customService) + } + + // 确定默认模型和快速模型 + const selectedModel = activeName === 'existingModels' ? existModel : customizeModel + const defaultServiceId = + activeName === 'existingModels' ? services.find((s) => s.baseUrl === selectedModel?.baseUrl)?.id : '' + + return { + version: SETTING_VERSION, + defaultModel: { + serviceId: defaultServiceId || services[0]?.id || '', + modelName: selectedModel?.model || services[0]?.models[0]?.name || '' + }, + quickModel: { + serviceId: defaultServiceId || '', + modelName: selectedModel?.completeModel || '' + }, + services, + chatMode: chatMode || ChatMode.Agent, + enableThinking: enableThinking || false + } as RobotSettings +} + +const loadRobotSettingState = (): RobotSettings | null => { + const items = localStorage.getItem(SETTING_STORAGE_KEY) + if (!items) { + return null + } + try { + const parsed = JSON.parse(items) + // 如果是旧版本,进行迁移 + if (!parsed.version || parsed.version < SETTING_VERSION) { + const migrated = migrateOldSettings(parsed) + if (migrated) { + saveRobotSettingState(migrated) // eslint-disable-line + return migrated + } + } + return parsed + } catch (error) { + return null + } +} + +const saveRobotSettingState = (state: Partial, updateState = true) => { + if (updateState) { + Object.assign(robotSettingState, state) + } + const currentState = loadRobotSettingState() || initDefaultSettings() + const newState = { ...currentState, ...state, version: SETTING_VERSION } + localStorage.setItem(SETTING_STORAGE_KEY, JSON.stringify(newState)) +} + +/** + * 合并默认模型服务配置和用户自定义配置 + * @param defaults 默认的模型服务配置列表 + * @param customs 用户自定义的模型服务配置列表 + * @returns 合并后的模型服务配置列表 + * + * 支持的操作: + * 1. 删除整个服务:{ provider: 'deepseek', _remove: true } + * 2. 修改服务属性:{ provider: 'qwen', label: '新名称', baseUrl: '新地址' } + * 3. 删除服务中的模型:{ provider: 'qwen', models: [{ name: 'qwen3-8b', _remove: true }] } + * 4. 添加新模型:{ provider: 'qwen', models: [{ name: 'new-model', label: '新模型' }] } + * 5. 覆盖模型配置:{ provider: 'qwen', models: [{ name: 'qwen-plus', label: '新标签', capabilities: {...} }] } + * 6. 添加新服务:{ provider: 'openai', label: 'OpenAI', baseUrl: '...', models: [...] } + */ +const mergeAIModelOptions = (defaults: any[], customs: any[]): any[] => { + // 深拷贝默认配置作为基础 + const result = JSON.parse(JSON.stringify(defaults)) + + customs.forEach((customProvider) => { + // 如果标记删除整个 provider(基于 provider 名称匹配) + if (customProvider._remove) { + const index = result.findIndex((p: any) => p.provider === customProvider.provider) + if (index !== -1) { + result.splice(index, 1) + } + return + } + + // 查找相同 provider 名称的服务 + const existingProviderIndex = result.findIndex((p: any) => p.provider === customProvider.provider) + + if (existingProviderIndex !== -1) { + // 找到相同 provider 的服务,合并配置 + const existingProvider = result[existingProviderIndex] + + // 更新服务的其他属性(如果提供了) + if (customProvider.label !== undefined) existingProvider.label = customProvider.label + if (customProvider.baseUrl !== undefined) existingProvider.baseUrl = customProvider.baseUrl + if (customProvider.allowEmptyApiKey !== undefined) + existingProvider.allowEmptyApiKey = customProvider.allowEmptyApiKey + + // 合并 models + customProvider.models?.forEach((customModel: any) => { + if (customModel._remove) { + // 移除指定的 model + const modelIndex = existingProvider.models.findIndex((m: any) => m.name === customModel.name) + if (modelIndex !== -1) { + existingProvider.models.splice(modelIndex, 1) + } + } else { + // 查找是否存在相同 name 的 model + const existingModelIndex = existingProvider.models.findIndex((m: any) => m.name === customModel.name) + if (existingModelIndex !== -1) { + // 替换已有 model(覆盖) + const { _remove, ...modelWithoutRemove } = customModel + existingProvider.models[existingModelIndex] = modelWithoutRemove + } else { + // 添加新 model + const { _remove, ...modelWithoutRemove } = customModel + existingProvider.models.push(modelWithoutRemove) + } + } + }) + } else { + // 添加新的 provider + const { _remove, ...providerWithoutRemove } = customProvider + // 过滤掉标记为删除的模型 + providerWithoutRemove.models = (providerWithoutRemove.models || []) + .filter((m: any) => !m._remove) + .map((m: any) => { + const { _remove, ...modelWithoutRemove } = m + return modelWithoutRemove + }) + result.push(providerWithoutRemove) + } + }) + + return result +} + +// 合并缓存的服务与内置的服务,解决部分场景下缓存配置中丢失了内置服务的问题 +const mergeServices = (services: ModelService[] = [], builtInServices: ModelService[]): ModelService[] => { + const cachedServiceMap = new Map(services.map((s) => [s.id, s])) + const result: ModelService[] = [] + + builtInServices.forEach((builtIn) => { + const cached = cachedServiceMap.get(builtIn.id) + if (cached) { + result.push({ + ...builtIn, + apiKey: cached.apiKey || '' + }) + cachedServiceMap.delete(builtIn.id) + } else { + result.push({ ...builtIn }) + } + }) + + cachedServiceMap.forEach((service) => { + if (!service.isBuiltIn) { + result.push(service) + } + }) + + return result +} + +export const init = () => { + let settingState = loadRobotSettingState() + if (!settingState) { + settingState = initDefaultSettings() + } else { + settingState.services = mergeServices(settingState.services, initBuiltInServices()) + } + saveRobotSettingState(settingState) +} + +// 根据serviceId和modelName获取模型能力 +const getModelCapabilities = (serviceId: string, modelName: string) => { + if (!serviceId || !modelName) { + return null + } + const service = robotSettingState.services.find((s) => s.id === serviceId) + return service?.models.find((m) => m.name === modelName)?.capabilities +} + +// 获取所有可用模型(扁平化) +const getAllAvailableModels = () => { + return robotSettingState.services.flatMap((service) => + service.models.map((model) => ({ + serviceId: service.id, + serviceName: service.label, + modelName: model.name, + modelLabel: model.label, + capabilities: model.capabilities || {}, + displayLabel: `${service.label} - ${model.label}`, + value: `${service.id}::${model.name}` + })) + ) +} + +// 获取快速模型列表 +const getCompactModels = () => { + return getAllAvailableModels().filter((model) => model.capabilities?.compact) +} + +const updateThinkingState = (value: boolean) => { + robotSettingState.enableThinking = value + saveRobotSettingState({ enableThinking: robotSettingState.enableThinking }) +} + +const updateChatModeState = (value: string) => { + robotSettingState.chatMode = value + saveRobotSettingState({ chatMode: robotSettingState.chatMode }) +} + +const encryptServiceApiKey = async (apiKey: string): Promise => { + if (!apiKey || !getRobotServiceOptions()?.encryptServiceApiKey || apiKey.startsWith('EKEY_')) return apiKey + + try { + const { token } = await apiService.encryptKey(apiKey) + return token + } catch (error) { + const logger = console + logger.error('加密API密钥失败', error) + return apiKey + } +} + +// 服务管理方法 +const addCustomService = async (service: Omit) => { + const newService: ModelService = { + ...service, + id: `custom_${Date.now()}`, + isBuiltIn: false, + apiKey: await encryptServiceApiKey(service.apiKey) + } + robotSettingState.services.push(newService) + saveRobotSettingState({ services: robotSettingState.services }, false) + return newService.id +} + +const updateService = async (serviceId: string, updates: Partial) => { + const index = robotSettingState.services.findIndex((s) => s.id === serviceId) + if (index !== -1) { + Object.assign(robotSettingState.services[index], { + ...updates, + apiKey: await encryptServiceApiKey(updates.apiKey || '') + }) + saveRobotSettingState({ services: robotSettingState.services }, false) + } +} + +const deleteService = (serviceId: string) => { + const index = robotSettingState.services.findIndex((s) => s.id === serviceId) + if (index !== -1 && !robotSettingState.services[index].isBuiltIn) { + robotSettingState.services.splice(index, 1) + saveRobotSettingState({ services: robotSettingState.services }, false) + } +} + +const getServiceById = (serviceId: string) => { + return robotSettingState.services.find((s) => s.id === serviceId) +} + +// 获取当前选择的对话模型信息 +const getSelectedModelInfo = (): SelectedModelInfo => { + const currentService: ModelService | undefined = getServiceById(robotSettingState.defaultModel.serviceId) + const currentModel: ModelConfig | undefined = currentService?.models.find( + (m) => m.name === robotSettingState.defaultModel.modelName + ) + const { name = '', label = '', capabilities = {} } = currentModel || {} + + const { models, ...service } = currentService! || {} + + return { + // 模型 + name, + label, + capabilities, + // 服务 + service: currentService ? service : null, + + // 配置 + config: { + chatMode: robotSettingState.chatMode, + enableThinking: robotSettingState.enableThinking + }, + + // 模型兼容字段 + model: robotSettingState.defaultModel.modelName, + // 服务兼容字段 + baseUrl: currentService?.baseUrl || '', + apiKey: currentService?.apiKey || '' + } +} + +const getSelectedQuickModelInfo = (): SelectedModelInfo => { + const currentService: ModelService | undefined = getServiceById(robotSettingState.quickModel.serviceId) + const currentModel: ModelConfig | undefined = currentService?.models.find( + (m) => m.name === robotSettingState.quickModel.modelName + ) + const { name = '', label = '', capabilities = {} } = currentModel || {} + + const { models, ...service } = currentService! || {} + + return { + // 模型 + name, + label, + capabilities, + // 服务 + service: currentService ? service : null, + + // 模型兼容字段 + model: robotSettingState.quickModel.modelName, + completeModel: robotSettingState.quickModel.modelName || '', + // 服务兼容字段 + baseUrl: currentService?.baseUrl || '', + apiKey: currentService?.apiKey || '' + } +} + +export default () => { + return { + // 配置状态 + robotSettingState: readonly(robotSettingState), + + // 状态更新与数据持久化 + updateThinkingState, + updateChatModeState, + saveRobotSettingState, + loadRobotSettingState, + + // 模型相关 + getAIModelOptions, // 合并后的模型配置 + getModelCapabilities, + getAllAvailableModels, + getCompactModels, + getSelectedModelInfo, // 对话模型信息 + getSelectedQuickModelInfo, // 快速模型信息 + + // 服务管理 + addCustomService, + updateService, + deleteService, + getServiceById + } +} diff --git a/packages/plugins/robot/src/composables/core/useConversation.ts b/packages/plugins/robot/src/composables/core/useConversation.ts new file mode 100644 index 0000000000..369d99f818 --- /dev/null +++ b/packages/plugins/robot/src/composables/core/useConversation.ts @@ -0,0 +1,108 @@ +import { toRaw } from 'vue' +import { useConversation as useConversationKit, type UseMessageOptions } from '@opentiny/tiny-robot-kit' +import type { AIClient } from '@opentiny/tiny-robot-kit' + +export interface ConversationAdapterOptions { + client: AIClient + // 业务回调函数 + onStreamData: (data: any, messages: any[]) => void + onFinishRequest: (finishReason: string, messages: any[], contextMessages: any[], messageState: any) => Promise + onMessageProcessed: (finishReason: string, content: any, messages: any[], context: any) => Promise +} + +export interface ConversationMetadata { + chatMode?: string + [key: string]: any +} + +/** + * Conversation 适配器 + * 将 tiny-robot-kit 的 useConversation 与业务逻辑解耦 + */ +export function useConversationAdapter(options: ConversationAdapterOptions) { + const { client, onStreamData, onFinishRequest, onMessageProcessed } = options + + // 构建 events 适配器,连接业务回调 + const events: UseMessageOptions['events'] = { + onReceiveData: (data, messages, preventDefault) => { + preventDefault() + onStreamData(data, messages.value) + }, + async onFinish(finishReason, { messages, messageState }, preventDefault) { + preventDefault() + const contextMessages = toRaw(messages.value.slice(0, -1)) + await onFinishRequest(finishReason ?? 'unknown', messages.value, contextMessages, messageState) + const lastMessage = messages.value.at(-1) + if (lastMessage) { + await onMessageProcessed(finishReason ?? 'unknown', lastMessage.content ?? '', messages.value, {}) + } + } + } + + // 使用 tiny-robot-kit 的 useConversation + const { + messageManager, + state: conversationState, + ...conversationMethods + } = useConversationKit({ + client, + events + }) + + /** + * 创建新会话 + * @param title 会话标题 + * @param metadata 会话元数据(如 chatMode) + */ + const createConversation = (title: string, metadata?: ConversationMetadata) => { + return conversationMethods.createConversation(title, metadata) + } + + /** + * 切换会话 + * @param conversationId 会话ID + * @param onStart 切换成功后的回调 + */ + const switchConversation = (conversationId: string, onStart?: (state: any, messages: any, methods: any) => void) => { + const conversation = conversationState.conversations.find((c) => c.id === conversationId) + if (!conversation) return + + const result = conversationMethods.switchConversation(conversationId) + + // 触发业务回调 + if (onStart) { + onStart(conversationState, messageManager.messages.value, conversationMethods) + } + + return result + } + + /** + * 自动设置会话标题 + * @param currentId 当前会话ID + * @param defaultTitle 默认标题 + */ + const autoSetTitle = (currentId: string, defaultTitle = '新会话') => { + const currentConversation = conversationState.conversations.find((conversation) => conversation.id === currentId) + if (!currentConversation) return + + const currentTitle = currentConversation?.title + if (currentTitle === defaultTitle && currentId) { + const messageContent = currentConversation.messages.find((item) => item.role === 'user')?.content + const contentStr = typeof messageContent === 'string' ? messageContent : JSON.stringify(messageContent) + conversationMethods.updateTitle(currentId, contentStr.substring(0, 20)) + } + } + + return { + // 消息管理器 + messageManager, + // 会话状态 + conversationState, + // 会话方法(包装后,覆盖原始方法) + ...conversationMethods, + createConversation, + switchConversation, + autoSetTitle + } +} diff --git a/packages/plugins/robot/src/composables/core/useMessageStream.ts b/packages/plugins/robot/src/composables/core/useMessageStream.ts new file mode 100644 index 0000000000..29ec941545 --- /dev/null +++ b/packages/plugins/robot/src/composables/core/useMessageStream.ts @@ -0,0 +1,111 @@ +import type { ChatCompletionStreamResponseChoice } from '@opentiny/tiny-robot-kit' +import type { Message, ResponseToolCall } from '../../types' +import { mergeStringFields } from '../../utils' + +// 流式数据处理器配置选项 +export interface StreamDataHandlerOptions { + getContentType: () => string + hooks: { + onStreamStart: (messages: any[]) => void + onStreamData: (data: any, content: any, messages: any[]) => void + onStreamTools: (tools: any[], context: { currentMessage: any }) => void + } + statusManager: { + isStreaming: () => boolean + setStreaming: () => void + } +} + +const handleDeltaReasoning = (choice: ChatCompletionStreamResponseChoice, lastMessage: Message) => { + if (typeof choice.delta.reasoning_content === 'string' && choice.delta.reasoning_content) { + if (lastMessage.renderContent.at(-1)?.contentType !== 'reasoning') { + lastMessage.renderContent.push({ + type: 'collapsible-text', + contentType: 'reasoning', + title: '深度思考', + content: '', + status: 'reasoning', + defaultOpen: true + }) + } + lastMessage.renderContent.at(-1)!.content += choice.delta.reasoning_content + } +} + +const handleDeltaContent = ( + choice: ChatCompletionStreamResponseChoice, + lastMessage: Message, + contentType = 'markdown' +) => { + if (typeof choice.delta.content === 'string' && choice.delta.content) { + if (lastMessage.renderContent.at(-1)?.contentType === 'reasoning') { + lastMessage.renderContent.at(-1)!.status = 'finish' + } + if (lastMessage.renderContent.at(-1)?.type !== contentType) { + lastMessage.renderContent.push({ type: contentType, content: '' }) + lastMessage.content = '' + } + lastMessage.renderContent.at(-1)!.content += choice.delta.content + lastMessage.content += choice.delta.content + } +} + +const handleDeltaToolCalls = (choice: ChatCompletionStreamResponseChoice, lastMessage: Message) => { + const toolCallChunks = choice.delta.tool_calls as (ResponseToolCall & { index: number })[] + if (Array.isArray(toolCallChunks) && toolCallChunks.length) { + if (!lastMessage.tool_calls) { + lastMessage.tool_calls = [] + } + for (const chunk of toolCallChunks) { + const { index, ...chunkWithoutIndex } = chunk + if (lastMessage.tool_calls[index]) { + mergeStringFields(lastMessage.tool_calls[index], chunkWithoutIndex) + } else { + lastMessage.tool_calls[index] = chunkWithoutIndex + } + } + } +} + +/** + * 创建流式数据处理器 + * 通过依赖注入解耦业务逻辑与状态管理、回调函数 + * @param options 配置选项,包含内容类型获取、钩子函数、状态管理器 + * @returns 流式数据处理函数 + */ +export function createStreamDataHandler(options: StreamDataHandlerOptions) { + const { getContentType, hooks, statusManager } = options + + return (data: any, messages: any[]) => { + const choice = data.choices?.[0] + if (!choice) { + return + } + + const lastMessage = messages.at(-1) as Message + + // 处理首次流式响应 + if (!statusManager.isStreaming()) { + statusManager.setStreaming() + hooks.onStreamStart(messages) + } + + // 核心流式处理逻辑 + handleDeltaReasoning(choice, lastMessage) + handleDeltaContent(choice, lastMessage, getContentType()) + handleDeltaToolCalls(choice, lastMessage) + + // 触发钩子 + hooks.onStreamData(data, lastMessage.content, messages) + hooks.onStreamTools(lastMessage.tool_calls || [], { currentMessage: lastMessage }) + } +} + +export default function useMessageStream() { + return { + handleDeltaReasoning, + handleDeltaContent, + handleDeltaToolCalls, + createStreamDataHandler + } +} diff --git a/packages/plugins/robot/src/mcp/useMcp.ts b/packages/plugins/robot/src/composables/features/useMcp.ts similarity index 83% rename from packages/plugins/robot/src/mcp/useMcp.ts rename to packages/plugins/robot/src/composables/features/useMcp.ts index 26963e1cd9..adfa9af6e8 100644 --- a/packages/plugins/robot/src/mcp/useMcp.ts +++ b/packages/plugins/robot/src/composables/features/useMcp.ts @@ -1,7 +1,8 @@ import { computed, ref } from 'vue' import type { PluginInfo, PluginTool } from '@opentiny/tiny-robot' import { getMetaApi, META_SERVICE } from '@opentiny/tiny-engine-meta-register' -import type { McpListToolsResponse, McpTool, RequestTool } from './types' +import type { McpTool } from '../types/mcp-types' +import type { RequestTool } from '../types/types' const ENGINE_MCP_SERVER: PluginInfo = { id: 'tiny-engine-mcp-server', @@ -11,8 +12,6 @@ const ENGINE_MCP_SERVER: PluginInfo = { added: true } -const mcpServers = ref([ENGINE_MCP_SERVER]) - const inUseMcpServers = ref([{ ...ENGINE_MCP_SERVER, enabled: true, expanded: true, tools: [] }]) const updateServerTools = (serverId: string, tools: PluginTool[]) => { @@ -68,12 +67,6 @@ const updateEngineServer = (engineServer: PluginInfo, enabled: boolean) => { }) } -// TODO: 连接MCP Server -const connectMcpServer = (_server: PluginInfo) => {} - -// TODO: 断开连接 -const disconnectMcpServer = (_server: PluginInfo) => {} - const updateMcpServerStatus = async (server: PluginInfo, added: boolean) => { // 市场添加状态修改 server.added = added @@ -91,15 +84,11 @@ const updateMcpServerStatus = async (server: PluginInfo, added: boolean) => { await updateEngineTools() updateEngineServer(newServer, added) } - // TODO: 连接MCP Server - connectMcpServer(newServer) } else { const index = inUseMcpServers.value.findIndex((p) => p.id === server.id) if (index > -1) { updateEngineServer(inUseMcpServers.value[index], added) inUseMcpServers.value.splice(index, 1) - // TODO: 断开连接 - disconnectMcpServer(server) } } } @@ -110,9 +99,6 @@ const updateMcpServerToolStatus = (currentServer: PluginInfo, toolId: string, en tool.enabled = enabled if (currentServer.id === ENGINE_MCP_SERVER.id) { updateEngineServerToolStatus(toolId, enabled) - } else { - // TODO: 更新MCP Server的Tool状态 - // 获取 tool 实例调用 enableTool 或 disableTool } } } @@ -121,20 +107,24 @@ const refreshMcpServerTools = () => { updateEngineTools() } -const listTools = async (): Promise => - getMetaApi(META_SERVICE.McpService)?.getMcpClient()?.listTools() +let llmTools: RequestTool[] | null = null + +const listTools = async (): Promise => { + const mcpTools = await getMetaApi(META_SERVICE.McpService)?.getMcpClient()?.listTools() + return mcpTools +} const callTool = async (toolId: string, args: Record) => getMetaApi(META_SERVICE.McpService)?.getMcpClient()?.callTool({ name: toolId, arguments: args }) || {} const getLLMTools = async () => { - const mcpTools = await listTools() - return convertMCPToOpenAITools(mcpTools?.tools || []) + const mcpTools = await getMetaApi(META_SERVICE.McpService)?.getMcpClient()?.listTools() + llmTools = convertMCPToOpenAITools(mcpTools?.tools || []) + return llmTools } export default function useMcpServer() { return { - mcpServers, inUseMcpServers, refreshMcpServerTools, updateMcpServerStatus, diff --git a/packages/plugins/robot/src/composables/features/useToolCalls.ts b/packages/plugins/robot/src/composables/features/useToolCalls.ts new file mode 100644 index 0000000000..444be154eb --- /dev/null +++ b/packages/plugins/robot/src/composables/features/useToolCalls.ts @@ -0,0 +1,132 @@ +import { toRaw } from 'vue' +import type { AIClient } from '@opentiny/tiny-robot-kit' +import useMcpServer from './useMcp' +import { serializeError } from '../../utils' +import type { ResponseToolCall, RobotMessage, LLMMessage } from '../../types' + +const parseArgs = (args: string) => { + try { + return JSON.parse(args) + } catch (error) { + return args + } +} + +const callTool = async (name: string, args: Record) => { + let toolCallResult: object | string + let toolCallStatus: 'success' | 'failed' + try { + const resp = await useMcpServer().callTool(name, args) + toolCallStatus = 'success' + toolCallResult = resp.content + } catch (error) { + toolCallStatus = 'failed' + toolCallResult = serializeError(error) + } + return { toolCallResult, toolCallStatus } +} + +interface CallToolHooks { + onBeforeCallTool: (tool: Record) => void + onPostCallTool: (tool: Record, toolCallResult: object | string, toolCallStatus: string) => void +} + +export const callTools = async (tool_calls: any, hooks: CallToolHooks, signal: AbortController['signal']) => { + const result = [] + for (const tool of tool_calls) { + const { name, arguments: args } = tool.function + const parsedArgs = parseArgs(args) + tool.parsedArgs = parsedArgs + tool.name = name + + hooks.onBeforeCallTool(tool) + + const { toolCallResult, toolCallStatus } = await callTool(name, parsedArgs) + result.push({ toolCallResult, toolCallStatus, ...tool }) + + hooks.onPostCallTool(tool, toolCallResult, toolCallStatus) + + if (signal?.aborted) { + return Promise.reject('aborted') + } + } + return result +} + +// 工厂函数配置接口 +export interface ToolCallHandlerConfig { + client: AIClient + getAbortController: () => AbortController + formatMessages: (messages: any[]) => LLMMessage[] + hooks: { + onBeforeCallTool: (tool: Record, context: { currentMessage: any }) => void + onPostCallTool: ( + tool: Record, + toolCallResult: object | string, + toolCallStatus: string, + context: { currentMessage: any } + ) => void + onPostCallTools: (results: any[], context: { currentMessage: any }) => void + } + streamHandlers: { + onData: (data: any, messages: any[]) => void + onError: (error: any, messages: any[], messageState: any) => void + onDone: (finishReason: string, messages: any[], contextMessages: any[], messageState: any) => Promise + } + getMessageState: () => any +} + +/** + * 创建工具调用处理器 + * 使用工厂函数模式,将所有依赖通过配置注入 + */ +export function createToolCallHandler(config: ToolCallHandlerConfig) { + const { client, getAbortController, formatMessages, hooks, streamHandlers, getMessageState } = config + + return async (tool_calls: ResponseToolCall[], messages: any[], contextMessages: RobotMessage[]) => { + const hasToolCall = tool_calls?.length > 0 + if (!hasToolCall) { + return + } + + // 获取新的 AbortController + const abortController = getAbortController() + + const currentMessage = messages.at(-1) + const toolMessages: LLMMessage[] = formatMessages([...contextMessages, toRaw(currentMessage)]) + + // 构建工具调用的 hooks + const toolCallHooks = { + onBeforeCallTool: (tool: Record) => hooks.onBeforeCallTool(tool, { currentMessage }), + onPostCallTool: (tool: Record, toolCallResult: object | string, toolCallStatus: string) => + hooks.onPostCallTool(tool, toolCallResult, toolCallStatus, { currentMessage }) + } + + try { + const result = await callTools(tool_calls, toolCallHooks, abortController.signal) + toolMessages.push( + ...result.map((item) => ({ + content: JSON.stringify(item.toolCallResult), + role: 'tool', + tool_call_id: item.id + })) + ) + hooks.onPostCallTools(result, { currentMessage }) + } catch (error) { + return + } + + delete currentMessage.tool_calls + + // 使用工具调用结果继续对话 + await client.chatStream( + { messages: toolMessages as any, options: { signal: abortController.signal } }, + { + onData: (data) => streamHandlers.onData(data, messages), + onError: (error) => streamHandlers.onError(error, messages, getMessageState()), + onDone: (finishReason?: string) => + streamHandlers.onDone(finishReason ?? 'unknown', messages, toolMessages, getMessageState()) + } + ) + } +} diff --git a/packages/plugins/robot/src/composables/modes/useAgentMode.ts b/packages/plugins/robot/src/composables/modes/useAgentMode.ts new file mode 100644 index 0000000000..a4b96a4ab5 --- /dev/null +++ b/packages/plugins/robot/src/composables/modes/useAgentMode.ts @@ -0,0 +1,264 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { useCanvas, useMaterial } from '@opentiny/tiny-engine-meta-register' +import { utils } from '@opentiny/tiny-engine-utils' +import { isValidJsonPatchObjectString, getRobotServiceOptions, removeLoading, addSystemPrompt } from '../../utils' +import { updatePageSchema } from '../core/pageUpdater' +import useModelConfig from '../core/useConfig' +import { formatComponents, getAgentSystemPrompt, getJsonFixPrompt } from '../../constants/prompts' +import { search, fetchAssets } from '../../services/agentServices' +import { updateClientConfig as updateConfig, client } from '../../services/aiClient' +import type { ModeHooks } from '../../types/mode.types' +import { ChatMode } from '../../types/mode.types' + +const { deepClone } = utils +const logger = console + +const updateToolCallRenderContent = (tool: Record, renderContent: any[]) => { + const currentToolCallContent = renderContent.find((item) => item.type === 'tool' && item.toolCallId === tool.id) + if (currentToolCallContent) { + currentToolCallContent.status = 'running' + if (!currentToolCallContent.content) { + currentToolCallContent.content = {} + } + currentToolCallContent.content.params = tool.parsedArgs || tool.function!.arguments || {} + } else { + renderContent.push({ + type: 'tool', + name: tool.name || tool.function!.name, + status: 'running', + content: { + params: tool.parsedArgs || tool.function!.arguments || {} + }, + formatPretty: true, + toolCallId: tool.id + }) + } +} + +/** + * Agent 模式实现 + * 特点: + * - 使用 JSON Patch 更新页面 schema + * - 支持 RAG 上下文和资源上下文 + * - 支持思考模式(thinking) + * - 实时更新画布 + * - JSON 修复机制 + */ +export default function useAgentMode(): ModeHooks { + let pageSchema: object | null = null + const { getSelectedModelInfo } = useModelConfig() + + // ========== 配置方法 ========== + const getApiUrl = () => '/app-center/api/ai/chat' + + const getContentType = () => 'agent-content' + + const getLoadingType = () => 'agent-loading' + + // ========== 生命周期钩子 ========== + const onConversationStart = (conversationState: any, messages: any[], apis: any) => { + logger.log('Agent mode: onConversationStart called', conversationState) + + const conversation = conversationState.conversations.find((item: any) => item.id === conversationState.currentId) + + // 确保会话元数据中记录为 Agent 模式 + if (!conversation.metadata?.chatMode || conversation.metadata.chatMode !== ChatMode.Agent) { + apis.updateMetadata(conversationState.currentId, { chatMode: ChatMode.Agent }) + apis.saveConversations() + } + + // Agent 模式特殊处理:标记失败的 loading + messages.at(-1)?.renderContent?.forEach((item: any) => { + if (item.type.includes('loading') || item.status !== 'success') { + item.status = 'failed' + } + }) + } + + const onMessageSent = () => { + // Agent 模式暂无特殊处理 + } + + const onBeforeRequest = async (requestParams: any) => { + const pageSchema = deepClone(useCanvas().pageState.pageSchema) + + let referenceContext = '' + let imageAssets: any[] = [] + + // 添加系统提示词 + if (requestParams.messages[0]?.role !== 'system') { + if (getRobotServiceOptions()?.enableRagContext) { + referenceContext = await search(requestParams.messages?.at(-1)?.content) + } + if (getRobotServiceOptions()?.enableResourceContext !== false) { + imageAssets = await fetchAssets() + } + const { materialState, getComponentDetail } = useMaterial() + const components = formatComponents(materialState.components, getComponentDetail) + addSystemPrompt( + requestParams.messages, + getAgentSystemPrompt(components, pageSchema, referenceContext, imageAssets) + ) + } + + const { baseUrl, model, config, capabilities } = getSelectedModelInfo() + + // Agent 模式默认使用 JSON 对象格式 + if (!config?.enableThinking) { + Object.assign(requestParams, { response_format: { type: 'json_object' } }) + } + + requestParams.baseUrl = baseUrl + requestParams.model = model + + if (capabilities?.reasoning?.extraBody) { + Object.assign( + requestParams, + config?.enableThinking ? capabilities.reasoning.extraBody.enable : capabilities.reasoning.extraBody.disable + ) + } + + return requestParams + } + + const onStreamStart = (messages: any[]) => { + removeLoading(messages) + pageSchema = deepClone(useCanvas().pageState.pageSchema) + } + + const onStreamData = (data: object, content: string | object, _messages: any[]) => { + updatePageSchema(content, pageSchema!) + } + + const onRequestEnd = async ( + finishReason: string, + content: string, + messages: any[], + extraData?: Record + ) => { + if (finishReason === 'aborted' || finishReason === 'error') { + removeLoading(messages) + const errorInfo = { content: extraData?.error || '请求失败', status: 'failed' } + if (messages.at(-1).renderContent.at(-1)) { + Object.assign(messages.at(-1).renderContent.at(-1), errorInfo) + } else { + messages.at(-1).renderContent = [{ type: getContentType(), ...errorInfo }] + } + } + } + + const onStreamTools = (tools: Record[], { currentMessage }: { currentMessage: any }) => { + tools.forEach((tool) => updateToolCallRenderContent(tool, currentMessage.renderContent)) + } + + const onBeforeCallTool = (tool: Record, { currentMessage }: { currentMessage: any }) => { + updateToolCallRenderContent(tool, currentMessage.renderContent) + } + + const onPostCallTool = ( + tool: Record, + toolCallResult: object | string, + toolCallStatus: string, + { currentMessage }: { currentMessage: any } + ) => { + currentMessage.renderContent.at(-1)!.status = toolCallStatus + currentMessage.renderContent.at(-1)!.content = { + params: tool.parsedArgs, + result: toolCallResult + } + } + + const onMessageProcessed = async ( + finishReason: string, + content: string, + messages: any[], + { abortControllerMap }: { abortControllerMap: Record } + ) => { + const lastMessage = messages.at(-1) + const jsonValidResult = isValidJsonPatchObjectString(content) + + // JSON 修复机制 + if (jsonValidResult.isError) { + abortControllerMap.errorFix = new AbortController() + try { + const beforeRequest = (requestParams: any) => { + const { capabilities, model, baseUrl } = getSelectedModelInfo() + if (capabilities?.reasoning?.extraBody?.disable) { + Object.assign(requestParams, capabilities.reasoning.extraBody.disable) + } + Object.assign(requestParams, { + response_format: { type: 'json_object' }, + model, + baseUrl + }) + return requestParams + } + updateConfig({ apiUrl: '/app-center/api/chat/completions' }) + messages.at(-1).renderContent.at(-1).status = 'fix' + const fixedResponse = await client.chat({ + messages: [{ role: 'user', content: getJsonFixPrompt(content, jsonValidResult.error) }], + options: { signal: abortControllerMap.errorFix?.signal, beforeRequest: beforeRequest as any } + }) + if (!isValidJsonPatchObjectString(fixedResponse.choices[0].message.content).isError) { + lastMessage.originContent = lastMessage.content + lastMessage.content = fixedResponse.choices[0].message.content + } + } catch (error) { + logger.error('json fix failed', error) + } + updateConfig({ apiUrl: getApiUrl() }) + } + + // 更新页面 schema + const result = await updatePageSchema(lastMessage.content, pageSchema, true) + if (result.schema) { + messages.at(-1).renderContent.at(-1).status = 'success' + messages.at(-1).renderContent.at(-1).schema = result.schema + } else { + messages.at(-1).renderContent.at(-1).status = 'failed' + } + + pageSchema = null + abortControllerMap.errorFix = null + } + + const onPostCallTools = (toolsResult: Record[], { currentMessage }: { currentMessage: any }) => { + currentMessage.renderContent.push({ type: 'loading', content: '' }) + } + + const onConversationEnd = (_conversationId: string) => { + // Agent 模式暂无特殊处理 + } + + return { + // 配置方法 + getApiUrl, + getContentType, + getLoadingType, + + // 生命周期钩子 + onConversationStart, + onMessageSent, + onBeforeRequest, + onStreamStart, + onStreamData, + onRequestEnd, + onStreamTools, + onBeforeCallTool, + onPostCallTool, + onPostCallTools, + onMessageProcessed, + onConversationEnd + } +} diff --git a/packages/plugins/robot/src/composables/modes/useChatMode.ts b/packages/plugins/robot/src/composables/modes/useChatMode.ts new file mode 100644 index 0000000000..eadbfa74e0 --- /dev/null +++ b/packages/plugins/robot/src/composables/modes/useChatMode.ts @@ -0,0 +1,177 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { removeLoading, serializeError } from '../../utils' +import useModelConfig from '../core/useConfig' +import useMcpServer from '../features/useMcp' +import type { ModeHooks } from '../../types/mode.types' +import { ChatMode } from '../../types/mode.types' + +const updateToolCallRenderContent = (tool: Record, renderContent: any[]) => { + const currentToolCallContent = renderContent.find((item) => item.type === 'tool' && item.toolCallId === tool.id) + if (currentToolCallContent) { + currentToolCallContent.status = 'running' + if (!currentToolCallContent.content) { + currentToolCallContent.content = {} + } + currentToolCallContent.content.params = tool.parsedArgs || tool.function!.arguments || {} + } else { + renderContent.push({ + type: 'tool', + name: tool.name || tool.function!.name, + status: 'running', + content: { + params: tool.parsedArgs || tool.function!.arguments || {} + }, + formatPretty: true, + toolCallId: tool.id + }) + } +} + +/** + * Chat 模式实现 + * 特点: + * - 标准的对话模式 + * - 支持 MCP 工具调用 + * - 简单的 loading 处理 + * - 无需 schema 更新 + */ +export default function useChatMode(): ModeHooks { + const { getSelectedModelInfo } = useModelConfig() + + // ========== 配置方法 ========== + const getApiUrl = () => '/app-center/api/chat/completions' + + const getContentType = () => 'markdown' + + const getLoadingType = () => 'loading' + + // ========== 生命周期钩子 ========== + const onConversationStart = (conversationState: any, messages: any[], apis: any) => { + const conversation = conversationState.conversations.find((item: any) => item.id === conversationState.currentId) + + // 确保会话元数据中记录为 Chat 模式 + if (!conversation.metadata?.chatMode || conversation.metadata.chatMode !== ChatMode.Chat) { + apis.updateMetadata(conversationState.currentId, { chatMode: ChatMode.Chat }) + apis.saveConversations() + } + + // Chat 模式简单移除 loading + removeLoading(messages) + } + + const onMessageSent = () => { + // Chat 模式暂无特殊处理 + } + + const onBeforeRequest = async (requestParams: any) => { + const tools = await useMcpServer().getLLMTools() + const { model, baseUrl, config, capabilities } = getSelectedModelInfo() + + // 添加 MCP 工具 + if (!requestParams.tools && tools?.length && capabilities?.toolCalling !== false) { + Object.assign(requestParams, { tools }) + } + + requestParams.baseUrl = baseUrl + requestParams.model = model + + if (capabilities?.reasoning?.extraBody) { + Object.assign( + requestParams, + config?.enableThinking ? capabilities.reasoning.extraBody.enable : capabilities.reasoning.extraBody.disable + ) + } + + return requestParams + } + + const onStreamStart = (messages: any[]) => { + removeLoading(messages) + } + + const onStreamData = (_data: object, _content: string | object, _messages: any[]) => { + // Chat 模式不需要处理流式数据 + } + + const onRequestEnd = async ( + finishReason: string, + _content: string, + messages: any[], + extraData?: Record + ) => { + if (finishReason === 'aborted' || finishReason === 'error') { + removeLoading(messages) + messages.at(-1)!.renderContent.push({ type: 'text', content: serializeError(extraData.error) }) + } + } + + const onStreamTools = (tools: Record[], { currentMessage }: { currentMessage: any }) => { + tools.forEach((tool) => updateToolCallRenderContent(tool, currentMessage.renderContent)) + } + + const onBeforeCallTool = (tool: Record, { currentMessage }: { currentMessage: any }) => { + updateToolCallRenderContent(tool, currentMessage.renderContent) + } + + const onPostCallTool = ( + tool: Record, + toolCallResult: object | string, + toolCallStatus: string, + { currentMessage }: { currentMessage: any } + ) => { + currentMessage.renderContent.at(-1)!.status = toolCallStatus + currentMessage.renderContent.at(-1)!.content = { + params: tool.parsedArgs, + result: toolCallResult + } + } + + const onPostCallTools = (_toolsResult: Record[], { currentMessage }: { currentMessage: any }) => { + currentMessage.renderContent.push({ type: 'loading', content: '' }) + } + + const onMessageProcessed = async ( + _finishReason: string, + _content: string, + _messages: any[], + _context: { abortControllerMap: Record } + ) => { + // Chat 模式不需要处理消息 + } + + const onConversationEnd = (_conversationId: string) => { + // Chat 模式暂无特殊处理 + } + + return { + // 配置方法 + getApiUrl, + getContentType, + getLoadingType, + + // 生命周期钩子 + onConversationStart, + onMessageSent, + onBeforeRequest, + onStreamStart, + onStreamData, + onRequestEnd, + onStreamTools, + onBeforeCallTool, + onPostCallTool, + onPostCallTools, + onMessageProcessed, + onConversationEnd + } +} diff --git a/packages/plugins/robot/src/composables/modes/useMode.ts b/packages/plugins/robot/src/composables/modes/useMode.ts new file mode 100644 index 0000000000..6b157f21f0 --- /dev/null +++ b/packages/plugins/robot/src/composables/modes/useMode.ts @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import useModelConfig from '../core/useConfig' +import useAgentMode from './useAgentMode' +import useChatMode from './useChatMode' +import type { ModeHooks } from '../../types/mode.types' +import { ChatMode } from '../../types/mode.types' +import { getRobotServiceOptions } from '../../utils' + +/** + * 模式注册表 + * 配置式管理所有聊天模式,便于扩展新模式 + */ +const modeRegistry: Record ModeHooks> = { + [ChatMode.Agent]: useAgentMode, + [ChatMode.Chat]: useChatMode +} + +// 缓存模式实例,避免重复创建 +const modeInstanceCache: Record = {} + +/** + * 获取指定模式的实例(带缓存) + */ +const getModeInstance = (chatMode: string): ModeHooks => { + if (!modeInstanceCache[chatMode]) { + const modeFactory = getRobotServiceOptions()?.modeImplementation?.[chatMode] || modeRegistry[chatMode] + if (!modeFactory) { + throw new Error(`Unknown chat mode: ${chatMode}. Available modes: ${Object.keys(modeRegistry).join(', ')}`) + } + modeInstanceCache[chatMode] = modeFactory() + } + return modeInstanceCache[chatMode] +} + +/** + * 获取当前激活的模式实例 + */ +const getCurrentMode = (): ModeHooks => { + const { getSelectedModelInfo } = useModelConfig() + return getModeInstance(getSelectedModelInfo().config!.chatMode) +} + +/** + * 模式统一入口 + * 返回代理对象,每次调用钩子时动态获取当前模式 + * 这样可以支持运行时模式切换 + */ +export default function useMode(): ModeHooks { + return { + // 配置方法代理 + getApiUrl: () => getCurrentMode().getApiUrl(), + getContentType: () => getCurrentMode().getContentType(), + getLoadingType: () => getCurrentMode().getLoadingType(), + + // 生命周期钩子代理 + onConversationStart: (...args) => getCurrentMode().onConversationStart(...args), + onMessageSent: (...args) => getCurrentMode().onMessageSent(...args), + onBeforeRequest: (...args) => getCurrentMode().onBeforeRequest(...args), + onStreamStart: (...args) => getCurrentMode().onStreamStart(...args), + onStreamData: (...args) => getCurrentMode().onStreamData(...args), + onRequestEnd: (...args) => getCurrentMode().onRequestEnd(...args), + onStreamTools: (...args) => getCurrentMode().onStreamTools(...args), + onBeforeCallTool: (...args) => getCurrentMode().onBeforeCallTool(...args), + onPostCallTool: (...args) => getCurrentMode().onPostCallTool(...args), + onPostCallTools: (...args) => getCurrentMode().onPostCallTools(...args), + onMessageProcessed: (...args) => getCurrentMode().onMessageProcessed(...args), + onConversationEnd: (...args) => getCurrentMode().onConversationEnd(...args) + } +} + +// 导出类型供其他模块使用 +export type { ModeHooks } from '../../types/mode.types' diff --git a/packages/plugins/robot/src/composables/useChat.ts b/packages/plugins/robot/src/composables/useChat.ts new file mode 100644 index 0000000000..bed0cf49e0 --- /dev/null +++ b/packages/plugins/robot/src/composables/useChat.ts @@ -0,0 +1,231 @@ +import { nextTick } from 'vue' +import { STATUS, type ChatMessage } from '@opentiny/tiny-robot-kit' +import { formatMessages, removeLoading } from '../utils' +import { getClientConfig as getConfig, updateClientConfig as updateConfig, client } from '../services/aiClient' +import useModelConfig from './core/useConfig' +import useMode from './modes/useMode' +import { createStreamDataHandler } from './core/useMessageStream' +import type { ChatRequestData, ProviderConfig } from '../services/OpenAICompatibleProvider' +import { createToolCallHandler } from './features/useToolCalls' +import apiService from '../services/api' +import { useConversationAdapter } from './core/useConversation' + +const { + // 配置方法 + getApiUrl, + getContentType, + getLoadingType, + // 生命周期钩子 + onConversationStart, + onMessageSent, + onBeforeRequest, + onStreamStart, + onStreamData, + onRequestEnd, + onStreamTools, + onBeforeCallTool, + onPostCallTool, + onPostCallTools, + onMessageProcessed, + onConversationEnd +} = useMode() + +const { robotSettingState, updateChatModeState, getSelectedModelInfo } = useModelConfig() + +// 本次对话的状态,从用户发送消息开始到AI返回或用户主动终止结束 +enum CHAT_STATUS { + IDLE = 'idle', // 本轮对话开始后,没有请求在流式返回(可能是等待请求,也可能是请求间隙) + STREAMING = 'streaming', // 当前有请求正在流式返回 + FINISHED = 'finished' // 本轮对话结束 +} + +let chatStatus: CHAT_STATUS = CHAT_STATUS.IDLE + +const abortControllerMap: Record = {} + +// 使用工厂函数创建流式数据处理器,解耦业务逻辑 +const handleStreamData = createStreamDataHandler({ + getContentType, + hooks: { + onStreamStart, + onStreamData, + onStreamTools + }, + statusManager: { + isStreaming: () => chatStatus === CHAT_STATUS.STREAMING, + setStreaming: () => { + chatStatus = CHAT_STATUS.STREAMING + } + } +}) + +const beforeRequest = async (params: ChatRequestData): Promise => { + const requestParams = await onBeforeRequest(params) + const { service } = getSelectedModelInfo() + + if (getConfig().apiKey !== service!.apiKey) { + updateConfig({ apiKey: service!.apiKey }) + } + if (getConfig().apiUrl !== getApiUrl()) { + updateConfig({ apiUrl: getApiUrl() }) + } + return requestParams +} + +const initChatClient = () => { + const { service, model } = getSelectedModelInfo() + + const config: ProviderConfig = { + apiKey: service?.apiKey || '', + apiUrl: getApiUrl(), + defaultModel: model || 'deepseek-v3', + axiosClient: () => apiService.getHttpClient(), + httpClientType: 'axios', + beforeRequest + } + updateConfig(config) +} + +const handleFinishRequest = async (finishReason, messages, contextMessages, messageState) => { + chatStatus = CHAT_STATUS.IDLE + const lastMessage = messages.at(-1) + + await onRequestEnd(finishReason, lastMessage.content, messages) // 本次请求结束 + + if (finishReason === 'tool_calls' && lastMessage.tool_calls?.length) { + await handleToolCall(lastMessage.tool_calls, messages, contextMessages) // eslint-disable-line + } + + if (finishReason === 'aborted' || messageState?.status === STATUS.ABORTED) { + messageState.status = STATUS.ABORTED + } else { + messageState.status = STATUS.FINISHED + } + + chatStatus = CHAT_STATUS.FINISHED +} + +const handleRequestError = async (error, messages, messageState) => { + chatStatus = CHAT_STATUS.FINISHED + await onRequestEnd('error', messages.at(-1).content, messages, { error }) // 本次请求结束 + messageState.status = STATUS.ERROR +} + +// 使用 conversation 适配器,将业务逻辑与 conversation 管理解耦 +const { + messageManager, + conversationState, + createConversation: createConversationBase, + switchConversation: switchConversationBase, + autoSetTitle: autoSetTitleBase, + ...conversationMethods +} = useConversationAdapter({ + client, + onStreamData: handleStreamData, + onFinishRequest: handleFinishRequest, + onMessageProcessed: async (finishReason, content, messages) => { + await onMessageProcessed(finishReason, content, messages, { abortControllerMap }) + } +}) + +// 使用工厂函数创建工具调用处理器 +const handleToolCall = createToolCallHandler({ + client, + getAbortController: () => { + abortControllerMap.toolCall = new AbortController() + return abortControllerMap.toolCall + }, + formatMessages, + hooks: { + onBeforeCallTool, + onPostCallTool, + onPostCallTools + }, + streamHandlers: { + onData: handleStreamData, + onError: handleRequestError, + onDone: handleFinishRequest + }, + getMessageState: () => messageManager.messageState +}) + +// 包装 conversation 方法,添加业务特定逻辑 +const createConversation = (title = '新会话', chatMode = robotSettingState.chatMode) => { + onConversationEnd(conversationState.currentId!) + return createConversationBase(title, { chatMode }) +} + +const switchConversation = (conversationId: string) => { + onConversationEnd(conversationState.currentId!) + return switchConversationBase(conversationId, (state, messages, methods) => { + onConversationStart(state, messages, methods) + }) +} + +const autoSetTitle = () => { + if (conversationState.currentId) { + autoSetTitleBase(conversationState.currentId) + } +} + +const sendUserMessage = async () => { + nextTick(() => { + const assistantMessage: ChatMessage = { + role: 'assistant', + content: '', + renderContent: [{ type: getLoadingType() }] + } + messageManager.messages.value.push(assistantMessage) + }) + await messageManager.send() + if (messageManager.messageState.status === STATUS.ERROR) { + removeLoading(messageManager.messages.value) + await handleRequestError( + messageManager.messageState.errorMsg, + messageManager.messages.value, + messageManager.messageState + ) + } + onMessageSent() + autoSetTitle() +} + +const abortRequest = () => { + Object.values(abortControllerMap).forEach((controller) => controller?.abort()) + for (const key of Object.keys(abortControllerMap)) { + delete abortControllerMap[key] + } + + messageManager.abortRequest() + messageManager.messageState.status = STATUS.ABORTED + onRequestEnd('aborted', messageManager.messages.value.at(-1)?.content as string, messageManager.messages.value) +} + +const changeChatMode = (chatMode: string) => { + // 空会话更新metadata + const usedConversationId = conversationState.currentId + const newConversationId = createConversation('新会话', chatMode) + if (usedConversationId === newConversationId) { + conversationMethods.updateMetadata(newConversationId, { chatMode }) + conversationMethods.saveConversations() + } + + updateChatModeState(chatMode) + updateConfig({ apiUrl: getApiUrl() }) +} + +export default function () { + return { + initChatClient, + updateConfig, + ...messageManager, + sendUserMessage, + changeChatMode, + abortRequest, + conversationState, + ...conversationMethods, + switchConversation, + createConversation, + autoSetTitle + } +} diff --git a/packages/plugins/robot/src/constants/index.ts b/packages/plugins/robot/src/constants/index.ts new file mode 100644 index 0000000000..504e83352d --- /dev/null +++ b/packages/plugins/robot/src/constants/index.ts @@ -0,0 +1 @@ +export * from './model-config' diff --git a/packages/plugins/robot/src/constants/model-config.ts b/packages/plugins/robot/src/constants/model-config.ts new file mode 100644 index 0000000000..f5082d444f --- /dev/null +++ b/packages/plugins/robot/src/constants/model-config.ts @@ -0,0 +1,94 @@ +const reasoningExtraBody = { + extraBody: { + enable: { + enable_thinking: true, + thinking_budget: 1000 + }, + disable: null + } +} + +export const DEFAULT_LLM_MODELS = [ + { + provider: 'bailian', + label: '阿里云百炼', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + allowEmptyApiKey: false, + models: [ + // Agent/chat + { + label: 'Qwen 通用模型(Plus)', + name: 'qwen-plus', + capabilities: { + toolCalling: true, + reasoning: reasoningExtraBody + } + }, + // 备注:千问多模态模型不支持工具调用; + { + label: 'Qwen VL视觉理解模型(PLUS)', + name: 'qwen3-vl-plus', + capabilities: { + vision: true, + reasoning: reasoningExtraBody + } + }, + { + label: 'Qwen Coder编程模型(PLUS)', + name: 'qwen3-coder-plus', + capabilities: { + toolCalling: true, + reasoning: reasoningExtraBody + } + }, + { + label: 'DeepSeek(v3.2)', + name: 'deepseek-v3.2-exp', + capabilities: { + toolCalling: true, + reasoning: reasoningExtraBody + } + }, + // 小参数模型 + { + label: 'Qwen 通用模型(Flash)', + name: 'qwen-flash', + capabilities: { + toolCalling: true, + compact: true + } + }, + { + label: 'Qwen Coder编程模型(Flash)', + name: 'qwen3-coder-flash', + capabilities: { + toolCalling: true, + compact: true + } + }, + { label: 'Qwen3(14b)', name: 'qwen3-14b', capabilities: { compact: true, toolCalling: true } }, + { label: 'Qwen3(8b)', name: 'qwen3-8b', capabilities: { compact: true, toolCalling: true } } + ] + }, + { + provider: 'deepseek', + label: 'DeepSeek', + baseUrl: 'https://api.deepseek.com/v1', + allowEmptyApiKey: false, + models: [ + { + label: 'DeepSeek', + name: 'deepseek-chat', + capabilities: { + toolCalling: true, + reasoning: { + extraBody: { + enable: { model: 'deepseek-reasoner' }, + disable: { model: 'deepseek-chat' } + } + } + } + } + ] + } +] diff --git a/packages/plugins/robot/src/constants/prompts/data/components.json b/packages/plugins/robot/src/constants/prompts/data/components.json new file mode 100644 index 0000000000..878c2aa355 --- /dev/null +++ b/packages/plugins/robot/src/constants/prompts/data/components.json @@ -0,0 +1,998 @@ +[ + { + "component": "Box", + "name": "盒子容器", + "demo": { + "componentName": "div", + "props": {} + } + }, + { + "component": "Text", + "name": "文本", + "properties": ["text"], + "events": ["onClick"], + "demo": { + "componentName": "Text", + "props": { + "style": "display: inline-block;", + "text": "TinyEngine 前端可视化设计器,为设计器开发者提供定制服务,在线构建出自己专属的设计器。" + } + } + }, + { + "component": "Icon", + "name": "图标", + "properties": ["name"], + "events": ["onClick"], + "demo": { + "componentName": "Icon", + "props": { + "name": "IconDel" + } + } + }, + { + "component": "Img", + "name": "图片", + "properties": ["src"], + "events": ["onClick"], + "demo": { + "componentName": "Img", + "props": { + "src": "https://tinyengine-assets.obs.cn-north-4.myhuaweicloud.com/files/designer-default-icon.jpg" + } + } + }, + { + "component": "Slot", + "name": "插槽", + "properties": ["name", "params"], + "events": [], + "demo": { + "componentName": "Slot", + "props": {} + } + }, + { + "component": "RouterView", + "name": "路由视图", + "properties": [], + "demo": { + "componentName": "RouterView", + "props": {} + } + }, + { + "component": "RouterLink", + "name": "路由链接", + "properties": ["to", "activeClass", "exactActiveClass"], + "demo": { + "componentName": "RouterLink", + "props": {}, + "children": [ + { + "componentName": "Text", + "props": { + "text": "路由文本" + } + } + ] + } + }, + { + "component": "TinyLayout", + "name": "栅格布局", + "properties": ["cols", "tag"], + "demo": { + "componentName": "TinyLayout", + "props": {}, + "children": [ + { + "componentName": "TinyRow", + "props": { + "style": "padding: 10px;" + }, + "children": [ + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + }, + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + }, + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + }, + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + } + ] + }, + { + "componentName": "TinyRow", + "props": { + "style": "padding: 10px;" + }, + "children": [ + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + }, + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + }, + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + }, + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + } + ] + } + ] + } + }, + { + "component": "TinyButton", + "name": "按钮", + "properties": ["text", "size", "disabled", "type"], + "events": ["onClick"], + "demo": { + "componentName": "TinyButton", + "props": { + "text": "按钮文案" + } + } + }, + { + "component": "TinyButtonGroup", + "name": "互斥按钮组", + "properties": ["data", "size", "plain", "disabled"], + "events": [], + "demo": { + "componentName": "TinyButtonGroup", + "props": { + "data": [ + { + "text": "Button1", + "value": "1" + }, + { + "text": "Button2", + "value": "2" + }, + { + "text": "Button3", + "value": "3" + } + ], + "modelValue": "1" + } + } + }, + { + "component": "TinySearch", + "name": "搜索框", + "properties": ["modelValue", "disabled", "placeholder", "clearable", "isEnterSearch"], + "events": ["onChange", "onSearch"], + "demo": { + "componentName": "TinySearch", + "props": { + "modelValue": "", + "placeholder": "输入关键词" + } + } + }, + { + "component": "TinyForm", + "name": "表单", + "properties": ["disabled", "label-width", "inline", "label-align", "label-suffix", "label-position"], + "events": ["onValidate", "onInput", "onBlur", "onFocus", "onClear"], + "demo": { + "componentName": "TinyForm", + "props": { + "labelWidth": "80px", + "labelPosition": "top" + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "人员" + }, + "children": [ + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": "" + } + } + ] + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "密码" + }, + "children": [ + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": "", + "type": "password" + } + } + ] + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "" + }, + "children": [ + { + "componentName": "TinyButton", + "props": { + "text": "提交", + "type": "primary", + "style": "margin-right: 10px" + } + }, + { + "componentName": "TinyButton", + "props": { + "text": "重置", + "type": "primary" + } + } + ] + } + ] + } + }, + { + "component": "TinySelect", + "name": "下拉框", + "properties": ["modelValue", "placeholder", "clearable", "searchable", "disabled", "options", "multiple"], + "events": ["onChange", "onUpdate:modelValue", "onBlur", "onFocus", "onClear", "onRemoveTag"], + "demo": { + "componentName": "TinySelect", + "props": { + "modelValue": "", + "placeholder": "请选择", + "options": [ + { + "value": "1", + "label": "黄金糕" + }, + { + "value": "2", + "label": "双皮奶" + } + ] + } + } + }, + { + "component": "TinySwitch", + "name": "开关", + "properties": ["disabled", "modelValue", "true-value", "false-value", "mini"], + "events": ["onChange", "onUpdate:modelValue"], + "demo": { + "componentName": "TinySwitch", + "props": { + "modelValue": "" + } + } + }, + { + "component": "TinyCheckboxGroup", + "name": "复选框组", + "properties": ["modelValue", "disabled", "options", "type"], + "events": ["onChange", "onUpdate:modelValue"], + "demo": { + "componentName": "TinyCheckboxGroup", + "props": { + "modelValue": ["name1", "name2"], + "type": "checkbox", + "options": [ + { + "text": "复选框1", + "label": "name1" + }, + { + "text": "复选框2", + "label": "name2" + }, + { + "text": "复选框3", + "label": "name3" + } + ] + } + } + }, + { + "component": "TinyInput", + "name": "输入框", + "properties": ["modelValue", "type", "rows", "placeholder", "clearable", "disabled", "size"], + "events": ["onChange", "onInput", "onUpdate:modelValue", "onBlur", "onFocus", "onClear"], + "slots": ["prefix", "suffix"], + "demo": { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": "" + } + } + }, + { + "component": "TinyRadio", + "name": "单选", + "properties": ["text", "label", "modelValue", "disabled"], + "events": ["onChange", "onUpdate:modelValue"], + "demo": { + "componentName": "TinyRadio", + "props": { + "label": "1", + "text": "单选文本" + } + } + }, + { + "component": "TinyCheckbox", + "name": "复选框", + "properties": ["modelValue", "disabled", "checked", "text"], + "events": ["onChange", "onUpdate:modelValue"], + "demo": { + "componentName": "TinyCheckbox", + "props": { + "text": "复选框文案" + } + } + }, + { + "component": "TinyDatePicker", + "name": "日期选择", + "properties": ["modelValue", "type", "placeholder", "clearable", "disabled", "readonly", "size"], + "events": ["onChange", "onInput", "onUpdate:modelValue", "onBlur", "onFocus", "onClear"], + "demo": { + "componentName": "TinyDatePicker", + "props": { + "placeholder": "请输入", + "modelValue": "" + } + } + }, + { + "component": "TinyNumeric", + "name": "数字输入框", + "properties": [ + "modelValue", + "placeholder", + "allow-empty", + "disabled", + "size", + "controls", + "controls-position", + "precision", + "step", + "max", + "min" + ], + "events": ["onChange", "onInput", "onUpdate:modelValue", "onBlur", "onFocus", "onClear"], + "demo": { + "componentName": "TinyNumeric", + "props": { + "allow-empty": true, + "placeholder": "请输入", + "controls-position": "right", + "step": 1 + } + } + }, + { + "component": "TinyTransfer", + "name": "穿梭框", + "properties": ["modelValue", "data", "filterable", "showAllBtn", "toLeftDisable", "toRightDisable", "titles"], + "events": ["onChange", "onLeftCheckChange", "onRightCheckChange"], + "demo": { + "componentName": "TinyTransfer", + "props": { + "modelValue": [3], + "data": [ + { + "key": 1, + "label": "备选项1", + "disabled": false + }, + { + "key": 2, + "label": "备选项2", + "disabled": false + }, + { + "key": 3, + "label": "备选项3", + "disabled": false + }, + { + "key": 4, + "label": "备选项4", + "disabled": false + } + ] + } + } + }, + { + "component": "TinyGrid", + "name": "表格", + "properties": [ + "data", + "columns", + "fetchData", + "pager", + "resizable", + "row-id", + "select-config", + "edit-rules", + "edit-config", + "expand-config", + "sortable" + ], + "events": [ + "onFilterChange", + "onSortChange", + "onSelectAll", + "onSelectChange", + "onToggleExpandChange", + "onCurrentChange" + ], + "demo": { + "componentName": "TinyGrid", + "props": { + "editConfig": { + "trigger": "click", + "mode": "cell", + "showStatus": true + }, + "columns": [ + { + "type": "index", + "width": 60 + }, + { + "type": "selection", + "width": 60 + }, + { + "field": "employees", + "title": "员工数" + }, + { + "field": "created_date", + "title": "创建日期" + }, + { + "field": "city", + "title": "城市" + } + ], + "data": [ + { + "id": "1", + "name": "GFD科技有限公司", + "city": "福州", + "employees": 800, + "created_date": "2014-04-30 00:56:00", + "boole": false + }, + { + "id": "2", + "name": "WWW科技有限公司", + "city": "深圳", + "employees": 300, + "created_date": "2016-07-08 12:36:22", + "boole": true + } + ] + } + } + }, + { + "component": "TinyPager", + "name": "分页", + "properties": ["currentPage", "pageSize", "pageSizes", "total", "layout"], + "events": ["onCurrentChange ", "onPrevClick ", "onNextClick"], + "demo": { + "componentName": "TinyPager", + "props": { + "layout": "total, sizes, prev, pager, next", + "total": 100, + "pageSize": 10, + "currentPage": 1 + } + } + }, + { + "component": "TinyCarousel", + "name": "走马灯", + "properties": [ + "arrow", + "autoplay", + "tabs", + "height", + "indicator-position", + "initial-index", + "interval", + "loop", + "show-title", + "trigger", + "type" + ], + "events": [], + "demo": { + "componentName": "TinyCarousel", + "props": { + "height": "180px" + }, + "children": [ + { + "componentName": "TinyCarouselItem", + "props": { + "title": "carousel-item-a" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "margin:10px 0 0 30px" + } + } + ] + }, + { + "componentName": "TinyCarouselItem", + "props": { + "title": "carousel-item-b" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "margin:10px 0 0 30px" + } + } + ] + } + ] + } + }, + { + "component": "TinyDialogBox", + "name": "对话框", + "properties": ["title", "visible", "width", "draggable", "center", "dialog-class", "append-to-body", "show-close"], + "events": ["onClose", "onUpdate:visible"], + "slots": ["title", "footer"], + "demo": { + "componentName": "TinyDialogBox", + "props": { + "visible": true, + "show-close": true, + "title": "dialogBox title" + }, + "children": [ + { + "componentName": "div" + } + ] + } + }, + { + "component": "TinyCollapse", + "name": "折叠面板", + "properties": ["modelValue"], + "events": ["onChange", "onUpdate:modelValue"], + "demo": { + "componentName": "TinyCollapse", + "props": { + "modelValue": "collapse1" + }, + "children": [ + { + "componentName": "TinyCollapseItem", + "props": { + "name": "collapse1", + "title": "折叠项1" + }, + "children": [ + { + "componentName": "div" + } + ] + }, + { + "componentName": "TinyCollapseItem", + "props": { + "name": "collapse2", + "title": "折叠项2" + }, + "children": [ + { + "componentName": "div" + } + ] + }, + { + "componentName": "TinyCollapseItem", + "props": { + "name": "collapse3", + "title": "折叠项3" + }, + "children": [ + { + "componentName": "div" + } + ] + } + ] + } + }, + { + "component": "TinyPopeditor", + "name": "弹出编辑", + "properties": ["modelValue", "placeholder", "show-clear-btn", "disabled", "auto-lookup"], + "events": ["onChange", "onUpdate:modelValue", "onClose", "onPageChange"], + "demo": { + "componentName": "TinyPopeditor", + "props": { + "modelValue": "", + "placeholder": "请选择", + "grid-op": { + "columns": [ + { + "field": "id", + "title": "ID", + "width": 40 + }, + { + "field": "name", + "title": "名称", + "showOverflow": "tooltip" + }, + { + "field": "province", + "title": "省份", + "width": 80 + }, + { + "field": "city", + "title": "城市", + "width": 80 + } + ], + "data": [ + { + "id": "1", + "name": "GFD科技有限公司GFD科技有限公司GFD科技有限公司GFD科技有限公司GFD科技有限公司GFD科技有限公司GFD科技有限公司", + "city": "福州", + "province": "福建" + }, + { + "id": "2", + "name": "WWW科技有限公司", + "city": "深圳", + "province": "广东" + }, + { + "id": "3", + "name": "RFV有限责任公司", + "city": "中山", + "province": "广东" + }, + { + "id": "4", + "name": "TGB科技有限公司", + "city": "龙岩", + "province": "福建" + }, + { + "id": "5", + "name": "YHN科技有限公司", + "city": "韶关", + "province": "广东" + }, + { + "id": "6", + "name": "WSX科技有限公司", + "city": "黄冈", + "province": "武汉" + } + ] + } + } + } + }, + { + "component": "TinyTree", + "name": "树", + "properties": [ + "show-checkbox", + "data", + "node-key", + "render-content", + "icon-trigger-click-node", + "expand-icon", + "shrink-icon" + ], + "events": ["onCheck", "onNodeClick"], + "demo": { + "componentName": "TinyTree", + "props": { + "data": [ + { + "label": "一级 1", + "children": [ + { + "label": "二级 1-1", + "children": [ + { + "label": "三级 1-1-1" + } + ] + } + ] + }, + { + "label": "一级 2", + "children": [ + { + "label": "二级 2-1", + "children": [ + { + "label": "三级 2-1-1" + } + ] + }, + { + "label": "二级 2-2", + "children": [ + { + "label": "三级 2-2-1" + } + ] + } + ] + } + ] + } + } + }, + { + "component": "TinyTooltip", + "name": "文字提示框", + "properties": ["placement", "content", "render-content", "modelValue", "manual"], + "events": [], + "slots": ["content"], + "demo": { + "componentName": "TinyTooltip", + "props": { + "content": "Top Left 提示文字", + "placement": "top-start", + "manual": true, + "modelValue": true + }, + "children": [ + { + "componentName": "span", + "children": [ + { + "componentName": "div", + "props": {} + } + ] + }, + { + "componentName": "Template", + "props": { + "slot": "content" + }, + "children": [ + { + "componentName": "span", + "children": [ + { + "componentName": "div", + "props": { + "placeholder": "提示内容" + } + } + ] + } + ] + } + ] + } + }, + { + "component": "TinyPopover", + "name": "提示框", + "properties": [ + "modelValue", + "placement", + "trigger", + "popper-class", + "visible-arrow", + "append-to-body", + "arrow-offset", + "close-delay", + "content", + "disabled", + "offset", + "open-delay", + "popper-options", + "title", + "transform-origin", + "transition", + "width" + ], + "events": ["onUpdate:modelValue"], + "demo": { + "componentName": "TinyPopover", + "props": { + "width": 200, + "title": "弹框标题", + "trigger": "manual", + "modelValue": true + }, + "children": [ + { + "componentName": "Template", + "props": { + "slot": "reference" + }, + "children": [ + { + "componentName": "div", + "props": { + "placeholder": "触发源" + } + } + ] + }, + { + "componentName": "Template", + "props": { + "slot": "default" + }, + "children": [ + { + "componentName": "div", + "props": { + "placeholder": "提示内容" + } + } + ] + } + ] + } + }, + { + "component": "TinyTimeLine", + "name": "时间线", + "properties": ["vertical", "active", "data"], + "events": ["onClick"], + "demo": { + "componentName": "TinyTimeLine", + "props": { + "active": "2", + "data": [ + { + "name": "已下单" + }, + { + "name": "运输中" + }, + { + "name": "已签收" + } + ] + } + } + }, + { + "component": "TinyBreadcrumb", + "name": "面包屑", + "properties": ["separator", "options", "textField"], + "events": ["onSelect"], + "demo": { + "componentName": "TinyBreadcrumb", + "props": { + "options": [ + { + "to": "{ path: '/' }", + "label": "首页" + }, + { + "to": "{ path: '/breadcrumb' }", + "label": "产品" + }, + { + "replace": "true", + "label": "软件" + } + ] + } + } + }, + { + "component": "TinyTabs", + "name": "标签页", + "properties": ["tabs", "modelValue", "with-add", "with-close", "tab-style"], + "events": ["onClick", "onEdit", "onClose"], + "demo": { + "componentName": "TinyTabs", + "props": { + "modelValue": "first" + }, + "children": [ + { + "componentName": "TinyTabItem", + "props": { + "title": "标签页1", + "name": "first" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "margin:10px 0 0 30px" + } + } + ] + }, + { + "componentName": "TinyTabItem", + "props": { + "title": "标签页2", + "name": "second" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "margin:10px 0 0 30px" + } + } + ] + } + ] + } + } +] diff --git a/packages/plugins/robot/src/constants/prompts/data/examples.json b/packages/plugins/robot/src/constants/prompts/data/examples.json new file mode 100644 index 0000000000..c5bbf1eb46 --- /dev/null +++ b/packages/plugins/robot/src/constants/prompts/data/examples.json @@ -0,0 +1,148 @@ +{ + "chatMessageList": { + "name": "Chat Message List Example", + "description": "A complete example of adding a chat message list with input and send functionality", + "note": "All JavaScript code uses string concatenation, not template literals", + "patch": [ + { + "op": "add", + "path": "/state/messages", + "value": [ + { + "content": "hello" + } + ] + }, + { + "op": "add", + "path": "/state/inputMessage", + "value": "" + }, + { + "op": "add", + "path": "/children/0", + "value": { + "componentName": "div", + "id": "25153243", + "props": { + "className": "component-base-style" + }, + "children": [ + { + "componentName": "h1", + "props": { + "className": "component-base-style" + }, + "children": "消息列表", + "id": "53222591" + }, + { + "componentName": "div", + "props": { + "className": "component-base-style div-uhqto", + "alignItems": "flex-start" + }, + "children": [ + { + "componentName": "div", + "props": { + "className": "component-base-style div-vinko", + "onClick": { + "type": "JSExpression", + "value": "this.onClickMessage", + "params": ["message", "index"] + }, + "key": { + "type": "JSExpression", + "value": "index" + } + }, + "children": [ + { + "componentName": "Text", + "props": { + "style": "display: inline-block;", + "text": { + "type": "JSExpression", + "value": "message.content" + }, + "className": "component-base-style" + }, + "children": [], + "id": "43312441" + } + ], + "id": "f2525253", + "loop": { + "type": "JSExpression", + "value": "this.state.messages" + }, + "loopArgs": ["message", "index"] + } + ], + "id": "544265d9" + }, + { + "componentName": "div", + "props": { + "className": "component-base-style div-iarpn" + }, + "children": [ + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": { + "type": "JSExpression", + "value": "this.state.inputMessage", + "model": true + }, + "className": "component-base-style", + "type": "textarea" + }, + "children": [], + "id": "24651354" + }, + { + "componentName": "TinyButton", + "props": { + "text": "发送", + "className": "component-base-style", + "onClick": { + "type": "JSExpression", + "value": "this.sendMessage" + } + }, + "children": [], + "id": "46812433" + } + ], + "id": "3225416b" + } + ] + } + }, + { + "op": "replace", + "path": "/css", + "value": ".page-base-style {\n padding: 24px;\n background: #ffffff;\n}\n.block-base-style {\n margin: 16px;\n}\n.component-base-style {\n margin: 8px;\n}\n.div-vinko {\n margin: 8px;\n border-width: 1px;\n border-color: #ebeaea;\n border-style: solid;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-radius: 50px;\n}\n.div-iarpn {\n margin: 8px;\n display: flex;\n align-items: center;\n}\n.div-uhqto {\n margin: 8px;\n display: flex;\n flex-direction: column;\n}\n" + }, + { + "op": "add", + "path": "/methods/sendMessage", + "value": { + "type": "JSFunction", + "value": "function sendMessage(event) {\n this.state.messages.push({ content: this.state.inputMessage })\n this.state.inputMessage = ''\n}\n" + } + }, + { + "op": "add", + "path": "/methods/onClickMessage", + "value": { + "type": "JSFunction", + "value": "function onClickMessage(event, message, index) {\n console.log('这是第' + (index + 1) + '条消息, 消息内容:' + message.content)\n}\n" + } + } + ] + } +} diff --git a/packages/plugins/robot/src/constants/prompts/index.ts b/packages/plugins/robot/src/constants/prompts/index.ts new file mode 100644 index 0000000000..659d47de21 --- /dev/null +++ b/packages/plugins/robot/src/constants/prompts/index.ts @@ -0,0 +1,206 @@ +import agentPrompt from './templates/agent-prompt.md?raw' +import chatPrompt from './templates/chat-prompt.md?raw' +import componentsData from './data/components.json' +import examplesData from './data/examples.json' + +/** + * Convert components array to JSONL format string + */ +const formatComponentsToJsonl = (components: any[]): string => { + return '```jsonl\n' + components.map((comp) => JSON.stringify(comp)).join('\n') + '\n```' +} + +/** + * Format examples object to readable text + */ +const formatExamples = (examples: Record): string => { + return Object.entries(examples) + .map(([_key, example]) => { + const { name, description, note, patch } = example + const header = `### ${name}\n${description ? `${description}\n` : ''}${note ? `**Note**: ${note}\n` : ''}` + const patchContent = JSON.stringify(patch) + return `${header}\n${patchContent}` + }) + .join('\n\n') +} + +type ComponentMaterial = { + component: string + name: { zh_CN: string; en_US?: string } + props: Array<{ + property: string + description?: { zh_CN: string; en_US?: string } + type: string + defaultValue?: any + }> + events: Array<{ + name: string + description?: { zh_CN: string; en_US?: string } + }> + slots: Array<{ + name: string + description?: { zh_CN: string; en_US?: string } + }> +} + +const toPascalCase = (str: string): string => { + if (!str.toLowerCase().startsWith('tiny')) return str + const result = str + .replace(/[-_]([a-z])/g, (_: string, char: string) => char.toUpperCase()) + .replace(/^[a-z]/, (firstChar: string) => firstChar.toUpperCase()) + return result +} + +const nativeComponents = [ + 'p', + 'a', + 'hr', + 'img', + 'h1', + 'video', + 'button', + 'input', + 'form', + 'form-item', + 'select', + 'table', + 'container', + 'text', + 'image' +] + +export const formatComponents = (snippets: any[], getComponent: (name: string) => ComponentMaterial): any[] => { + const ignoreGroups = ['model', 'element-plus'] + const ignoreComponents = [ + ...nativeComponents, + 'Box', + 'CanvasRowColContainer', + 'CanvasFlexBox', + 'CanvasSection', + 'Collection', + 'CanvasNavigation', + 'TinyButtons', + 'TinyCheckboxbuttonGroup', + 'TinyNumeric' // 组件报错,先忽略 + ] + const ignoreProperties = ['id', 'className', 'ref', 'attributes3'] + + const newComponents = snippets + .filter((item: any) => !ignoreGroups.includes(item.group)) + .map((group) => group.children) + .flat() + .filter((item: any) => !ignoreComponents.includes(item.snippetName)) + .map((child) => { + const component: ComponentMaterial = getComponent(toPascalCase(child.snippetName)) + const schema: any = {} + + if (component?.props?.length) { + schema.properties = component.props + ?.filter((prop) => !ignoreProperties.includes(prop.property)) + .map((prop) => ({ + name: prop.property, + description: prop.description?.en_US || prop.description?.zh_CN || '', + type: prop.type || 'string', + ...(prop.defaultValue === undefined ? {} : { default: prop.defaultValue }) + })) + .reduce((acc: Record, cur) => { + const type = cur.type && cur.type.toUpperCase() !== 'STRING' ? `(${cur.type})` : '' + const defaultValue = + cur.default !== undefined && cur.default !== '' ? `(defaultValue: ${JSON.stringify(cur.default)})` : '' + acc[cur.name] = `${cur.description}${type}${defaultValue}` + return acc + }, {}) + } + + if (component?.events?.length) { + schema.events = component.events + .map((event) => ({ + name: event.name, + description: event.description?.en_US || event.description?.zh_CN || '' + })) + .reduce((acc: Record, cur) => { + acc[cur.name] = cur.description + return acc + }, {}) + } + if (component?.slots?.length) { + schema.slots = component.slots + .map((slot) => ({ + name: slot.name, + description: slot.description?.en_US || slot.description?.zh_CN || '' + })) + .reduce((acc: Record, cur) => { + acc[cur.name] = cur.description + return acc + }, {}) + } + + return { + component: toPascalCase(child.snippetName), + name: child.name?.zh_CN || child.name || toPascalCase(child.snippetName), + ...schema, + demo: child.schema + } + }) + return newComponents +} + +/** + * Generate agent system prompt with dynamic components and examples + */ +export const getAgentSystemPrompt = ( + components = componentsData, + currentPageSchema: object, + referenceContext: string, + imageAssets: any[] +) => { + const componentsList = formatComponentsToJsonl(components) + + const examplesSection = formatExamples(examplesData) + + const currentPageSchemaStr = JSON.stringify(currentPageSchema) + + const prompt = agentPrompt + .replace('{{COMPONENTS_LIST}}', componentsList) + .replace('{{EXAMPLES_SECTION}}', examplesSection) + .replace('{{CURRENT_PAGE_SCHEMA}}', currentPageSchemaStr) + .replace('{{REFERENCE_KNOWLEDGE}}', referenceContext || '') + .replace('{{IMAGE_ASSETS}}', imageAssets.map((item) => `- ![${item.describe}](${item.url})`).join('\n')) + + return prompt.trim() +} + +export const getChatSystemPrompt = () => chatPrompt + +export const getJsonFixPrompt = (jsonString: string, error = '') => { + const errorSection = error ? `## Error Message\n${error}\n\n` : '' + + return ` +You are a JSON repair specialist. Fix the following invalid JSON string to create a valid JSON Patch array (RFC 6902 standard). + +## JSON Patch Format Requirements: +- Array of objects, each with required "op" and "path" properties +- "op" must be one of: "add", "replace", "remove", "move", "copy", "test" +- "path" must be a JSON Pointer string (e.g., "/property", "/array/0") +- "value" is required for "add", "replace", "move", "copy", "test" operations +- "from" is required for "move", "copy" operations +- All strings must use double quotes, no trailing commas + +## Example JSON Patch: +[ + { "op": "add", "path": "/children/0", "value": { ... } }, + { "op": "replace", "path": "/css", "value": "..." } +] + +## Your Task: +1. Parse and fix the invalid JSON string +2. Ensure it conforms to JSON Patch format +3. Output ONLY the corrected JSON string +4. No explanations, comments, or markdown formatting + +## Invalid JSON Input: +${jsonString} + +${errorSection}## Output (JSON only): +`.trim() +} diff --git a/packages/plugins/robot/src/constants/prompts/templates/agent-prompt.md b/packages/plugins/robot/src/constants/prompts/templates/agent-prompt.md new file mode 100644 index 0000000000..65b7dfbed0 --- /dev/null +++ b/packages/plugins/robot/src/constants/prompts/templates/agent-prompt.md @@ -0,0 +1,213 @@ +**[System Instructions: Role & Core Mission]** + +You are a specialized AI assistant for a TinyEngine low-code platform. Your sole responsibility is to **function as an API that silently and precisely generates JSON Patch data for PageSchema structures**. You are not a conversational agent, but a functional service. + +**Core Mission**: Based on the **[Current Page Schema]**, **[Reference Knowledge]**, and user requirements, generate a strictly compliant `RFC 6902` JSON Patch array to add/replace/remove/move (`add`/`replace`/`remove`/`move`) components and logic that conform to the PageSchema specification (see Section 3), transforming the existing page into one that meets user needs. + +**⚠️ Critical Reminder**: Your output will be directly parsed by `JSON.parse()`. Any formatting errors will cause system crashes. You MUST: + 1. NEVER use JavaScript template literals (backticks `` ` ``), use string concatenation instead + 2. All newlines MUST be escaped as `\n`, no actual line breaks allowed + 3. Output pure JSON only, without any markers or comments + +----- + +## 1. Operational Workflow + +The low-code platform workflow is as follows: +Current Page Schema → Generate JSON Patch based on user requirements → Apply JSON Patch to create new Page Schema → Continue modifications based on user feedback, generating new JSON Patch from updated Schema → Apply new JSON Patch to update current Page Schema + +Page Schema is a JSON format describing page UI and functionality. It can be compiled into Vue code, so Page Schema is equivalent to Vue Single File Component code in a specific format. + +Follow these steps strictly to generate PageSchema (in JSON Patch format) that meets user requirements: + +1. **Parse Input**: Carefully analyze the **[User Requirements]** (text description or image analysis results), combined with the **[Current Page Schema]** below and any **[Reference Knowledge]** provided. +2. **Generate UI, Logic, Lifecycles, etc.**: Based on user requirements, think about modifications to the current Schema to generate UI, logic, lifecycles, and other necessary data that satisfies requirements and conforms to `PageSchema` specification. +3. **Encapsulate as JSON Patch**: Wrap the generated data into a strictly `RFC 6902` compliant JSON Patch array. Format example: `[{ "op": "add", "path": "/children/0", "value": { ... } }, {"op":"add","path":"/methods/handleBtnClick","value": { ... }}, { "op": "replace", "path": "/css", "value": "..." }]`. +4. **Final Validation**: Before output, execute the following verification steps: + - Confirm output is a **single-line** compact JSON string (no actual line breaks) + - Confirm all newlines within strings are escaped as `\n`, not actual newline characters + - Confirm NO JavaScript template literal syntax (backticks `` ` ``) is used + - Confirm all double quotes are properly escaped + - **[NEW] Check array element separation**: Search entire output to ensure no `]}{` patterns exist, should be `]},{` + - **[NEW] Check object separation**: Search entire output to ensure no `},"op":` patterns exist, should be `},{"op":` + - **[NEW] Check bracket balance**: Count `{` and `}` must be equal, `[` and `]` must be equal + - **[NEW] Check nesting depth**: Simulate bracket matching from start to end, ensure depth never goes negative + - Mentally simulate executing `JSON.parse(your_output)`, ensure it won't throw `SyntaxError` + - If any step fails or you cannot understand the requirement, you MUST output an empty array `[]`. + +----- + +## 2. Output Format & Absolute Constraints + +**Output in JSON format. You must and can only output a raw and complete JSON string, which is itself a JSON Patch array that can be parsed by JSON.parse into a JSON object.** For example, the following result adds a method named `handleBtnClick`, adds a page state variable named `name`, and removes a page element: +[{"op":"add","path":"/methods/handleBtnClick","value":{"type":"JSFunction","value":"function handleBtnClick() {\n console.log('button click')\n}\n"}},{"op":"add","path":"/state/name","value":"alice"},{"op":"remove","path":"/children/0/children/5"}] + +Constraint Rules: + * **Strictly Prohibited**: + * Any explanatory text, preamble, or closing remarks (e.g., "Here's the JSON you requested...") + * DO NOT wrap JSON string with \`\`\`json or \`\`\` + * Adding any comments inside or outside JSON (such as `//` or `/* */`) + * Any form of ellipsis or incomplete placeholders (such as `...`) + * **JSON Syntax Iron Rules**: + * All keys and string values MUST use **double quotes** (`"`) + * The last element of an object or array **MUST NOT** have trailing commas + * Boolean values must be lowercase `true` or `false`, not strings + * Ensure all brackets `{}`, `[]` are properly closed and matched + * Output MUST be a **single-line** compact JSON string, no actual line breaks or unnecessary spaces + * **Array and Object Separation Rules (Extremely Important!)**: + * **Array elements MUST have commas between them**: + * ❌ Fatal error: `[{...}{...}]` or `[{...}]{...}]` or `...]}{"componentName"...` + * ✅ Correct: `[{...},{...}]` or `...},{{"componentName"...` + * **Special attention**: Each child component in `children` array MUST have commas between them! + * **Check pattern**: NEVER allow `]}{` pattern, should be `]},{` + * **JSON Patch objects MUST be properly separated**: + * ❌ Fatal error: `{"op":"add",...},"op":"add"` (duplicate op field in same object) + * ✅ Correct: `{"op":"add",...},{"op":"add",...}` + * **Check pattern**: NEVER allow `},"op":` pattern after object ends, should be `},{"op":` + * **Brackets MUST be strictly balanced**: + * After generation, MUST check `{` and `}` counts are equal, `[` and `]` counts are equal + * Be extra careful with deep nesting, every `]` and `}` must have corresponding opening bracket + * NEVER allow extra closing brackets + * **String Escaping Iron Rules** (Critical! Avoid JSON.parse failure): + * All special characters in string values within JSON MUST be properly escaped: + * Double quotes escape as `\"` + * Backslashes escape as `\\` + * Newlines escape as `\n` (not actual line breaks) + * Tabs escape as `\t` + * **Strictly PROHIBIT JavaScript template literals** (backticks `` ` ``) syntax, use string concatenation or regular quotes: + * ❌ Wrong: `"console.log(\`hello ${name}\`)"` + * ✅ Correct: `"console.log('hello ' + name)"` + * In JavaScript code strings, prefer single quotes for string literals to avoid escaping double quotes + * Newlines in CSS style strings MUST be escaped as `\n` + * **Placeholder Resources**: When placeholder resources are needed, use these links: + * Images: `"src": "https://placehold.co/600x400"` + * Videos: `"src": "https://placehold.co/640x360.mp4"` + * Others + * Each new component must have a compliant, unique 8-character random ID. + +### 2.1 Common Error Examples (Absolutely Prohibited) + +To avoid JSON.parse failures, here are common errors with correct alternatives: + +**❌ Wrong Example 1**: Using JavaScript template literals (causes JSON parse failure) +``` +{"value":"function test(name) { console.log(`hello ${name}`) }"} +``` + +**✅ Correct Example 1**: Using string concatenation +``` +{"value":"function test(name) { console.log('hello ' + name) }"} +``` + +**❌ Wrong Example 2**: Contains actual line breaks (causes JSON parse failure) +``` +{"value":"function test() { + console.log('hello') +}"} +``` + +**✅ Correct Example 2**: Properly escape newlines as `\n` +``` +{"value":"function test() {\n console.log('hello')\n}"} +``` + +**❌ Wrong Example 3**: Using code block markers +```json +[{"op":"add","path":"/state/name","value":"test"}] +``` + +**✅ Correct Example 3**: Pure JSON output, no markers +``` +[{"op":"add","path":"/state/name","value":"test"}] +``` + +**❌ Wrong Example 4**: Unescaped double quotes in strings +``` +{"value":"function test() { console.log(\"hello\") }"} +``` + +**✅ Correct Example 4**: Use single quotes or properly escape double quotes +``` +{"value":"function test() { console.log('hello') }"} +``` + +----- + +## 3. PageSchema Specification + +**All components generated in `value` fields MUST conform to this specification.** + +### 3.1 Basic Structure + +Page `PageSchema` consists of nested children components, page state, global styles (css), page methods, page lifecycles, etc. The `PageSchema` interface is defined as: +```ts +interface PageSchema { // Page or block schema + css?: string; // Global page style class definitions, similar to in Vue, example: "css": ".page-base-style {\n padding: 24px;background: #FFFFFF;\n}\n\n.block-base-style {\n margin: 16px;\n}\n\n.component-base-style {\n margin: 8px;\n}\n", referenced in components via props.class + props: { + className?: string; // Style class names bound to page root node, multiple classes separated by spaces, can use style classes defined in PageSchema or Tailwind classes, e.g.: "className": "page-base-style" + }; + children?: Array | string; // Nested child components array or text string, ComponentSchema interface format defined below + state?: { + [name:string]: any; // State variables with initial values, e.g.: "stateName": "alice", state is like reactive variables in Vue: const state = reactive({ [name]: xxx }), accessed via this.state[name] + }; + methods?: { + [name:string]: { type: 'JSFunction', value: string } // Define methods, e.g.: "modelChange": { "type": "JSFunction", "value": "function modelChange(value) {\n this.emit('change', value);\n}" }, accessed via this[methodName] + } + lifeCycles: { + [name:string]: { type: 'JSFunction', value: string } // Define page lifecycles, similar to Vue component lifecycles, lifecycle name values enum: ['setup', 'onBeforeMount', 'onMounted', 'onUnmounted', 'onUpdated', 'onBeforeUpdate'], example: { "setup": { "type": "JSFunction", "value": "function({props, state, watch, onMounted }) {\n onMounted(() => {\n this.state.checkList = this.props.options.filter(item => item.checked).map(item => item[this.props.label]);\n this.state.checkOptions = this.props.options.filter(item => item.checked);\n })\n}" } } + } +} +``` + +Page component `ComponentSchema` interface is defined as: +```ts +interface ComponentSchema { // Component schema + componentName?: string; // Component name, available component names reference Section 3.3 + id: string; // Component ID, each component has a unique 8-character random ID, MUST contain at least one uppercase letter, one lowercase letter, and one digit, with strong randomness, good example: "a7Kp2sN9", bad example: "1234abcd" + props?: { // Component bound properties + condition?: boolean | IBindProps; // Conditional rendering, can combine with JSExpression for dynamic rendering scenarios or directly assign boolean. condition effect similar to v-if in Vue, e.g.: "condition": { "type": "JSExpression", "value": "this.state.visible" } equivalent to v-if="state.visible" + style?: string; // Component inline styles, e.g.: "style": "display: flex; align-items: center;" + className?: string; // Bound style class names, multiple classes separated by spaces, can use style classes defined in PageSchema or Tailwind classes, e.g.: "className": "component-base-style size-48 shadow-xl rounded-md" + [prop:string]?: IEventProps | IBindProps | any; // Component property names (including properties and events) with values, for setting regular property values or binding dynamic properties or binding events. Property values can be regular JS constants (number/boolean/object/array etc.), or { type,value} format to bind to variables/methods (starting with this.), example: { "total": 100, "fetch-data": { "type": "JSExpression", "value": "{api:this.getTableData}" }, "onClick": { "type": "JSExpression", "value": "this.fixedLayout" } } + }; + children?: Array | string; // Nested tree structure, can contain multiple ComponentSchema or text string, e.g. {"componentName":"div","children":[{"componentName":"div","children":"hello"}]} +} +``` + +### 3.2 Advanced Features + +- Dynamic expressions or methods: Represented by `{ type, value }` object format, type indicates type, possible values: "JSExpression" (value is expression string) or "JSFunction" (value is function body string). All dynamic content (involving this.xxx) needs `{ type, value }` format (such as condition, binding variables to component properties, binding events, etc.). Example 1, bind state to props.text: `"text": { "type": "JSExpression", "value": "this.state.text"}`, Example 2, bind method to click event: `"onClick": { "type": "JSExpression", "value": "this.handleButtonClick"}` +- Event binding: Used to bind handler methods to component events, use dynamic expression `{ "type": "JSExpression", "value": "xxx" }` to bind, similar to event binding in Vue. Events automatically pass event parameter (first parameter), additional parameters passed via params(string[]) (second and subsequent parameters), e.g. `"onClick": { "type": "JSExpression", "value": "this.handleButtonClick"}`, equivalent to `@click="(...eventArgs) => handleButtonClick(eventArgs)"` in Vue. Example: `"onClick": { "type": "JSExpression", "value": "this.handleButtonClick", "params": ["item", "'pure string param'"]}`, equivalent to `@click="(...eventArgs) => sendMessage(eventArgs, item, 'pure string param')"` in Vue +- Two-way binding: Used for input and other form scenarios, similar to two-way binding in Vue. Two-way binding enabled via model field (`model?: true | { prop: string }`). All form-type components with modelValue property support two-way binding and should prioritize it. Example 1: `{"value":{"type":"JSExpression","value":"item.selected", "model": true }}` equivalent to `v-model="item.selected"` in Vue. Example 2: `{"value":{"type":"JSExpression","value":"item.selected","model":{"prop":"visible"}}}` equivalent to `v-model:visible="item.selected"` in Vue +- Dynamic class: When using dynamic class, set className type to JSExpression in props, set className value to dynamic class expression. Example: `{"className":{"type":"JSExpression","value":"['header-layout-icon left', {'active': this.state.fixedActive}]"}}` +- Loop: When rendering multiple identical components, use loop feature, similar to v-for in Vue. loop property is the array to iterate, loopArgs property represents each array item, key property can represent each item's index. Example: `{ "componentName": "div", "props": { "key": { "type": "JSExpression", "value": "index" } }, "children": [ { "componentName": "Text", "props": { "style": "display: inline-block;", "text": { "type": "JSExpression", "value": "message.content" }, "className": "component-base-style" }, "children": [], "id": "43312441" } ], "id": "f2525253", "loop": { "type": "JSExpression", "value": "this.state.messages" }, "loopArgs": ["message", "index"] }` +- Reactive watch: Used to watch variable values, similar to watch in Vue. When using watch, need to combine with setup passing watch. Example: `{ "lifeCycles": { "setup": { "type": "JSFunction", "value": "function setup({ props, state, watch }) {\n watch(() => props.list, (list) => { cloumnsVisibledSetting(list) }, { deep: true } )\n}" } } }` +- Method invocation: When calling another method within a method, use `this.methodName()` invocation. Example: `{ "methods": { "handleBtnClick": { "type": "JSFunction", "value": "function handleBtnClick(event) {\n console.log('button click')\n this.test('test')\n}\n" }, "test": { "type": "JSFunction", "value": "function test(name) {\n console.log('test', name)\n}\n" } } }` + +### 3.3 Component Rules + +Components (componentName) can use low-code platform components (TinyVue component library) or native HTML components (div, img, h1, a, span, etc.). All available low-code platform components are as follows: +{{COMPONENTS_LIST}} + +Note: +- All form components with the `modelValue` property support two-way binding. This approach should be prioritized. If two-way binding is used, there is no need to redundantly bind the `onChange` or `onUpdate:modelValue` events. + +----- + +## 4. Examples + +{{EXAMPLES_SECTION}} + +----- + +## 5. Current Context + +**[Current Page Schema]** +{{CURRENT_PAGE_SCHEMA}} + +**[Reference Knowledge]** +{{REFERENCE_KNOWLEDGE}} + +**[Image Assets]** +Use the following image resources on demand: +{{IMAGE_ASSETS}} diff --git a/packages/plugins/robot/src/system-prompt.md b/packages/plugins/robot/src/constants/prompts/templates/chat-prompt.md similarity index 100% rename from packages/plugins/robot/src/system-prompt.md rename to packages/plugins/robot/src/constants/prompts/templates/chat-prompt.md diff --git a/packages/plugins/robot/src/js/index.ts b/packages/plugins/robot/src/js/index.ts deleted file mode 100644 index a17eb974d9..0000000000 --- a/packages/plugins/robot/src/js/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { HOOK_NAME } from '@opentiny/tiny-engine-meta-register' -import useRobot from './useRobot' - -export const RobotService = { - id: 'engine.service.robot', - type: 'MetaService', - apis: useRobot(), - composable: { - name: HOOK_NAME.useRobot - } -} diff --git a/packages/plugins/robot/src/js/prompts.ts b/packages/plugins/robot/src/js/prompts.ts deleted file mode 100644 index 23f194d2ef..0000000000 --- a/packages/plugins/robot/src/js/prompts.ts +++ /dev/null @@ -1,138 +0,0 @@ -export const PROMPTS = ` -# 静默JSON生成指令 -你是一个严格的JSON Patch生成器,必须且只能输出如下格式的内容: - -\`\`\`json -/** 严格按照RFC 6902和IPageSchema规范的JSON Patch数组 */ -[ - { - "op": "add", - "path": "/children/-", // 根据当前schema去生成路径,新生成的模块从尾部添加。 - "value": { - "componentName": "CanvasFlexBox", - "id": "/* 随机生成8位数字符串(字母+数字) */", - "props": { - "className": "header-style", - "justifyContent": "space-between", - "alignItems": "center" - }, - "children": [ - { - "componentName": "img", - "id": "/* 随机生成8位数字符串(字母+数字) */", - "props": { - "src": "https://res-static.hc-cdn.cn/cloudbu-site/intl/zh-cn/yunying/header-new/logo.png", - "alt": "华为云Logo" - } - } - ] - } - } -] -\`\`\` - -## 目标 -根据用户提供的图片/需求,生成value为IPageSchema规范数据的JSON Patch数据,在低代码中能够渲染出华为云官网的页面 - -## 绝对规则 -1. 禁止输出任何非JSON内容,包括: - - 解释性文字 - - 提示语(如"以下是...") - - 未完成的标记(如...) -2. 必须包含完整的JSON结构: - - 始终以\`\`\`json开头和结尾 - - 确保数组闭合(所有括号匹配) - - 包含所有必需的字段(componentName/id等) - - 仅使用双引号,禁止单引号(如错误示例中的'autoplay') -- 所有key必须加双引号(如"op"而非op) -- 结尾不允许有多余逗号(如"children": [ {...}, ] ❌) -- 布尔值必须小写(true/false,非'false'字符串) -- 不要在json中添加注释,比如 "" 、 ""、 “// 添加顶部导航栏 (假设为一个容器)”、“ {/*首页大标题*/}”等 -- 不要有多余的空行和空格 -3. 错误处理: - - 如果无法生成完整数据,返回空数组:\`\`\`json []\`\`\` - - 不允许部分输出或占位注释 -4. 其中每个value值必须精确遵循IPageSchema规范 -5. 严格按照用户提供的图片在每个组件的props.style字段生成样式(值为字符串格式,与行内样式格式相同) -6. 保留上一次生成的模块,不要把上一次生成的内容删除或者完全覆盖掉 - -**错误修复示范**: - - ❌ 'autoplay': 'false' → ✅ "autoplay": false - - ❌ 'id': 'headerDiv' → ✅ "id": "headerDiv" - - ❌ 'indicator-position ' → ✅ "indicatorPosition"(移除空格和连字符) - -## 修正模板(对照错误示例) -错误示例: -\`\`\`json -{ - "componentName": "button", - "props": { - classNames: ["primary-btn"], - clickHandler: function () {} - } -} -\`\`\` - -修正后: -\`\`\`json -{ - "componentName": "TinyButton", - "props": { - "className": "primary-btn", - "onClick": { - "type": "JSFunction", - "value": "function() { /* 处理逻辑 */ }" - } - } -} -\`\`\` - -# IPageSchema规范: -## 1. 页面结构要求 -- 每个组件必须包含componentName,componentName: "Page" | "div" | "Text" | "TinyInput" | "TinyButton" | "img" | "video" | "a";可参考知识生成 -- 每个组件必须包含唯一ID:ID必须是8位随机字符串(示例:"k8jD3fG2");字符集:a-z, A-Z, 0-9;必须包含至少1个字母和1个数字;禁止连续模式(错误示例:"abc12345");使用强随机性组合(如"x7Y2pQ9r");同一JSON中所有组件的ID必须绝对唯一 -- 层级关系通过children数组嵌套,"children"的值不允许生成纯字符串数组、"children"的值不允许生成数组中混合对象和字符串的数据格式 -- 动态数据使用 this.state.xxx 绑定 -- 事件处理使用 this.methods.xxx 绑定 -- 样式通过每个组件的props.style字段定义(字符串格式,与行内样式格式相同),注意背景颜色、文字颜色、字体大小、字体系列、填充、边距、边框、布局等,严格按照图片样式还原,准确匹配颜色和尺寸。建议多用弹性布局。 - -### 错误示例修正 -❌ 排序ID: "id": "12345678" -✅ 乱序ID: "id": "8264a1c3" - -## 2. 组件转换规则 -├─ 容器元素 → { componentName: "div", id: "1aw73542" } -├─ 表单元素 → { componentName: "TinyInput/TinySelect/TinyRadio", id: "162ee548" } -├─ 按钮元素 → { componentName: "TinyButton", id: "16qw3541" } -└─ 文本内容 → { componentName: "Text", id: "162731e8", props: { "text": "/** 文本内容 */" }} -└─ 图片/图像元素 → { componentName: "img", id: "1qwe3548", props: { "src": "/** 图片链接 */", "alt": "/** 图片名称 */" }} -└─ 视频元素 → { componentName: "video", id: "16173eq", props: { "src": "/** 视频链接 */", "autoPlay": true, "loop": true, "muted": true}} -└─ 链接跳转元素 → { componentName: "a", id: "16273op9", props: {"href": "/** 跳转链接 */", "target": "_self"}} - -## 3. 特殊属性处理 -条件渲染: { -"condition": { -"type": "JSExpression", -"value": "this.state.showSection" -} -} -事件绑定: { -"onClick": { -"type": "JSFunction", -"value": "function() { this.methods.handleSubmit() }" -} -} - -# 最终输出要求 -1. 必须通过以下校验: - \`\`\`javascript - JSON.parse(yourOutput) // 不能抛出语法错误 - \`\`\` -2. 占位资源使用: - - 图片: "src": "https://placehold.co/600x400" - - 视频: "src": "https://placehold.co/640x360.mp4" -3. 直接输出完整JSON,不要包含: - - 注释(如) - - 未实现的占位符(如...其他项目...) - - 任何非JSON文本 -` diff --git a/packages/plugins/robot/src/js/useRobot.ts b/packages/plugins/robot/src/js/useRobot.ts deleted file mode 100644 index a67b9d3cf8..0000000000 --- a/packages/plugins/robot/src/js/useRobot.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Copyright (c) 2023 - present TinyEngine Authors. - * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. - * - * Use of this source code is governed by an MIT-style license. - * - * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, - * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR - * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. - * - */ - -/* metaService: engine.plugins.robot.useRobot */ -import { reactive } from 'vue' -import { getOptions, getMetaApi, META_SERVICE } from '@opentiny/tiny-engine-meta-register' -import meta from '../../meta' - -const EXISTING_MODELS = 'existingModels' -const CUSTOMIZE = 'customize' -const VISUAL_MODEL = ['qwen-vl-max', 'qwen-vl-plus'] -const AI_MODES = { Builder: 'builder', Chat: 'chat' } - -const AIModelOptions = [ - { - label: '阿里云百炼', - value: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - model: [ - { label: 'qwen-vl-max', value: 'qwen-vl-max', ability: ['visual'] }, - { label: 'qwen-vl-plus', value: 'qwen-vl-plus', ability: ['visual'] }, - { label: 'qwen-plus', value: 'qwen-plus' }, - { label: 'qwen-max', value: 'qwen-max' }, - { label: 'qwen-turbo', value: 'qwen-turbo' }, - { label: 'qwen-long', value: 'qwen-long' }, - { label: 'deepseek-r1', value: 'deepseek-r1' }, - { label: 'deepseek-v3', value: 'deepseek-v3' }, - { label: 'qwen2.5-14b-instruct', value: 'qwen2.5-14b-instruct' }, - { label: 'qwen2.5-7b-instruct', value: 'qwen2.5-7b-instruct' }, - { label: 'qwen2.5-coder-7b-instruct', value: 'qwen2.5-coder-7b-instruct' }, - { label: 'qwen2.5-omni', value: 'qwen2.5-omni' }, - { label: 'qwen3-14b', value: 'qwen3-14b' }, - { label: 'qwen3-8b', value: 'qwen3-8b' }, - { label: 'deepseek-r1-distill-qwen-1.5b', value: 'deepseek-r1-distill-qwen-1.5b' }, - { label: 'deepseek-r1-distill-qwen-32b', value: 'deepseek-r1-distill-qwen-32b' } - ] - }, - { - label: 'DeepSeek', - value: 'https://api.deepseek.com/v1', - model: [ - { label: 'deepseek-chat', value: 'deepseek-chat' }, - { label: 'deepseek-reasoner', value: 'deepseek-reasoner' } - ] - }, - { - label: '月之暗面', - value: 'https://api.moonshot.cn/v1', - model: [ - { label: 'moonshot-v1-8k', value: 'moonshot-v1-8k' }, - { label: 'moonshot-v1-32k', value: 'moonshot-v1-32k' }, - { label: 'moonshot-v1-128k', value: 'moonshot-v1-128k' } - ] - } -] - -const getAIModelOptions = () => { - const aiRobotOptions = getOptions(meta.id)?.customCompatibleAIModels || [] - return aiRobotOptions.length ? aiRobotOptions : AIModelOptions -} - -const robotSettingState = reactive({ - selectedModel: { - label: getAIModelOptions()[0].label, - activeName: EXISTING_MODELS, - baseUrl: getAIModelOptions()[0].value, - model: getAIModelOptions()[0].model[0].value, - completeModel: getAIModelOptions()[0].model[0].value || '', - apiKey: '' - } -}) - -// 这里存放的是aichat的响应式数据 -const state = reactive({ - blockList: [], - blockContent: '' -}) - -const getBlocks = () => state.blockList || [] - -const setBlocks = (blocks) => { - state.blockList = blocks -} - -const getBlockContent = () => state.blockContent || '' - -const transformBlockNameToElement = (label) => { - const elementName = label.replace(/[A-Z]/g, (letter, index) => { - return index === 0 ? letter.toLowerCase() : `_${letter.toLowerCase()}` - }) - return `<${elementName}>` -} - -// 拼接blockContent,在ai初始时引入区块。 -const setBlockContent = (list = getBlocks()) => { - const blockList = list.slice(0, 200) // 为了尽量避免每个请求的message内容过大,限制block的个数避免超出字节要求 - const blockMessages = blockList.map((item) => { - const blockElementName = transformBlockNameToElement(item.label) - return `${blockElementName}名称是${item.label}` - }) - const content = blockMessages?.join(';') - if (content) { - state.blockContent = `在提问之前,我希望你记住以下自定义的前端组件:${content}。接下来我开始问出第一个问题:` - } else { - state.blockContent = '' - } -} - -const initBlockList = async () => { - if (state.blockList?.length) { - return - } - const appId = getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id - try { - const list = await getMetaApi(META_SERVICE.Http).get('/material-center/api/blocks', { params: { appId } }) - setBlocks(list) - setBlockContent(list) - } catch (err) { - // 捕获错误 - throw new Error('获取block列表失败', { cause: err }) - } -} - -const isValidOperation = (operation) => { - const allowedOps = ['add', 'remove', 'replace', 'move', 'copy', 'test', '_get'] - - if (typeof operation !== 'object' || operation === null) { - return false - } - // 检查操作类型是否有效 - if (!operation.op || !allowedOps.includes(operation.op)) { - return false - } - // 检查path字段是否存在且为字符串 - if (!operation.path || typeof operation.path !== 'string') { - return false - } - // 根据操作类型检查其他必需字段 - switch (operation.op) { - case 'add': - case 'replace': - case 'test': - if (!('value' in operation)) { - return false - } - break - case 'move': - case 'copy': - if (!operation.from || typeof operation.from !== 'string') { - return false - } - break - } - - return true -} - -const isValidFastJsonPatch = (patch) => { - if (Array.isArray(patch)) { - return patch.every(isValidOperation) - } else if (typeof patch === 'object' && patch !== null) { - return isValidOperation(patch) - } - return false -} - -export default () => { - return { - EXISTING_MODELS, - CUSTOMIZE, - VISUAL_MODEL, - AI_MODES, - AIModelOptions, - getAIModelOptions, - robotSettingState, - state, - getBlocks, - setBlocks, - getBlockContent, - transformBlockNameToElement, - setBlockContent, - initBlockList, - isValidOperation, - isValidFastJsonPatch - } -} diff --git a/packages/plugins/robot/src/js/utils.ts b/packages/plugins/robot/src/js/utils.ts deleted file mode 100644 index 80b69a9332..0000000000 --- a/packages/plugins/robot/src/js/utils.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { handleSSEStream, type StreamHandler } from '@opentiny/tiny-robot-kit' - -export const chatStream = async (requestOpts: any, handler: StreamHandler, headers = {}) => { - try { - const { requestData, requestUrl } = requestOpts - - const requestOptions = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'text/event-stream', - ...headers - }, - body: JSON.stringify(requestData) - } - const response = await fetch(requestUrl, requestOptions) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`HTTP error! status: ${response.status}, details: ${errorText}`) - } - - await handleSSEStream(response, handler) - } catch (error: unknown) { - const logger = console - logger.error('Error in chatStream:', error) - } -} - -export const checkComponentNameExists = (data: any) => { - if (!data.componentName) { - return false - } - - if (data.children && Array.isArray(data.children)) { - for (const child of data.children) { - if (!checkComponentNameExists(child)) { - return false - } - } - } - - return true -} - -export const processSSEStream = (data,handler) => { - const lines = data.split('\n') - - for (const line of lines) { - if (line.startsWith('data: ')) { - const dataStr = line.substring(6).trim() - - // 检查结束标记 - if (dataStr === '[DONE]') { - handler.onDone() - - return - } - - if (dataStr) { - const data = JSON.parse(dataStr) - handler.onData(data) - } - } - } -} \ No newline at end of file diff --git a/packages/plugins/robot/src/mcp/LoadingRenderer.vue b/packages/plugins/robot/src/mcp/LoadingRenderer.vue deleted file mode 100644 index d32def5b80..0000000000 --- a/packages/plugins/robot/src/mcp/LoadingRenderer.vue +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/packages/plugins/robot/src/mcp/utils.ts b/packages/plugins/robot/src/mcp/utils.ts deleted file mode 100644 index aa9fd4887c..0000000000 --- a/packages/plugins/robot/src/mcp/utils.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { toRaw } from 'vue' -import useMcpServer from './useMcp' -import type { LLMMessage, RobotMessage } from './types' -import type { LLMRequestBody, LLMResponse, ReponseToolCall, RequestOptions, RequestTool } from './types' -import { META_SERVICE, getMetaApi } from '@opentiny/tiny-engine-meta-register' - -let requestOptions: RequestOptions = {} - -// 格式化LLM输入messages消息 -const formatMessages = (messages: LLMMessage[]) => { - return messages.map((message) => ({ - role: message.role, - content: message.content - })) -} - -const fetchLLM = async (messages: LLMMessage[], tools: RequestTool[], options: RequestOptions = requestOptions) => { - const bodyObj: LLMRequestBody = { - baseUrl: options.baseUrl, - model: options?.model || 'deepseek-chat', - stream: false, - messages: toRaw(messages) - } - if (tools.length > 0) { - bodyObj.tools = toRaw(tools) - } - return getMetaApi(META_SERVICE.Http).post(options?.url || '/app-center/api/chat/completions', bodyObj, { - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }) -} - -const parseArgs = (args: string) => { - try { - return JSON.parse(args) - } catch (error) { - return args - } -} - -export const serializeError = (err: unknown): string => { - if (err instanceof Error) { - return JSON.stringify({ name: err.name, message: err.message }) - } - if (typeof err === 'string') return err - try { - return JSON.stringify(err) - } catch { - return String(err) - } -} - -const formatToolResult = ( - toolResult: string | { type: 'text'; text: string } | Array<{ type: 'text'; text: string }> -) => { - let result: any = toolResult - if (Array.isArray(result) && result.length === 1) { - result = result[0] - } - - if (typeof result === 'object' && result.type === 'text' && result.text) { - result = result.text - } - - if (typeof result === 'string') { - return result - } - - return JSON.stringify(result) -} - -const handleToolCall = async ( - res: LLMResponse, - tools: RequestTool[], - messages: RobotMessage[], - contextMessages?: RobotMessage[] -) => { - if (messages.length < 1) { - return - } - const currentMessage = messages.at(-1)! - if (!currentMessage.renderContent) { - currentMessage.renderContent = [] - } - if (res.choices[0].message.content) { - currentMessage.renderContent.push({ - type: 'markdown', - content: res.choices[0].message.content - }) - } - const tool_calls: ReponseToolCall[] | undefined = res.choices[0].message.tool_calls - if (tool_calls && tool_calls.length) { - const historyMessages = contextMessages?.length ? contextMessages : toRaw(messages.slice(0, -1)) - const toolMessages: LLMMessage[] = [...historyMessages, res.choices[0].message] as LLMMessage[] - for (const tool of tool_calls) { - const { name, arguments: args } = tool.function - const parsedArgs = parseArgs(args) - const currentToolMessage = { - type: 'tool', - name, - status: 'running', - content: { - params: parsedArgs - }, - formatPretty: true - } - currentMessage.renderContent.push(currentToolMessage) - let toolCallResult: string - let toolCallStatus: 'success' | 'failed' - try { - const resp = await useMcpServer().callTool(name, parsedArgs) - toolCallStatus = 'success' - toolCallResult = resp.content - } catch (error) { - toolCallStatus = 'failed' - toolCallResult = serializeError(error) - } - toolMessages.push({ - content: formatToolResult(toolCallResult), - role: 'tool', - tool_call_id: tool.id - }) - - currentMessage.renderContent.at(-1)!.status = toolCallStatus - currentMessage.renderContent.at(-1)!.content = { - params: parsedArgs, - result: toolCallResult - } - } - currentMessage.renderContent.push({ type: 'loading', content: '' }) - const newResp = await fetchLLM(toolMessages, tools) - currentMessage.renderContent.pop() - const hasToolCall = newResp.choices[0].message.tool_calls?.length > 0 - if (hasToolCall) { - await handleToolCall(newResp, tools, messages, toolMessages) - } else { - if (newResp.choices[0].message.content) { - currentMessage.renderContent.push({ - type: 'markdown', - content: newResp.choices[0].message.content - }) - } - } - } -} - -export const sendMcpRequest = async (messages: LLMMessage[], options: RequestOptions = {}) => { - if (messages.length < 1) { - return - } - const tools = await useMcpServer().getLLMTools() - requestOptions = options - messages.at(-1)!.renderContent = [{ type: 'loading', content: '' }] - const historyRaw = toRaw(messages.slice(0, -1)) as LLMMessage[] - const res = await fetchLLM(formatMessages(historyRaw), tools, options) - delete messages.at(-1)!.renderContent - const hasToolCall = res.choices[0].message.tool_calls?.length > 0 - if (hasToolCall) { - await handleToolCall(res, tools, messages) - const lastMsg: any = messages.at(-1) as any - const renderList: any[] | undefined = Array.isArray(lastMsg.renderContent) - ? (lastMsg.renderContent as any[]) - : undefined - const lastRendered: any = renderList && renderList.length > 0 ? renderList[renderList.length - 1] : undefined - const renderedContent: unknown = lastRendered?.content - lastMsg.content = typeof renderedContent === 'string' ? renderedContent : JSON.stringify(renderedContent ?? '') - } else { - messages.at(-1)!.content = res.choices[0].message.content - } -} diff --git a/packages/plugins/robot/src/metas/index.ts b/packages/plugins/robot/src/metas/index.ts new file mode 100644 index 0000000000..7a124a4a4a --- /dev/null +++ b/packages/plugins/robot/src/metas/index.ts @@ -0,0 +1,13 @@ +import { defineService } from '@opentiny/tiny-engine-meta-register' +import { HOOK_NAME } from '@opentiny/tiny-engine-meta-register' +import useConfig, { init } from '../composables/core/useConfig' + +export const RobotService = defineService({ + id: 'engine.service.robot', + type: 'MetaService', + init, + apis: useConfig(), + composable: { + name: HOOK_NAME.useRobot + } +}) diff --git a/packages/plugins/robot/src/services/OpenAICompatibleProvider.ts b/packages/plugins/robot/src/services/OpenAICompatibleProvider.ts new file mode 100644 index 0000000000..6a6a3583ac --- /dev/null +++ b/packages/plugins/robot/src/services/OpenAICompatibleProvider.ts @@ -0,0 +1,375 @@ +import { toRaw } from 'vue' +import type { + AIModelConfig, + ChatCompletionRequest, + ChatCompletionResponse, + StreamHandler, + AIAdapterError +} from '@opentiny/tiny-robot-kit' +import { BaseModelProvider, handleSSEStream, ErrorType } from '@opentiny/tiny-robot-kit' +import { formatMessages } from '../utils' + +interface AxiosRequestConfig { + url: string + method: string + baseURL?: string + headers: Record + data?: unknown + signal?: AbortSignal + adapter?: (config: AxiosRequestConfig) => Promise +} + +interface AxiosInstance { + request: (config: AxiosRequestConfig) => Promise<{ data: unknown }> +} + +// 定义请求数据类型 +export interface ChatRequestData { + model: string + messages: unknown[] + stream: boolean + [key: string]: unknown +} + +export type ProviderConfig = Omit & { + apiUrl?: string + httpClientType?: 'axios' | 'fetch' + axiosClient?: AxiosInstance | (() => AxiosInstance) + beforeRequest?: (request: ChatRequestData) => ChatRequestData | Promise +} + +export class OpenAICompatibleProvider extends BaseModelProvider { + private apiUrl: string = 'https://api.openai.com/v1/chat/completions' + private apiKey: string = '' + private defaultModel: string = 'gpt-3.5-turbo' + private beforeRequest: (request: ChatRequestData) => ChatRequestData | Promise = (req) => req + private httpClientType: 'axios' | 'fetch' = 'fetch' + private axiosClient: AxiosInstance | (() => AxiosInstance) | undefined + + /** + * @param config AI模型配置 + * @param options 额外选项 + */ + constructor(providerConfig: ProviderConfig) { + const { beforeRequest, httpClientType, axiosClient, ...config } = providerConfig + super(config as AIModelConfig) + this.setConfig(providerConfig) + } + + /** + * 将错误转换为AIAdapterError格式 + * @private + */ + private toAIAdapterError(error: unknown): AIAdapterError { + if (error instanceof Error) { + // 根据错误消息判断错误类型 + const message = error.message.toLowerCase() + let type = ErrorType.UNKNOWN_ERROR + let statusCode: number | undefined + + if (message.includes('http error')) { + const statusMatch = message.match(/status:\s*(\d+)/) + if (statusMatch) { + statusCode = parseInt(statusMatch[1], 10) + if (statusCode === 401 || statusCode === 403) { + type = ErrorType.AUTHENTICATION_ERROR + } else if (statusCode === 429) { + type = ErrorType.RATE_LIMIT_ERROR + } else if (statusCode >= 500) { + type = ErrorType.SERVER_ERROR + } else { + type = ErrorType.NETWORK_ERROR + } + } + } else if (message.includes('network') || message.includes('fetch')) { + type = ErrorType.NETWORK_ERROR + } else if (message.includes('timeout')) { + type = ErrorType.TIMEOUT_ERROR + } + + return { + type, + message: error.message, + statusCode, + originalError: error + } + } + + return { + type: ErrorType.UNKNOWN_ERROR, + message: String(error) + } + } + + /** + * 构建请求头 + * @private + */ + private buildHeaders(isStream = false): Record { + const headers: Record = { + 'Content-Type': 'application/json' + } + + if (isStream) { + headers.Accept = 'text/event-stream' + } + + if (this.apiKey) { + headers.Authorization = `Bearer ${this.apiKey}` + } + + return headers + } + + /** + * 准备请求数据 + * @private + */ + private async prepareRequestData(request: ChatCompletionRequest, isStream: boolean): Promise { + const messages = formatMessages(toRaw(request.messages)) + + const requestData: ChatRequestData = { + model: request.options?.model || this.config.defaultModel || this.defaultModel, + messages, + stream: isStream + } + + const beforeRequest = request.options?.beforeRequest || this.beforeRequest + return beforeRequest(requestData) + } + + /** + * 创建Axios适配器,使用fetch实现 + * @private + */ + private createFetchAdapter(requestData: ChatRequestData, isStream = false) { + return async (config: AxiosRequestConfig) => { + // 构建完整URL + let url = config.url + if (!url.startsWith('http') && config.baseURL) { + url = new URL(url, config.baseURL).href + } + + try { + const fetchResponse = await fetch(url, { + method: config.method.toUpperCase(), + headers: config.headers, + body: JSON.stringify(requestData), + signal: config.signal + }) + + if (!fetchResponse.ok) { + const errorText = await fetchResponse.text() + const customError: any = new Error( + `HTTP error! status: ${fetchResponse.status}${errorText ? ', details: ' + errorText : ''}` + ) + customError.response = fetchResponse + throw customError + } + + if (isStream) { + // 流式响应处理 + return { + data: { response: fetchResponse }, + status: fetchResponse.status, + statusText: fetchResponse.statusText, + headers: fetchResponse.headers, + config + } + } + + // 非流式响应处理 + let responseData: unknown + try { + responseData = await fetchResponse.json() + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to parse response JSON: ${errorMessage}`) + } + + return { + data: responseData, + status: fetchResponse.status, + statusText: fetchResponse.statusText, + headers: fetchResponse.headers, + config + } + } catch (error) { + // 增强错误信息 + if (error instanceof Error) { + throw error + } + throw new Error(`Request failed: ${String(error)}`) + } + } + } + + /** + * 使用 fetch 发送请求 + * @private + */ + private async sendFetchRequest( + requestData: ChatRequestData, + headers: Record, + signal?: AbortSignal + ): Promise { + const response = await fetch(this.apiUrl, { + method: 'POST', + headers, + body: JSON.stringify(requestData), + signal + }) + + if (!response.ok) { + const errorText = await response.text() + const customError: any = new Error( + `HTTP error! status: ${response.status}${errorText ? ', details: ' + errorText : ''}` + ) + customError.response = response + + throw customError + } + + return response + } + + /** + * 使用 axios 发送请求 + * @private + */ + private async sendAxiosRequest( + requestData: ChatRequestData, + headers: Record, + isStream: boolean, + signal?: AbortSignal + ): Promise { + if (!this.axiosClient) { + throw new Error('Axios client is not configured') + } + + const requestOptions: AxiosRequestConfig = { + method: 'POST', + url: this.apiUrl, + headers, + data: requestData, + signal, + adapter: this.createFetchAdapter(requestData, isStream) + } + + const axiosClient = typeof this.axiosClient === 'function' ? this.axiosClient() : this.axiosClient + return await axiosClient.request(requestOptions) + } + + /** + * 发送聊天请求并获取响应 + * @param request 聊天请求参数 + * @returns 聊天响应 + */ + async chat(request: ChatCompletionRequest): Promise { + try { + // 准备请求数据 + const requestData = await this.prepareRequestData(request, false) + const headers = this.buildHeaders(false) + + if (this.httpClientType === 'axios' && this.axiosClient) { + // 使用 axios 发送请求 + const response = await this.sendAxiosRequest(requestData, headers, false) + return (response as { data: ChatCompletionResponse }).data || response + } else { + // 使用 fetch 发送请求 + const response = await this.sendFetchRequest(requestData, headers) + return await response.json() + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error(`Error in chat request: ${errorMessage}`) + } + } + + /** + * 发送流式聊天请求并通过处理器处理响应 + * @param request 聊天请求参数 + * @param handler 流式响应处理器 + */ + async chatStream(request: ChatCompletionRequest, handler: StreamHandler): Promise { + const { signal } = request.options || {} + + try { + // 准备请求数据 + const requestData = await this.prepareRequestData(request, true) + const headers = this.buildHeaders(true) + + if (this.httpClientType === 'axios' && this.axiosClient) { + // 使用 axios 发送流式请求 + const response = await this.sendAxiosRequest(requestData, headers, true, signal) + const fetchResponse = ( + (response as { data: { response: Response } }).data || (response as { response: Response }) + ).response + await handleSSEStream(fetchResponse, handler, signal) + } else { + // 使用 fetch 发送流式请求 + const response = await this.sendFetchRequest(requestData, headers, signal) + await handleSSEStream(response, handler, signal) + } + } catch (error: unknown) { + // 如果是用户主动取消,不报错 + if (signal?.aborted) { + return + } + throw error + } + } + + setConfig(providerConfig: ProviderConfig): void { + const { beforeRequest, httpClientType, axiosClient, ...config } = providerConfig + + // 更新基础配置 + super.updateConfig(config as AIModelConfig) + + if (config.apiUrl) { + this.apiUrl = config.apiUrl + } + + // apikey允许为空 + if (typeof config.apiKey === 'string') { + this.apiKey = config.apiKey + } + + if (config.defaultModel) { + this.defaultModel = config.defaultModel + } + + if (beforeRequest) { + this.beforeRequest = beforeRequest + } + + if (httpClientType === 'axios' && axiosClient) { + this.httpClientType = 'axios' + this.axiosClient = axiosClient + } else if (httpClientType) { + this.httpClientType = 'fetch' + } + + // 验证配置 + if (this.httpClientType === 'axios' && !this.axiosClient) { + throw new Error('axiosClient is required when httpClientType is axios') + } + } + + getBaseConfig(): ProviderConfig { + return { + apiKey: this.apiKey, + apiUrl: this.apiUrl, + defaultModel: this.defaultModel, + httpClientType: this.httpClientType + } + } + + /** + * 更新配置 + * @param config 新的AI模型配置 + */ + updateConfig(config: ProviderConfig): void { + this.setConfig(config) + } +} diff --git a/packages/plugins/robot/src/services/agentServices.ts b/packages/plugins/robot/src/services/agentServices.ts new file mode 100644 index 0000000000..893e84d690 --- /dev/null +++ b/packages/plugins/robot/src/services/agentServices.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { apiService } from './api' + +const logger = console + +/** + * AI搜索功能 + * @param content 搜索内容 + * @returns 搜索结果字符串 + */ +export const search = async (content: string): Promise => { + let result = '' + const MAX_SEARCH_LENGTH = 8000 + + try { + const res = await apiService.aiSearch(content) + + res.forEach((item: { content: string }) => { + if (result.length + item.content.length > MAX_SEARCH_LENGTH) { + return + } + result += item.content + }) + } catch (error) { + // 静默处理错误,返回空字符串 + logger.warn('AI search failed:', error) + } + + return result +} + +/** + * 获取资源列表 + * @returns 格式化的资源列表 + */ +export const fetchAssets = async () => { + try { + const res = (await apiService.getResourceList('1')) || [] + return res + .filter((item: any) => item.description) + .map((item: any) => ({ + url: item.resourceUrl, + describe: item.description + })) + } catch (error) { + logger.warn('Fetch assets failed:', error) + return [] + } +} diff --git a/packages/plugins/robot/src/services/aiClient.ts b/packages/plugins/robot/src/services/aiClient.ts new file mode 100644 index 0000000000..c7471b279e --- /dev/null +++ b/packages/plugins/robot/src/services/aiClient.ts @@ -0,0 +1,21 @@ +import { AIClient } from '@opentiny/tiny-robot-kit' +import { OpenAICompatibleProvider, type ProviderConfig } from './OpenAICompatibleProvider' + +const createClient = (config: ProviderConfig) => { + const provider: OpenAICompatibleProvider = new OpenAICompatibleProvider(config) + + const client = new AIClient({ + ...config, + provider: 'custom', + providerImplementation: provider + }) + + return { client, provider } +} + +const { client, provider } = createClient({} as ProviderConfig) + +const getClientConfig = provider.getBaseConfig.bind(provider) +const updateClientConfig = provider.updateConfig.bind(provider) + +export { client, getClientConfig, updateClientConfig } diff --git a/packages/plugins/robot/src/services/api.ts b/packages/plugins/robot/src/services/api.ts new file mode 100644 index 0000000000..905071ba6c --- /dev/null +++ b/packages/plugins/robot/src/services/api.ts @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { getMetaApi, META_SERVICE } from '@opentiny/tiny-engine-meta-register' +import type { LLMRequestBody, RequestOptions } from '../types' + +/** + * AI聊天相关API + */ +export const aiChatApi = { + /** + * 聊天补全请求 + * @param body 请求体 + * @param options 请求选项 + */ + chatCompletions: (body: LLMRequestBody, options: RequestOptions = {}) => { + return getMetaApi(META_SERVICE.Http).post(options?.url || '/app-center/api/chat/completions', body, { + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }) + }, + + /** + * Agent聊天请求 + * @param body 请求体 + * @param options 请求选项 + */ + agentChat: (body: LLMRequestBody, options: RequestOptions = {}) => { + return getMetaApi(META_SERVICE.Http).post('/app-center/api/ai/chat', body, { + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }) + }, + + /** + * AI搜索请求 + * @param content 搜索内容 + */ + aiSearch: (content: string) => { + return getMetaApi(META_SERVICE.Http).post('/app-center/api/ai/search', { content }) + } +} + +/** + * 资源管理相关API + */ +export const resourceApi = { + /** + * 上传文件 + * @param formData 文件表单数据 + */ + uploadFile: (formData: FormData) => { + return getMetaApi(META_SERVICE.Http).post('/material-center/api/resource/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) + }, + + /** + * 获取资源列表 + * @param resourceId 资源ID,默认为'1' + */ + getResourceList: (resourceId: string = '1') => { + return getMetaApi(META_SERVICE.Http).get(`/material-center/api/resource/find/${resourceId}`) + } +} + +/** + * HTTP客户端相关API + */ +export const httpApi = { + /** + * 获取HTTP客户端 + */ + getHttpClient: () => { + return getMetaApi(META_SERVICE.Http)?.getHttp() + } +} + +export const encryptApi = { + encryptKey: (apiKey: string): Promise<{ token: string }> => + getMetaApi(META_SERVICE.Http).post('/app-center/api/encrypt-key', { apiKey }) +} + +export const apiService = { + ...aiChatApi, + ...resourceApi, + ...httpApi, + ...encryptApi +} + +export default apiService diff --git a/packages/plugins/robot/assets/test.png b/packages/plugins/robot/src/types/agent.types.ts similarity index 100% rename from packages/plugins/robot/assets/test.png rename to packages/plugins/robot/src/types/agent.types.ts diff --git a/packages/plugins/robot/src/mcp/types.ts b/packages/plugins/robot/src/types/chat.types.ts similarity index 65% rename from packages/plugins/robot/src/mcp/types.ts rename to packages/plugins/robot/src/types/chat.types.ts index 781523f3e0..f5a1d7f691 100644 --- a/packages/plugins/robot/src/mcp/types.ts +++ b/packages/plugins/robot/src/types/chat.types.ts @@ -1,9 +1,12 @@ import type { BubbleContentItem } from '@opentiny/tiny-robot' +import type { ResponseToolCall } from './mcp.types' +import type { ChatMessage } from '@opentiny/tiny-robot-kit' export interface RequestOptions { url?: string model?: string headers?: Record + baseUrl?: string } export interface RequestTool { @@ -33,9 +36,14 @@ export interface LLMMessage { [prop: string]: unknown } +export type Message = ChatMessage & { + renderContent: BubbleContentItem[] + tool_calls: ResponseToolCall[] +} + export interface RobotMessage { role: string - content: string + content: string | BubbleContentItem[] renderContent?: Array [prop: string]: unknown } @@ -48,44 +56,26 @@ export interface LLMRequestBody { tools?: RequestTool[] } -export interface ReponseToolCall { - id: string - function: { - name: string - arguments: string - } -} - export interface LLMResponse { choices: Array<{ message: { role?: string content: string - tool_calls?: Array + tool_calls?: Array [prop: string]: unknown } }> } -export interface McpTool { - name: string - title?: string - description: string - inputSchema?: { - type: 'object' - properties: Record< - string, - { - type: string - description: string - [prop: string]: unknown - } - > - [prop: string]: unknown - } - [prop: string]: unknown +export enum MessageContentStatus { + INIT = 'init', + PROCESSING = 'processing', + STREAMING = 'streaming', + FINISHED = 'finished', + ABORTED = 'aborted', + ERROR = 'error' } -export interface McpListToolsResponse { - tools: Array +export enum MessageContentType { + REASONING = 'reasoning' } diff --git a/packages/plugins/robot/mock/test.ts b/packages/plugins/robot/src/types/common.types.ts similarity index 100% rename from packages/plugins/robot/mock/test.ts rename to packages/plugins/robot/src/types/common.types.ts diff --git a/packages/plugins/robot/src/types/index.ts b/packages/plugins/robot/src/types/index.ts new file mode 100644 index 0000000000..38ff023d6a --- /dev/null +++ b/packages/plugins/robot/src/types/index.ts @@ -0,0 +1,6 @@ +export * from './mcp.types' +export * from './chat.types' +export * from './common.types' +export * from './agent.types' +export * from './mode.types' +export * from './setting.types' diff --git a/packages/plugins/robot/src/types/mcp.types.ts b/packages/plugins/robot/src/types/mcp.types.ts new file mode 100644 index 0000000000..571d3c2b51 --- /dev/null +++ b/packages/plugins/robot/src/types/mcp.types.ts @@ -0,0 +1,38 @@ +export interface ResponseToolCall { + id: string + function: { + name: string + arguments: string + } +} + +export interface McpTool { + name: string + description: string + inputSchema?: { + type: 'object' + properties: Record< + string, + { + type: string + description: string + [prop: string]: unknown + } + > + [prop: string]: unknown + } + [prop: string]: unknown +} + +export interface McpListToolsResponse { + tools: Array +} + +export interface NextServerInfoResult { + device: { + referer?: string + ip?: string + [prop: string]: unknown + } + type: string +} diff --git a/packages/plugins/robot/src/types/mode.types.ts b/packages/plugins/robot/src/types/mode.types.ts new file mode 100644 index 0000000000..f8ccf015be --- /dev/null +++ b/packages/plugins/robot/src/types/mode.types.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +/** + * 聊天模式枚举 + */ +export enum ChatMode { + Agent = 'agent', + Chat = 'chat' +} + +/** + * 模式钩子接口 + * 定义所有聊天模式必须实现的配置方法和生命周期钩子 + */ +export interface ModeHooks { + // ========== 配置方法 ========== + /** 获取 API URL */ + getApiUrl: () => string + + /** 获取内容类型 */ + getContentType: () => string + + /** 获取加载类型 */ + getLoadingType: () => string + + // ========== 生命周期钩子 ========== + /** 会话开始 */ + onConversationStart: (conversationState: any, messages: any[], apis: any) => void + + /** 消息发送 */ + onMessageSent: () => void + + /** 请求前处理 */ + onBeforeRequest: (requestParams: any) => Promise + + /** 流式开始 */ + onStreamStart: (messages: any[]) => void + + /** 流式数据处理 */ + onStreamData: (data: object, content: string | object, messages: any[]) => void + + /** 请求结束 */ + onRequestEnd: (finishReason: string, content: string, messages: any[]) => Promise + + /** 工具调用流 */ + onStreamTools: (tools: Record[], context: { currentMessage: any }) => void + + /** 调用工具前 */ + onBeforeCallTool: (tool: Record, context: { currentMessage: any }) => void + + /** 调用工具后 */ + onPostCallTool: ( + tool: Record, + toolCallResult: object | string, + toolCallStatus: string, + context: { currentMessage: any } + ) => void + + /** 所有工具调用后 */ + onPostCallTools: (toolsResult: Record[], context: { currentMessage: any }) => void + + /** 消息处理完成 */ + onMessageProcessed: ( + finishReason: string, + content: string, + messages: any[], + context: { abortControllerMap: Record } + ) => Promise + + /** 会话结束 */ + onConversationEnd: (conversationId: string) => void +} diff --git a/packages/plugins/robot/src/types/setting.types.ts b/packages/plugins/robot/src/types/setting.types.ts new file mode 100644 index 0000000000..680523bb74 --- /dev/null +++ b/packages/plugins/robot/src/types/setting.types.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +/** + * 模型配置接口 + */ +export interface ModelConfig { + name: string + label: string + capabilities?: { + toolCalling?: boolean + vision?: boolean + reasoning?: boolean | object + compact?: boolean + } +} + +/** + * 模型服务接口 + */ +export interface ModelService { + id: string + provider: string + label: string + baseUrl: string + apiKey: string + allowEmptyApiKey: boolean + isBuiltIn: boolean + models: ModelConfig[] +} + +/** + * 模型选择接口 + */ +export interface ModelSelection { + serviceId: string + modelName: string +} + +/** + * 设置接口 + */ +export interface RobotSettings { + version?: number + defaultModel: ModelSelection + quickModel: ModelSelection + services: ModelService[] + chatMode: string + enableThinking: boolean +} + +export type SelectedModelInfo = ModelConfig & { + service: Omit | null + + // 配置相关 + config?: { + chatMode: string + enableThinking: boolean + } + + // 模型兼容字段 + model?: string + completeModel?: string + // 服务兼容字段 + baseUrl?: string + apiKey?: string +} diff --git a/packages/plugins/robot/src/utils/chat.utils.ts b/packages/plugins/robot/src/utils/chat.utils.ts new file mode 100644 index 0000000000..ddc0bb865b --- /dev/null +++ b/packages/plugins/robot/src/utils/chat.utils.ts @@ -0,0 +1,105 @@ +import { toRaw } from 'vue' +import type { StreamHandler } from '@opentiny/tiny-robot-kit' +import type { LLMMessage, RobotMessage } from '../types' + +// 格式化LLM输入messages消息 +export const formatMessages = (messages: LLMMessage[]) => { + const validMessageFilter = (message: LLMMessage) => message.content || message.tool_calls || message.tool_call_id + return toRaw(messages) + .filter(validMessageFilter) + .map((message) => ({ + role: message.role, + content: message.content, + ...(message.tool_calls ? { tool_calls: message.tool_calls } : {}), + ...(message.tool_call_id ? { tool_call_id: message.tool_call_id } : {}) + })) +} + +export const serializeError = (err: unknown): string => { + if (err instanceof Error) { + return JSON.stringify({ name: err.name, message: err.message }) + } + if (typeof err === 'string') return err + try { + return JSON.stringify(err) + } catch { + return String(err) + } +} + +/** + * 合并字符串字段。如果值是对象,则递归合并字符串字段 + * @param target 目标对象 + * @param source 源对象 + * @returns 合并后的对象 + */ +export const mergeStringFields = (target: Record, source: Record) => { + for (const [key, value] of Object.entries(source)) { + const targetValue = target[key] + + if (targetValue) { + if (typeof targetValue === 'string' && typeof value === 'string') { + // 都是字符串,直接拼接 + target[key] = targetValue + value + } else if (targetValue && typeof targetValue === 'object' && value && typeof value === 'object') { + // 都是对象,递归合并 + target[key] = mergeStringFields(targetValue, value) + } + } else { + // 不存在,直接赋值 + target[key] = value + } + } + + return target +} + +export const processSSEStream = (data: string, handler: StreamHandler) => { + let finishReason: string | undefined + let latestFinishReason: string | undefined + const lines = data.split('\n\n') + lines.pop() + + for (const line of lines) { + if (line.trim() === '') continue + if (line.trim() === 'data: [DONE]') { + if (latestFinishReason) { + finishReason = latestFinishReason + } + handler.onDone(finishReason) + continue + } + + try { + // 解析SSE消息 + const dataMatch = line.match(/^data: (.+)$/m) + if (!dataMatch) continue + + const data = JSON.parse(dataMatch[1]) + handler.onData(data) + latestFinishReason = data.choices?.[0]?.finish_reason || undefined + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error parsing SSE message:', error, line) + } + } +} + +export const removeLoading = (messages: RobotMessage[], name?: string) => { + const renderContent = messages.at(-1)?.renderContent + if (!renderContent || !renderContent.length) return + const index = renderContent.findLastIndex( + (item) => item.type.includes('loading') && (name ? item.content === name : true) + ) + if (index !== -1) { + renderContent?.splice(index, 1) + } +} + +export const addSystemPrompt = (messages: LLMMessage[], prompt: string = '') => { + if (!messages.length || messages[0].role !== 'system') { + messages.unshift({ role: 'system', content: prompt }) + } else if (messages[0].role === 'system' && messages[0].content !== prompt) { + messages[0].content = prompt + } +} diff --git a/packages/plugins/robot/src/utils/index.ts b/packages/plugins/robot/src/utils/index.ts new file mode 100644 index 0000000000..9a5846d006 --- /dev/null +++ b/packages/plugins/robot/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './chat.utils' +export * from './schema.utils' +export * from './meta.utils' diff --git a/packages/plugins/robot/src/utils/meta.utils.ts b/packages/plugins/robot/src/utils/meta.utils.ts new file mode 100644 index 0000000000..0d99a723d0 --- /dev/null +++ b/packages/plugins/robot/src/utils/meta.utils.ts @@ -0,0 +1,6 @@ +import { getOptions } from '@opentiny/tiny-engine-meta-register' +import meta from '../../meta' + +export const getRobotServiceOptions = () => { + return getOptions(meta.id) +} diff --git a/packages/plugins/robot/src/utils/schema.utils.ts b/packages/plugins/robot/src/utils/schema.utils.ts new file mode 100644 index 0000000000..3e070fa6c9 --- /dev/null +++ b/packages/plugins/robot/src/utils/schema.utils.ts @@ -0,0 +1,190 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { jsonrepair } from 'jsonrepair' +import SvgICons from '@opentiny/vue-icon' +import { serializeError } from './chat.utils' + +const logger = console + +/** + * 修复图标组件,如果图标不存在则使用警告图标 + */ +export const fixIconComponent = (data: any) => { + if (data?.componentName === 'Icon' && data.props?.name && !SvgICons[data.props.name as keyof typeof SvgICons]) { + data.props.name = 'IconWarning' + logger.log('autofix icon to warning:', data) + } +} + +/** + * 检查是否为纯对象 + */ +const isPlainObject = (value: unknown) => + typeof value === 'object' && value !== null && Object.prototype.toString.call(value) === '[object Object]' + +/** + * 修复组件名,如果没有组件名则设为div + */ +export const fixComponentName = (data: any) => { + if (isPlainObject(data) && !data.componentName && !data.op && !data.path) { + data.componentName = 'div' + logger.log('autofix component to div:', data) + } +} + +/** + * 修复方法对象,确保方法格式正确 + */ +export const fixMethods = (methods: Record) => { + if (methods && Object.keys(methods).length) { + Object.entries(methods).forEach(([methodName, methodValue]: [string, any]) => { + if ( + typeof methodValue !== 'object' || + methodValue?.type !== 'JSFunction' || + !methodValue?.value.startsWith('function') + ) { + methods[methodName] = { + type: 'JSFunction', + value: 'function ' + methodName + '() {\n console.log("' + methodName + '");\n}' + } + logger.log('autofix method to empty function:', methodName, methods[methodName]) + } + }) + } +} + +/** + * 递归修复Schema中的各种问题 + */ +export const schemaAutoFix = (data: any) => { + if (!data) return + if (Array.isArray(data)) { + data.forEach((item) => schemaAutoFix(item)) + return + } + + fixIconComponent(data) + fixComponentName(data) + + if (data.children && Array.isArray(data.children)) { + data.children.forEach((child: any) => schemaAutoFix(child)) + } +} + +/** + * 验证JSON Patch操作是否有效 + */ +export const isValidOperation = (operation: any): boolean => { + const allowedOps = ['add', 'remove', 'replace', 'move', 'copy', 'test', '_get'] + + if (typeof operation !== 'object' || operation === null) { + return false + } + + // 检查操作类型是否有效 + if (!operation.op || !allowedOps.includes(operation.op)) { + return false + } + + // 检查path字段是否存在且为字符串 + if (!operation.path || typeof operation.path !== 'string') { + return false + } + + // 根据操作类型检查其他必需字段 + switch (operation.op) { + case 'add': + case 'replace': + case 'test': + if (!('value' in operation)) { + return false + } + break + case 'move': + case 'copy': + if (!operation.from || typeof operation.from !== 'string') { + return false + } + break + } + + return true +} + +/** + * 验证JSON Patch是否有效 + */ +export const isValidFastJsonPatch = (patch: any): boolean => { + if (Array.isArray(patch)) { + return patch.every(isValidOperation) + } else if (typeof patch === 'object' && patch !== null) { + return isValidOperation(patch) + } + return false +} + +/** + * 自动修复JSON Patch数组,过滤无效操作 + */ +export const jsonPatchAutoFix = (jsonPatches: any[], isFinial: boolean) => { + // 流式渲染过程中,画布只渲染完整的字段或流式的children字段,避免不完整的methods/states/css等字段导致解析报错 + const childrenFilter = (patch: any, index: number, arr: any[]) => + isFinial || index < arr.length - 1 || (index === arr.length - 1 && patch.path?.startsWith('/children')) + const validJsonPatches = jsonPatches.filter(childrenFilter).filter(isValidFastJsonPatch) + + return validJsonPatches +} + +/** + * 从流式内容中提取JSON对象字符串 + */ +export const getJsonObjectString = (streamContent: string): string => { + const regex = /```(json|schema)?([\s\S]*?)```/ + const match = streamContent.match(regex) + return (match && match[2]) || streamContent +} + +/** + * 验证流式内容是否为有效的JSON Patch对象字符串 + */ +export const isValidJsonPatchObjectString = (streamContent: string) => { + const jsonString = getJsonObjectString(streamContent) + try { + const data = JSON.parse(jsonString) + if (!isValidFastJsonPatch(data)) { + return { + isError: true, + error: + 'format error: not a valid json patch format(strictly `RFC 6902` compliant JSON Patch array. Format example: `[{ "op": "add", "path": "/children/0", "value": { ... } }, {"op":"add","path":"/methods/handleBtnClick","value": { ... }}, { "op": "replace", "path": "/css", "value": "..." }]`), please check and fix the json patch format.' + } + } + return { isError: false, data } + } catch (error) { + return { isError: true, error: serializeError(error) } + } +} + +/** + * 解析和修复JSON字符串 + */ +export const parseAndRepairJson = (content: string, isFinial: boolean) => { + try { + let repairedContent = content + if (!isFinial) { + repairedContent = jsonrepair(content) + } + return { data: JSON.parse(repairedContent), isError: false } + } catch (error) { + return { isError: true, error } + } +} diff --git a/packages/plugins/robot/test/test.ts b/packages/plugins/robot/test/test.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/register/src/constants.ts b/packages/register/src/constants.ts index 6019640633..3e8a42edd5 100644 --- a/packages/register/src/constants.ts +++ b/packages/register/src/constants.ts @@ -20,7 +20,8 @@ export const META_SERVICE = { ThemeSwitch: 'engine.service.themeSwitch', Style: 'engine.service.style', McpService: 'engine.service.mcpService', - UseUtils: 'engine.service.useUtils' + UseUtils: 'engine.service.useUtils', + Robot: 'engine.service.robot' } export const META_APP = { diff --git a/tsconfig.app.json b/tsconfig.app.json index 3ab1d0ed93..27977e5dd3 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -13,6 +13,7 @@ "allowJs": true, "baseUrl": "./", "jsx": "react-jsx", + "lib": ["ES2023", "DOM", "DOM.Iterable"], "paths": { "@/*": ["packages/*"], "@opentiny/tiny-engine": ["packages/design-core/index.js"],