diff --git a/docs/docs/cn/ai-employees/_meta.json b/docs/docs/cn/ai-employees/_meta.json index 5f8e8f4b9b5..bda75b05cb7 100644 --- a/docs/docs/cn/ai-employees/_meta.json +++ b/docs/docs/cn/ai-employees/_meta.json @@ -41,7 +41,12 @@ { "type": "custom-link", "label": "使用技能", - "link": "/ai-employees/features/tool" + "link": "/ai-employees/features/skills" + }, + { + "type": "custom-link", + "label": "使用工具", + "link": "/ai-employees/features/tools" }, { "type": "custom-link", diff --git a/docs/docs/cn/ai-employees/features/built-in-employee.md b/docs/docs/cn/ai-employees/features/built-in-employee.md index 8c80975e7b8..8fb85594112 100644 --- a/docs/docs/cn/ai-employees/features/built-in-employee.md +++ b/docs/docs/cn/ai-employees/features/built-in-employee.md @@ -1,8 +1,8 @@ --- pkg: "@nocobase/plugin-ai" title: "内置 AI 员工" -description: "NocoBase 预置 AI 员工:Cole、Ellis、Dex、Viz、Lexi、Vera、Dara、Orin、Nathan 的角色定位与使用场景,含 Viz 洞察分析、Dex 数据整理、Orin 数据建模、Nathan 前端开发。" -keywords: "内置 AI 员工,Cole,Dex,Viz,Ellis,Lexi,Vera,Dara,Orin,Nathan,NocoBase" +description: "NocoBase 预置 AI 员工:Atlas、Cole、Ellis、Dex、Viz、Lexi、Vera、Dara、Orin、Nathan 的角色定位与使用场景,含 Atlas 团队领导、Viz 洞察分析、Dex 数据整理、Orin 数据建模、Nathan 前端开发。" +keywords: "内置 AI 员工,Atlas,Cole,Dex,Viz,Ellis,Lexi,Vera,Dara,Orin,Nathan,NocoBase" --- # 内置 AI 员工 @@ -14,23 +14,41 @@ NocoBase 预置了多个针对特定场景的 AI 员工。 ## 介绍 -![clipboard-image-1766653060](https://static-docs.nocobase.com/clipboard-image-1766653060.png) +![20260331165935](https://static-docs.nocobase.com/20260331165935.png) -| 员工名称 | 角色定位 | 核心能力 | -| :--- | :--- | :--- | -| **Cole** | NocoBase 助手 | 产品使用问答、文档检索 | -| **Ellis** | 电子邮件专家 | 邮件撰写、摘要生成、回复建议 | -| **Dex** | 数据整理专家 | 字段翻译、格式化、信息提取 | -| **Viz** | 洞察分析师 | 数据洞察、趋势分析、关键指标解读 | -| **Lexi** | 翻译助理 | 多语言翻译、沟通辅助 | -| **Vera** | 研究分析师 | 联网搜索、信息汇总、深度研究 | -| **Dara** | 数据可视化专家 | 图表配置、可视化报表生成 | -| **Orin** | 数据建模专家 | 辅助设计数据表结构、字段建议 | -| **Nathan** | 前端工程师 | 辅助编写前端代码片段、样式调整 | +| 员工名称 | 角色定位 | 核心能力 | +| :--------- | :------------- | :------------------------------------------------------------- | +| **Atlas** | 团队领导 | 默认通用型 AI 员工,识别用户意图,自动分派合适 AI 员工处理问题 | +| **Cole** | NocoBase 助手 | 产品使用问答、文档检索 | +| **Ellis** | 电子邮件专家 | 邮件撰写、摘要生成、回复建议 | +| **Dex** | 数据整理专家 | 字段翻译、格式化、信息提取 | +| **Viz** | 洞察分析师 | 数据洞察、趋势分析、关键指标解读 | +| **Lexi** | 翻译助理 | 多语言翻译、沟通辅助 | +| **Vera** | 研究分析师 | 联网搜索、信息汇总、深度研究 | +| **Dara** | 数据可视化专家 | 图表配置、可视化报表生成 | +| **Orin** | 数据建模专家 | 辅助设计数据表结构、字段建议 | +| **Nathan** | 前端工程师 | 辅助编写前端代码片段、样式调整 | -你可以在应用界面,点击右下角的 **AI 悬浮球**,选择你要的员工,即可开启协作。 +在应用界面点击右下角的 **AI 悬浮球**,即可进入 AI 会话并开始协作。 +如需切换其他 AI 员工,可在会话中通过 AI 员工下拉列表进行选择。 + +## 默认 AI 员工 Atlas + +### 介绍 + +> AI 员工默认入口,理解用户意图并统一分派合适的 AI 员工协助处理 + +Atlas 是 NocoBase 内置的默认总入口 AI 员工。大多数情况下,直接与 Atlas 对话即可;他会结合上下文理解用户意图,并统一分派合适的 AI 员工协助处理当前问题。 + +### 使用方式 + +点击应用界面右下角的 **AI 悬浮球**,即可进入与 Atlas 的会话。 + +在大多数场景下,直接描述你的需求即可,Atlas 会继续响应或协调合适的 AI 员工参与处理。 + +![20260331172734](https://static-docs.nocobase.com/20260331172734.png) ## 专属场景 AI 员工 diff --git a/docs/docs/cn/ai-employees/features/collaborate.md b/docs/docs/cn/ai-employees/features/collaborate.md index 340f6783fbb..43f83b76951 100644 --- a/docs/docs/cn/ai-employees/features/collaborate.md +++ b/docs/docs/cn/ai-employees/features/collaborate.md @@ -18,7 +18,7 @@ keywords: "AI 员工协作,对话面板,员工切换,模型切换,NocoBase" ### 右下角主入口 -![ai-employees-entry-bottom-right.png](https://static-docs.nocobase.com/ai-employees/2026-02-14/ai-employees-entry-bottom-right.png) +![20260331165456](https://static-docs.nocobase.com/20260331165456.png) ### 区块 Action 入口 @@ -30,10 +30,11 @@ keywords: "AI 员工协作,对话面板,员工切换,模型切换,NocoBase" ## 会话内切换 -在输入区域底部可直接切换: +大部分情况下直接和 Atlas 对话即可,他会协调合适的 AI 员工协助处理问题。 -- `AI Employee Switcher`:切换当前员工。 -- `Model Switcher`:切换当前会话模型。 +如果要使用指定 AI 员工,可以点击发送框的 AI 员工下拉列表进行选择 + +![20260331174320](https://static-docs.nocobase.com/20260331174320.png) 模型会按员工维度保存偏好,下一次进入时优先恢复。 diff --git a/docs/docs/cn/ai-employees/features/skills.md b/docs/docs/cn/ai-employees/features/skills.md new file mode 100644 index 00000000000..4ca8a68e831 --- /dev/null +++ b/docs/docs/cn/ai-employees/features/skills.md @@ -0,0 +1,20 @@ +--- +pkg: "@nocobase/plugin-ai" +title: "AI 员工使用技能" +description: "技能(Skills)是 AI 员工专业领域知识指南:General skills、Employee-specific skills。" +keywords: "AI 员工技能,Skills,NocoBase" +--- + +# 使用技能 + +技能(Skills)是提供给 AI 员工的专业领域知识指南,指导 AI 员工使用多个工具处理专业领域任务 + +## 技能结构 + +技能页分为两类: + +1. `General skills`:所有 AI 员工共享,通常只读。 +2. `Employee-specific skills`:当前员工专属。 + +![20260331183029](https://static-docs.nocobase.com/20260331183029.png) + diff --git a/docs/docs/cn/ai-employees/features/tool.md b/docs/docs/cn/ai-employees/features/tool.md deleted file mode 100644 index c923d57c5ef..00000000000 --- a/docs/docs/cn/ai-employees/features/tool.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -pkg: "@nocobase/plugin-ai" -title: "AI 员工使用技能" -description: "技能(Tools)定义 AI 员工能力:General skills、Employee-specific skills、Custom skills,技能权限 Ask/Allow 配置。" -keywords: "AI 员工技能,Tools,Ask,Allow,技能权限,NocoBase" ---- - -# 使用技能 - -技能(Tools)定义了 AI 员工“能做什么”。 - -## 技能结构 - -技能页分为三类: - -1. `General skills`:所有 AI 员工共享,只读。 -2. `Employee-specific skills`:当前员工专属,通常只读。 -3. `Custom skills`:自定义技能,可增删并配置默认权限。 - -![skills-three-sections-general-specific-custom.png](https://static-docs.nocobase.com/ai-employees/2026-02-14/skills-three-sections-general-specific-custom.png) - -## 技能权限 - -技能权限统一为: - -- `Ask`:调用前询问确认。 -- `Allow`:允许直接调用。 - -建议:涉及修改数据的技能默认使用 `Ask`。 - -![skills-permission-ask-allow-segmented.png](https://static-docs.nocobase.com/ai-employees/2026-02-14/skills-permission-ask-allow-segmented.png) - -## 添加与维护 - -在 `Custom skills` 中点击 `Add skill` 添加技能,并按业务风险配置权限。 diff --git a/docs/docs/cn/ai-employees/features/tools.md b/docs/docs/cn/ai-employees/features/tools.md new file mode 100644 index 00000000000..ff9d7a9b74d --- /dev/null +++ b/docs/docs/cn/ai-employees/features/tools.md @@ -0,0 +1,41 @@ +--- +pkg: "@nocobase/plugin-ai" +title: "AI 员工使用工具" +description: "工具(Tools)定义 AI 员工能力:General tools、Employee-specific tools、Custom tools,技能权限 Ask/Allow 配置。" +keywords: "AI 员工工具,Tools,Ask,Allow,技能权限,NocoBase" +--- + +# 使用工具 + +工具(Tools)定义了 AI 员工“能做什么”。 + +## 工具结构 + +工具页分为三类: + +1. `General tools`:所有 AI 员工共享,通常只读。 +2. `Employee-specific tools`:当前员工专属。 +3. `Custom tools`:自定义技能,可增删并配置默认权限。 + +![20260331182248](https://static-docs.nocobase.com/20260331182248.png) + +## 工具权限 + +工具权限统一为: + +- `Ask`:调用前询问确认。 +- `Allow`:允许直接调用。 + +建议:涉及修改数据的工具默认使用 `Ask`。 + +![20260331182832](https://static-docs.nocobase.com/20260331182832.png) + +## 添加与维护 + +在工作流模块中创建触发类型为 `AI employee event` 的工作流。 + +![20260331185556](https://static-docs.nocobase.com/20260331185556.png) + +在 `Custom tools` 中点击 `Add tool` 添加工作流作为的工具使用,并按业务风险配置权限。 + +![20260331185711](https://static-docs.nocobase.com/20260331185711.png) diff --git a/docs/docs/en/ai-employees/_meta.json b/docs/docs/en/ai-employees/_meta.json index dc9f75da2a9..217107dd47c 100644 --- a/docs/docs/en/ai-employees/_meta.json +++ b/docs/docs/en/ai-employees/_meta.json @@ -41,7 +41,12 @@ { "type": "custom-link", "label": "Use Skills", - "link": "/ai-employees/features/tool" + "link": "/ai-employees/features/skills" + }, + { + "type": "custom-link", + "label": "Use Tools", + "link": "/ai-employees/features/tools" }, { "type": "custom-link", diff --git a/docs/docs/en/ai-employees/features/built-in-employee.md b/docs/docs/en/ai-employees/features/built-in-employee.md index 06e77f0d264..c437aead986 100644 --- a/docs/docs/en/ai-employees/features/built-in-employee.md +++ b/docs/docs/en/ai-employees/features/built-in-employee.md @@ -6,10 +6,11 @@ You only need to configure the LLM service and enable the corresponding employee ## Introduction -![clipboard-image-1766653060](https://static-docs.nocobase.com/clipboard-image-1766653060.png) +![20260331165935](https://static-docs.nocobase.com/20260331165935.png) | Employee Name | Role Positioning | Core Capabilities | | :--- | :--- | :--- | +| **Atlas** | Team Leader | The default general-purpose AI employee that understands user intent and automatically routes the task to the appropriate AI employee | | **Cole** | NocoBase Assistant | Product Q&A, document retrieval | | **Ellis** | Email Expert | Email drafting, summary generation, reply suggestions | | **Dex** | Data Organizer | Field translation, formatting, information extraction | @@ -20,7 +21,25 @@ You only need to configure the LLM service and enable the corresponding employee | **Orin** | Data Modeling Expert | Assist in designing collection structures, field suggestions | | **Nathan** | Frontend Engineer | Assist in writing frontend code snippets, style adjustments | -You can click the **AI floating ball** in the bottom right corner of the application interface and select the employee you need to start collaborating. +Click the **AI floating ball** in the bottom-right corner of the application interface to open an AI conversation and start collaborating. + +To switch to a different AI employee, use the AI employee dropdown in the conversation. + +## Default AI Employee Atlas + +### Introduction + +> The default entry point for AI employees, understanding user intent and coordinating the appropriate AI employee to help. + +Atlas is NocoBase's built-in default AI employee and the central entry point. In most cases, you can simply talk to Atlas directly. He understands your intent from the context and coordinates the most suitable AI employee to help with the current task. + +### Usage + +Click the **AI floating ball** in the bottom-right corner of the application interface to enter a conversation with Atlas. + +In most scenarios, you can simply describe what you need. Atlas will either continue responding directly or coordinate the appropriate AI employee to assist. + +![20260331172734](https://static-docs.nocobase.com/20260331172734.png) ## Dedicated Scenario AI Employees @@ -209,4 +228,4 @@ Click the **Command Line** icon in the top right corner of `Nathan`'s dialog box ![20251022164644](https://static-docs.nocobase.com/20251022164644.png) -![20251022164713](https://static-docs.nocobase.com/20251022164713.png) \ No newline at end of file +![20251022164713](https://static-docs.nocobase.com/20251022164713.png) diff --git a/docs/docs/en/ai-employees/features/collaborate.md b/docs/docs/en/ai-employees/features/collaborate.md index 453bf509ae3..ea3e6e8d5fe 100644 --- a/docs/docs/en/ai-employees/features/collaborate.md +++ b/docs/docs/en/ai-employees/features/collaborate.md @@ -11,7 +11,7 @@ There are two common entry points: ### Bottom-right main entry -![ai-employees-entry-bottom-right.png](https://static-docs.nocobase.com/ai-employees/2026-02-14/ai-employees-entry-bottom-right.png) +![20260331165456](https://static-docs.nocobase.com/20260331165456.png) ### Block Action entry @@ -23,11 +23,12 @@ The chat panel supports common operations such as sending messages, uploading at ## In-Chat Switching -At the bottom of the input area, you can switch directly: +In most cases, you can simply talk to Atlas, and he will coordinate the appropriate AI employee to help handle the task. -- `AI Employee Switcher`: Switch current employee. -- `Model Switcher`: Switch current session model. +If you want to use a specific AI employee, click the AI employee dropdown in the message composer to select one. + +![20260331174320](https://static-docs.nocobase.com/20260331174320.png) Model preferences are saved per employee and restored next time. -![chat-footer-employee-switcher-and-model-switcher.png](https://static-docs.nocobase.com/ai-employees/2026-02-14/chat-footer-employee-switcher-and-model-switcher.png) \ No newline at end of file +![chat-footer-employee-switcher-and-model-switcher.png](https://static-docs.nocobase.com/ai-employees/2026-02-14/chat-footer-employee-switcher-and-model-switcher.png) diff --git a/docs/docs/en/ai-employees/features/skills.md b/docs/docs/en/ai-employees/features/skills.md new file mode 100644 index 00000000000..930b9aa0954 --- /dev/null +++ b/docs/docs/en/ai-employees/features/skills.md @@ -0,0 +1,12 @@ +# Using Skills + +Skills are domain-specific knowledge guides for AI employees, enabling them to use multiple tools to handle tasks in specialized domains. + +## Skill Structure + +The Skills page is divided into two categories: + +1. `General skills`: Shared by all AI employees and usually read-only. +2. `Employee-specific skills`: Exclusive to the current employee. + +![20260331183029](https://static-docs.nocobase.com/20260331183029.png) diff --git a/docs/docs/en/ai-employees/features/tool.md b/docs/docs/en/ai-employees/features/tool.md deleted file mode 100644 index ecef10a9b09..00000000000 --- a/docs/docs/en/ai-employees/features/tool.md +++ /dev/null @@ -1,28 +0,0 @@ -# Use Skills - -Skills (Tools) define what an AI Employee can do. - -## Skill Structure - -The Skills page is split into three sections: - -1. `General skills`: shared by all AI Employees, read-only. -2. `Employee-specific skills`: specific to the current employee, usually read-only. -3. `Custom skills`: custom skills that can be added/removed and configured with default permissions. - -![skills-three-sections-general-specific-custom.png](https://static-docs.nocobase.com/ai-employees/2026-02-14/skills-three-sections-general-specific-custom.png) - -## Skill Permissions - -Skill permissions are unified as: - -- `Ask`: ask for confirmation before calling. -- `Allow`: allow direct calling. - -Recommendation: use `Ask` by default for data-modifying skills. - -![skills-permission-ask-allow-segmented.png](https://static-docs.nocobase.com/ai-employees/2026-02-14/skills-permission-ask-allow-segmented.png) - -## Add and Maintain - -Click `Add skill` in `Custom skills` to add skills and configure permissions based on business risk. \ No newline at end of file diff --git a/docs/docs/en/ai-employees/features/tools.md b/docs/docs/en/ai-employees/features/tools.md new file mode 100644 index 00000000000..0d70a987a10 --- /dev/null +++ b/docs/docs/en/ai-employees/features/tools.md @@ -0,0 +1,34 @@ +# Use Tools + +tools define what an AI Employee can do. + +## Tool Structure + +The tools page is split into three sections: + +1. `General tools`: shared by all AI Employees, read-only. +2. `Employee-specific tools`: specific to the current employee, usually read-only. +3. `Custom tools`: custom tools that can be added/removed and configured with default permissions. + +![20260331182248](https://static-docs.nocobase.com/20260331182248.png) + +## Tool Permissions + +Tool permissions are unified as: + +- `Ask`: ask for confirmation before calling. +- `Allow`: allow direct calling. + +Recommendation: use `Ask` by default for data-modifying tools. + +![20260331182832](https://static-docs.nocobase.com/20260331182832.png) + +## Add and Maintain + +Create a workflow in the workflow module with the trigger type set to `AI employee event`. + +![20260331185556](https://static-docs.nocobase.com/20260331185556.png) + +Click `Add skill` in `Custom tools` to add workflow as tool and configure permissions based on business risk. + +![20260331185711](https://static-docs.nocobase.com/20260331185711.png) diff --git a/packages/core/ai/src/__tests__/ai-employees.test.ts b/packages/core/ai/src/__tests__/ai-employees.test.ts index f81a28fded7..1e6c012c8aa 100644 --- a/packages/core/ai/src/__tests__/ai-employees.test.ts +++ b/packages/core/ai/src/__tests__/ai-employees.test.ts @@ -13,6 +13,7 @@ import path from 'path'; import { AIManager } from '../ai-manager'; import { AIEmployeeManager, AIEmployeeEntry } from '../ai-employee-manager'; +const normalizeEOL = (value: string) => value.replace(/\r\n?/g, '\n'); const normalizeTools = (entry: AIEmployeeEntry) => [...(entry.skillSettings?.tools ?? [])].sort((a, b) => a.name.localeCompare(b.name)); const normalizeSkills = (entry: AIEmployeeEntry) => [...(entry.skillSettings?.skills ?? [])].sort(); @@ -39,6 +40,7 @@ describe('AI employee loader test cases', () => { '**/ai-employees/*/index.ts', '**/ai-employees/*.js', '**/ai-employees/*/index.js', + '**/ai-employees/*/prompt.md', '!**/ai-employees/**/*.d.ts', ], }, @@ -67,6 +69,7 @@ describe('AI employee loader test cases', () => { expect(employee).toBeDefined(); expect(employee.username).toBe('index-employee'); expect(employee.nickname).toBe('Index Employee'); + expect(normalizeEOL(employee.defaultPrompt)).toBe('Prompt from markdown file.\n'); expect(normalizeTools(employee)).toEqual([]); expect(normalizeSkills(employee)).toEqual([]); }); diff --git a/packages/core/ai/src/__tests__/resource/ai/ai-employees/index-employee/prompt.md b/packages/core/ai/src/__tests__/resource/ai/ai-employees/index-employee/prompt.md new file mode 100644 index 00000000000..1b40bbd1a0e --- /dev/null +++ b/packages/core/ai/src/__tests__/resource/ai/ai-employees/index-employee/prompt.md @@ -0,0 +1 @@ +Prompt from markdown file. diff --git a/packages/core/ai/src/__tests__/resource/ai/ai-employees/with-tools-merge/tools/discoveredTool.ts b/packages/core/ai/src/__tests__/resource/ai/ai-employees/with-tools-merge/tools/discoveredTool.ts index 0685ecf7851..a440856290f 100644 --- a/packages/core/ai/src/__tests__/resource/ai/ai-employees/with-tools-merge/tools/discoveredTool.ts +++ b/packages/core/ai/src/__tests__/resource/ai/ai-employees/with-tools-merge/tools/discoveredTool.ts @@ -17,7 +17,7 @@ export default defineTools({ description: 'discovered tool in merge employee', schema: null, }, - invoke: async (ctx: Context, args: any, id: string) => { + invoke: async (ctx: Context, args: any) => { return { status: 'success' }; }, }); diff --git a/packages/core/ai/src/__tests__/resource/ai/ai-employees/with-tools/tools/discoveredTool.ts b/packages/core/ai/src/__tests__/resource/ai/ai-employees/with-tools/tools/discoveredTool.ts index ec1d5f1629b..d3adf3ced15 100644 --- a/packages/core/ai/src/__tests__/resource/ai/ai-employees/with-tools/tools/discoveredTool.ts +++ b/packages/core/ai/src/__tests__/resource/ai/ai-employees/with-tools/tools/discoveredTool.ts @@ -17,7 +17,7 @@ export default defineTools({ description: 'discovered tool', schema: null, }, - invoke: async (ctx: Context, args: any, id: string) => { + invoke: async (ctx: Context, args: any) => { return { status: 'success' }; }, }); diff --git a/packages/core/ai/src/__tests__/resource/ai/skills/data-modeling/tools/read.ts b/packages/core/ai/src/__tests__/resource/ai/skills/data-modeling/tools/read.ts index c5b9652d01b..81a036111be 100644 --- a/packages/core/ai/src/__tests__/resource/ai/skills/data-modeling/tools/read.ts +++ b/packages/core/ai/src/__tests__/resource/ai/skills/data-modeling/tools/read.ts @@ -17,7 +17,7 @@ export default defineTools({ description: 'read document', schema: null, }, - invoke: async (ctx: Context, args: any, id: string) => { + invoke: async (ctx: Context, args: any) => { return { status: 'success' }; }, }); diff --git a/packages/core/ai/src/__tests__/resource/ai/skills/data-modeling/tools/search/index.ts b/packages/core/ai/src/__tests__/resource/ai/skills/data-modeling/tools/search/index.ts index 80e9ae49be6..ad2c8d90078 100644 --- a/packages/core/ai/src/__tests__/resource/ai/skills/data-modeling/tools/search/index.ts +++ b/packages/core/ai/src/__tests__/resource/ai/skills/data-modeling/tools/search/index.ts @@ -17,7 +17,7 @@ export default defineTools({ description: 'search document', schema: null, }, - invoke: async (ctx: Context, args: any, id: string) => { + invoke: async (ctx: Context, args: any) => { return { status: 'success' }; }, }); diff --git a/packages/core/ai/src/__tests__/resource/ai/skills/document/tools/read.ts b/packages/core/ai/src/__tests__/resource/ai/skills/document/tools/read.ts index c5b9652d01b..81a036111be 100644 --- a/packages/core/ai/src/__tests__/resource/ai/skills/document/tools/read.ts +++ b/packages/core/ai/src/__tests__/resource/ai/skills/document/tools/read.ts @@ -17,7 +17,7 @@ export default defineTools({ description: 'read document', schema: null, }, - invoke: async (ctx: Context, args: any, id: string) => { + invoke: async (ctx: Context, args: any) => { return { status: 'success' }; }, }); diff --git a/packages/core/ai/src/__tests__/resource/ai/skills/document/tools/search/index.ts b/packages/core/ai/src/__tests__/resource/ai/skills/document/tools/search/index.ts index 80e9ae49be6..ad2c8d90078 100644 --- a/packages/core/ai/src/__tests__/resource/ai/skills/document/tools/search/index.ts +++ b/packages/core/ai/src/__tests__/resource/ai/skills/document/tools/search/index.ts @@ -17,7 +17,7 @@ export default defineTools({ description: 'search document', schema: null, }, - invoke: async (ctx: Context, args: any, id: string) => { + invoke: async (ctx: Context, args: any) => { return { status: 'success' }; }, }); diff --git a/packages/core/ai/src/__tests__/resource/ai/tools/desc/index.ts b/packages/core/ai/src/__tests__/resource/ai/tools/desc/index.ts index 9990c489655..a48890ef0b9 100644 --- a/packages/core/ai/src/__tests__/resource/ai/tools/desc/index.ts +++ b/packages/core/ai/src/__tests__/resource/ai/tools/desc/index.ts @@ -17,7 +17,7 @@ export default defineTools({ description: 'tools with description.md', schema: null, }, - invoke: async (ctx: Context, args: any, id: string) => { + invoke: async (ctx: Context, args: any) => { return { status: 'success' }; }, }); diff --git a/packages/core/ai/src/__tests__/resource/ai/tools/group/group1.ts b/packages/core/ai/src/__tests__/resource/ai/tools/group/group1.ts index 6d68a5e606f..3c023533d41 100644 --- a/packages/core/ai/src/__tests__/resource/ai/tools/group/group1.ts +++ b/packages/core/ai/src/__tests__/resource/ai/tools/group/group1.ts @@ -17,7 +17,7 @@ export default defineTools({ description: 'hallow group1', schema: null, }, - invoke: async (ctx: Context, args: any, id: string) => { + invoke: async (ctx: Context, args: any) => { return { status: 'success' }; }, }); diff --git a/packages/core/ai/src/__tests__/resource/ai/tools/group/group2.ts b/packages/core/ai/src/__tests__/resource/ai/tools/group/group2.ts index cd572a6642a..31c6c4ea62b 100644 --- a/packages/core/ai/src/__tests__/resource/ai/tools/group/group2.ts +++ b/packages/core/ai/src/__tests__/resource/ai/tools/group/group2.ts @@ -17,7 +17,7 @@ export default defineTools({ description: 'hallow group2', schema: null, }, - invoke: async (ctx: Context, args: any, id: string) => { + invoke: async (ctx: Context, args: any) => { return { status: 'success' }; }, }); diff --git a/packages/core/ai/src/__tests__/resource/ai/tools/group/group3/index.ts b/packages/core/ai/src/__tests__/resource/ai/tools/group/group3/index.ts index f82a6cec875..8c2bb4e6805 100644 --- a/packages/core/ai/src/__tests__/resource/ai/tools/group/group3/index.ts +++ b/packages/core/ai/src/__tests__/resource/ai/tools/group/group3/index.ts @@ -17,7 +17,7 @@ export default defineTools({ description: 'hallow group3', schema: null, }, - invoke: async (ctx: Context, args: any, id: string) => { + invoke: async (ctx: Context, args: any) => { return { status: 'success' }; }, }); diff --git a/packages/core/ai/src/__tests__/resource/ai/tools/hallow/index.ts b/packages/core/ai/src/__tests__/resource/ai/tools/hallow/index.ts index ab928fba4fd..7a2fa76eaa4 100644 --- a/packages/core/ai/src/__tests__/resource/ai/tools/hallow/index.ts +++ b/packages/core/ai/src/__tests__/resource/ai/tools/hallow/index.ts @@ -17,7 +17,7 @@ export default defineTools({ description: 'hallow tools', schema: null, }, - invoke: async (ctx: Context, args: any, id: string) => { + invoke: async (ctx: Context, args: any) => { return { status: 'success' }; }, }); diff --git a/packages/core/ai/src/__tests__/resource/ai/tools/print.ts b/packages/core/ai/src/__tests__/resource/ai/tools/print.ts index 6ab6b70b366..1e835820cae 100644 --- a/packages/core/ai/src/__tests__/resource/ai/tools/print.ts +++ b/packages/core/ai/src/__tests__/resource/ai/tools/print.ts @@ -17,7 +17,7 @@ export default defineTools({ description: 'print tools', schema: null, }, - invoke: async (ctx: Context, args: any, id: string) => { + invoke: async (ctx: Context, args: any) => { return { status: 'success' }; }, }); diff --git a/packages/core/ai/src/ai-employee-manager/index.ts b/packages/core/ai/src/ai-employee-manager/index.ts index 0e2a8167af2..d364e6d59a6 100644 --- a/packages/core/ai/src/ai-employee-manager/index.ts +++ b/packages/core/ai/src/ai-employee-manager/index.ts @@ -96,6 +96,7 @@ export class DefaultAIEmployeeManager implements AIEmployeeManager { knowledgeBasePrompt: DEFAULT_KNOWLEDGE_BASE_PROMPT, enabled: true, builtIn: true, + sort: employee.sort, }, { transaction }, ); @@ -117,6 +118,7 @@ export class DefaultAIEmployeeManager implements AIEmployeeManager { skills: [...employee.skills], tools: [...mergedTools], }, + sort: employee.sort, }; await existed.update(values, { transaction }); }); diff --git a/packages/core/ai/src/ai-employee-manager/types.ts b/packages/core/ai/src/ai-employee-manager/types.ts index e27700671ea..e525fbd5bc2 100644 --- a/packages/core/ai/src/ai-employee-manager/types.ts +++ b/packages/core/ai/src/ai-employee-manager/types.ts @@ -38,6 +38,7 @@ export type AIEmployeeOptions = { bio?: string; greeting?: string; systemPrompt?: string | null; + sort?: number; }; export type AIEmployeeEntry = Omit & { diff --git a/packages/core/ai/src/loader/employee.ts b/packages/core/ai/src/loader/employee.ts index bf475421e48..991e82cb516 100644 --- a/packages/core/ai/src/loader/employee.ts +++ b/packages/core/ai/src/loader/employee.ts @@ -10,6 +10,7 @@ import { importModule } from '@nocobase/utils'; import { DirectoryScanner, DirectoryScannerOptions, FileDescriptor } from './scanner'; import { existsSync } from 'fs'; +import { readFile } from 'fs/promises'; import { AIManager } from '../ai-manager'; import { LoadAndRegister } from './types'; import { Logger } from '@nocobase/logger'; @@ -45,7 +46,7 @@ export class AIEmployeeLoader extends LoadAndRegister { const grouped = new Map(); for (const fd of this.files) { - const employeeRoot = getEmployeeRoot(fd.path); + const employeeRoot = getEmployeeRoot(fd); const group = grouped.get(employeeRoot) ?? []; group.push(fd); grouped.set(employeeRoot, group); @@ -53,10 +54,12 @@ export class AIEmployeeLoader extends LoadAndRegister { const descriptors = await Promise.all( Array.from(grouped.entries()).map(async ([employeeRoot, fds]) => { - const file = selectEmployeeDefinitionFile(fds, employeeRoot, this.log); + const file = fds.find((fd) => fd.extname === '.ts' || fd.extname === '.js'); if (!file || !existsSync(file.path)) { return null; } + const promptFile = fds.find((fd) => fd.basename === 'prompt.md'); + const name = path.basename(employeeRoot); try { const imported = await importModule(file.path); @@ -67,6 +70,19 @@ export class AIEmployeeLoader extends LoadAndRegister { return null; } const { skills = [], tools = [] } = employeeOptions; + + if (promptFile && existsSync(promptFile.path)) { + try { + employeeOptions.systemPrompt = await readFile(promptFile.path, 'utf-8'); + } catch (e) { + this.log?.error( + `ai employee [${name}] load fail: error occur when reading prompt.md at ${promptFile.path}`, + e, + ); + return null; + } + } + return { name, employeeRoot, @@ -105,25 +121,11 @@ export type AIEmployeeDescriptor = { options: AIEmployeeOptions; }; -function getEmployeeRoot(filePath: string) { - if (isIndexFile(filePath)) { - return path.dirname(filePath); +function getEmployeeRoot(fd: FileDescriptor) { + if (fd.basename === 'index.ts' || fd.basename === 'index.js' || fd.basename === 'prompt.md') { + return path.dirname(fd.path); } - const { dir, name } = path.parse(filePath); - return path.join(dir, name); -} - -function selectEmployeeDefinitionFile(files: FileDescriptor[], employeeRoot: string, log?: Logger) { - const direct = files.find((fd) => !isIndexFile(fd.path)); - const nested = files.find((fd) => isIndexFile(fd.path)); - if (direct && nested) { - log?.warn( - `ai employee [${path.basename(employeeRoot)}] duplicate definition found, use ${direct.path} instead of ${ - nested.path - }`, - ); - } - return direct ?? nested; + return fd.path; } async function discoverSkills(employeeRoot: string): Promise { @@ -161,11 +163,6 @@ async function discoverTools(employeeRoot: string): Promise(values: T[]) { return [...new Set(values)]; } diff --git a/packages/core/ai/src/mcp-manager/index.ts b/packages/core/ai/src/mcp-manager/index.ts index 8e42231eae8..eda38e6a9e6 100644 --- a/packages/core/ai/src/mcp-manager/index.ts +++ b/packages/core/ai/src/mcp-manager/index.ts @@ -139,7 +139,7 @@ export class DefaultMCPManager implements MCPManager { description: tool.description || `MCP tool: ${tool.name} from ${serverName}`, schema: tool.schema, }, - invoke: async (_ctx: Context, args: any, _id: string) => { + invoke: async (_ctx: Context, args: any) => { try { const result = await tool.invoke(args); return result; diff --git a/packages/core/ai/src/tools-manager/types.ts b/packages/core/ai/src/tools-manager/types.ts index e71d60c9f16..aa91810e246 100644 --- a/packages/core/ai/src/tools-manager/types.ts +++ b/packages/core/ai/src/tools-manager/types.ts @@ -34,7 +34,12 @@ export type ToolsOptions = { description: string; schema?: any; }; - invoke: (ctx: Context, args: any, id: string) => Promise; + invoke: (ctx: Context, args: any, runtime: ToolsRuntime) => Promise; +}; + +export type ToolsRuntime = { + toolCallId: string; + writer: (chunk: any) => void; }; export type ToolsEntry = ToolsOptions; diff --git a/packages/core/server/src/plugin.ts b/packages/core/server/src/plugin.ts index 82d2d2d27f9..cf39013a4fc 100644 --- a/packages/core/server/src/plugin.ts +++ b/packages/core/server/src/plugin.ts @@ -249,6 +249,7 @@ export abstract class Plugin implements PluginInterface { '**/ai-employees/*/index.ts', '**/ai-employees/*.js', '**/ai-employees/*/index.js', + '**/ai-employees/*/prompt.md', '!**/ai-employees/**/*.d.ts', ], }, diff --git a/packages/plugins/@nocobase/plugin-ai/src/ai/ai-employees/atlas/index.ts b/packages/plugins/@nocobase/plugin-ai/src/ai/ai-employees/atlas/index.ts new file mode 100644 index 00000000000..7c788b9921d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/ai/ai-employees/atlas/index.ts @@ -0,0 +1,36 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { defineAIEmployee } from '@nocobase/ai'; + +export default defineAIEmployee({ + sort: 0, + username: 'atlas', + description: 'Team leader of AI employee for request analysis and sub-agent orchestration', + avatar: 'nocobase-044-male', + nickname: 'Atlas', + position: 'Team leader', + bio: 'I analyze each request, identify the right specialist, and coordinate the best AI employee to complete the task efficiently.', + greeting: + "Hi, I'm Atlas. Tell me what you need, and I'll route it to the right AI specialist and coordinate the result.", + tools: [ + { + name: 'dispatch-sub-agent-task', + autoCall: true, + }, + { + name: 'list-ai-employees', + autoCall: true, + }, + { + name: 'get-ai-employee', + autoCall: true, + }, + ], +}); diff --git a/packages/plugins/@nocobase/plugin-ai/src/ai/ai-employees/atlas/prompt.md b/packages/plugins/@nocobase/plugin-ai/src/ai/ai-employees/atlas/prompt.md new file mode 100644 index 00000000000..a6efe4efa46 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/ai/ai-employees/atlas/prompt.md @@ -0,0 +1,84 @@ +You are Atlas, the main AI employee and orchestration lead for the NocoBase AI team. + +Your primary job is not to solve every problem yourself. Your job is to: +1. Understand the user's real intent +2. Select the best specialist AI employee +3. Coordinate the task through sub-agents +4. Use the available routing tools to delegate efficiently +5. Return a concise, high-quality final answer to the user + +You have three core tools for orchestration: +- \`list-ai-employees\` +- \`get-ai-employee\` +- \`dispatch-sub-agent-task\` + +**Mandatory default behavior** +- Treat delegation as the default for nearly every substantive user request +- Prefer using \`dispatch-sub-agent-task\` to have the most suitable AI employee answer the user +- If a \`\` section is present in the system prompt, treat it as the current source of truth for available specialists +- Do not call \`list-ai-employees\` when \`\` already gives enough information to choose a specialist +- Use \`list-ai-employees\` only when you genuinely need discovery because the current specialist roster is missing, ambiguous, insufficient, or likely outdated +- Use \`get-ai-employee\` when you need the full profile of a candidate before dispatching +- Do not wait for the user to tell you to delegate +- Do not give up simply because you cannot solve the task yourself +- If the request is not a trivial greeting, a pure clarification turn, or a simple meta question about your own role, attempt delegation first +- When a task seems difficult, specialized, domain-specific, or tool-dependent, delegation is required unless no suitable employee exists + +**Language** +- Reply in {{$nLang}} whenever possible +- Match the user's language and tone + +**Core behavior** +- Treat every new request as a routing and coordination problem first +- Prefer delegating specialized work to the most suitable sub-agent instead of answering directly yourself +- Your default execution path is to identify the right employee and then use \`dispatch-sub-agent-task\` +- Use direct handling only for simple meta-level guidance, clarification, or when no specialist is appropriate +- Preserve the user's original goal when delegating + +**Required execution order** +1. Analyze the user's goal +2. Identify the best specialist AI employee for the request +3. Read \`\` first if it is present and use it as your routing roster +4. Use \`list-ai-employees\` only if you still need discovery beyond what \`\` already provides +5. Use \`get-ai-employee\` if you need the full profile of a candidate before deciding +6. Use \`dispatch-sub-agent-task\` to assign one concrete, focused task to the selected employee +7. Summarize the result back to the user + +You should skip discovery or profile inspection only when the correct specialist is already clear from the request or existing conversation context. + +**Delegation policy** +- Choose the employee whose role is closest to the user's main outcome +- Inspect available AI employees from \`\` before considering discovery tools +- Do not re-list employees merely to confirm information already provided in \`\` +- Read the full profile of a candidate employee when needed before dispatching +- Delegate one focused task at a time +- If a request requires multiple specialist steps, break it into a clear sequence and coordinate them one by one +- Preserve the user's original intent when forwarding the task +- Forward the task in the user's language when appropriate and keep the instruction concrete + +**Decision rules** +- If the user's request is ambiguous, ask a short clarifying question before dispatching +- If one specialist clearly fits, dispatch immediately +- If more than one specialist could fit, choose the one whose role is closest to the user's main outcome +- If no current specialist can solve the task well, explain the limitation clearly and provide the best high-level help you can without pretending specialist execution happened + +**Anti-failure rule** +- Never stop at "I can't do that" or equivalent before checking whether another AI employee can do it +- If you are personally unable to answer, that is a signal to delegate, not a reason to quit +- Only conclude that the team cannot complete the task after checking the available roster and finding no suitable employee + +**Response style** +- Be concise, calm, and execution-oriented +- Do not expose internal routing deliberation unless it helps the user +- Summarize delegated results into one coherent final answer +- When useful, briefly state which specialist handled the task and why + +**Quality bar** +- Do not invent capabilities that a sub-agent does not have +- Do not claim delegation unless you actually delegated +- Keep the final output focused on the user's requested outcome, not on the coordination process + +**Tool usage notes** +- \`list-ai-employees\` returns lightweight profiles for discovery only; it is not a mandatory first step when \`\` already provides the roster +- \`get-ai-employee\` returns the full employee profile; if the employee has no custom \`about\`, the response may use \`defaultPrompt\` +- \`dispatch-sub-agent-task\` starts a sub-agent conversation and returns both the sub-session ID and the final answer diff --git a/packages/plugins/@nocobase/plugin-ai/src/ai/tools/chartGenerator.ts b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/chartGenerator.ts index 5bdfdc39415..b3aee7cbe04 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/ai/tools/chartGenerator.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/chartGenerator.ts @@ -14,6 +14,7 @@ import pkg from '../../../package.json'; export default defineTools({ scope: 'GENERAL', + defaultPermission: 'ALLOW', introduction: { title: `{{t("Chart generator", { ns: "${pkg.name}" })}}`, about: `{{t("Generates ECharts options (JSON) based on user input or data context.", { ns: "${pkg.name}" })}}`, diff --git a/packages/plugins/@nocobase/plugin-ai/src/ai/tools/getSkill.ts b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/getSkill.ts index d4ea491355b..c135b0cc992 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/ai/tools/getSkill.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/getSkill.ts @@ -26,7 +26,7 @@ export default defineTools({ skillName: z.string().describe('Name of skill to load'), }), }, - invoke: async (ctx, args, id) => { + invoke: async (ctx, args) => { const target = await ctx.app.aiManager.skillsManager.getSkills(args.skillName); if (!target) { return { diff --git a/packages/plugins/@nocobase/plugin-ai/src/ai/tools/sub-agents/dispatch-sub-agent-task.ts b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/sub-agents/dispatch-sub-agent-task.ts new file mode 100644 index 00000000000..c47afd6e150 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/sub-agents/dispatch-sub-agent-task.ts @@ -0,0 +1,80 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { defineTools } from '@nocobase/ai'; +import { z } from 'zod'; +import { getAccessibleAIEmployee, getAIPlugin, getSkillSettingsFromMain, updateMessageMetadata } from './shared'; +// @ts-ignore +import pkg from '../../../../package.json'; + +export default defineTools({ + scope: 'SPECIFIED', + defaultPermission: 'ALLOW', + introduction: { + title: `{{t("AI employee task dispatching", { ns: "${pkg.name}" })}}`, + about: `{{t("Awaken and assign specific tasks to ai employees", { ns: "${pkg.name}" })}}`, + }, + definition: { + name: 'dispatch-sub-agent-task', + description: 'Dispatch a question to a target AI employee and return the sub-session result.', + schema: z.object({ + username: z.string().describe('The username of the target AI employee.'), + question: z.string().describe('The question or task that should be executed by the target AI employee.'), + }), + }, + async invoke(ctx, { username, question }, { toolCallId, writer }) { + const sessionId = ctx.action?.params?.values?.sessionId; + const userId = ctx.auth?.user?.id; + if (!userId) { + throw new Error('User not authenticated'); + } + const plugin = getAIPlugin(ctx); + const employee = await getAccessibleAIEmployee(ctx, username); + if (!employee) { + throw new Error(`AI employee "${username}" not found`); + } + + let subSessionId: string; + const skillSettings = await getSkillSettingsFromMain(ctx); + const existedConversation = await plugin.aiConversationsManager.resolveSubAgentConversation(sessionId, toolCallId); + if (existedConversation) { + subSessionId = existedConversation.sessionId; + } else { + const newConversation = await plugin.aiConversationsManager.create({ + userId, + aiEmployee: { + username: employee.get('username'), + }, + title: question.slice(0, 30), + from: 'sub-agent', + options: { + skillSettings, + }, + }); + subSessionId = newConversation.sessionId; + } + + await updateMessageMetadata(ctx, toolCallId, subSessionId, 'pending'); + const answer = await plugin.subAgentsDispatcher.run({ + ctx, + sessionId: subSessionId, + employee, + model: employee.get('modelSettings') ?? ctx.action?.params?.values?.model, + question, + skillSettings, + writer, + }); + await updateMessageMetadata(ctx, toolCallId, subSessionId, 'completed'); + + return { + sessionId: subSessionId, + answer, + }; + }, +}); diff --git a/packages/plugins/@nocobase/plugin-ai/src/ai/tools/sub-agents/get-ai-employee.ts b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/sub-agents/get-ai-employee.ts new file mode 100644 index 00000000000..d7bf2278572 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/sub-agents/get-ai-employee.ts @@ -0,0 +1,38 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { defineTools } from '@nocobase/ai'; +import { z } from 'zod'; +import { getAccessibleAIEmployee, serializeEmployeeDetail } from './shared'; +// @ts-ignore +import pkg from '../../../../package.json'; + +export default defineTools({ + scope: 'SPECIFIED', + defaultPermission: 'ALLOW', + introduction: { + title: `{{t("Get AI employee", { ns: "${pkg.name}" })}}`, + about: `{{t("Get the detailed definition of AI employee", { ns: "${pkg.name}" })}}`, + }, + definition: { + name: 'get-ai-employee', + description: 'Get the detailed profile of one accessible AI employee by username.', + schema: z.object({ + username: z.string().describe('The username of the AI employee.'), + }), + }, + async invoke(ctx, args) { + const employee = await getAccessibleAIEmployee(ctx, args.username); + if (!employee) { + throw new Error(`AI employee "${args.username}" not found`); + } + + return serializeEmployeeDetail(ctx, employee); + }, +}); diff --git a/packages/plugins/@nocobase/plugin-ai/src/ai/tools/sub-agents/list-ai-employees.ts b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/sub-agents/list-ai-employees.ts new file mode 100644 index 00000000000..e81e20c4680 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/sub-agents/list-ai-employees.ts @@ -0,0 +1,34 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { defineTools } from '@nocobase/ai'; +import { z } from 'zod'; +import { listAccessibleAIEmployees, serializeEmployeeSummary } from './shared'; +// @ts-ignore +import pkg from '../../../../package.json'; + +export default defineTools({ + scope: 'SPECIFIED', + defaultPermission: 'ALLOW', + introduction: { + title: `{{t("List AI employee", { ns: "${pkg.name}" })}}`, + about: `{{t("Get the list of available AI employees", { ns: "${pkg.name}" })}}`, + }, + definition: { + name: 'list-ai-employees', + description: 'List accessible AI employees with their basic profile and skill settings.', + schema: z.object({}).describe('No input is required.'), + }, + async invoke(ctx) { + const employees = await listAccessibleAIEmployees(ctx); + return { + aiEmployees: employees.map((employee) => serializeEmployeeSummary(ctx, employee)), + }; + }, +}); diff --git a/packages/plugins/@nocobase/plugin-ai/src/ai/tools/sub-agents/shared.ts b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/sub-agents/shared.ts new file mode 100644 index 00000000000..e58c19be1c5 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/sub-agents/shared.ts @@ -0,0 +1,155 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { Context } from '@nocobase/actions'; +import type { Model } from '@nocobase/database'; +import type PluginAIServer from '../../../server/plugin'; +import type { AIEmployee as AIEmployeeType } from '../../../collections/ai-employees'; +import type { SubAgentConversationMetadata } from '../../../server/types'; + +export const getAIPlugin = (ctx: Context) => ctx.app.pm.get('ai') as PluginAIServer; + +export async function listAccessibleAIEmployees(ctx: Context): Promise { + const filter = await buildAccessibleEmployeeFilter(ctx); + return ctx.db.getRepository('aiEmployees').find({ + filter, + sort: ['sort', 'username'], + }); +} + +export async function getAccessibleAIEmployee(ctx: Context, username: string): Promise { + const filter = await buildAccessibleEmployeeFilter(ctx); + return ctx.db.getRepository('aiEmployees').findOne({ + filter: { + ...filter, + username, + }, + }); +} + +function localizeBuiltInInfo(ctx: Context, employee: Model) { + const plugin = getAIPlugin(ctx); + plugin.builtInManager.setupBuiltInInfo(ctx, employee as unknown as AIEmployeeType); +} + +export function serializeEmployeeSummary(ctx: Context, employee: Model) { + localizeBuiltInInfo(ctx, employee); + return { + username: employee.get('username') as string, + nickname: employee.get('nickname') as string, + position: employee.get('position') as string, + bio: employee.get('bio') as string, + greeting: employee.get('greeting') as string, + skillSettings: employee.get('skillSettings'), + }; +} + +export function serializeEmployeeDetail(ctx: Context, employee: Model) { + localizeBuiltInInfo(ctx, employee); + const about = employee.get('about') || employee.get('defaultPrompt') || ''; + return { + ...serializeEmployeeSummary(ctx, employee), + about, + }; +} + +async function buildAccessibleEmployeeFilter(ctx: Context) { + const filter: Record = { + enabled: true, + }; + + if (ctx.state.currentRoles?.includes('root')) { + return filter; + } + + const roleMappings = await ctx.db.getRepository('rolesAiEmployees').find({ + filter: { + roleName: ctx.state.currentRoles, + }, + }); + + const usernames = roleMappings.map((item: { aiEmployee: string }) => item.aiEmployee); + filter.username = usernames.length ? usernames : '__NO_ACCESSIBLE_AI_EMPLOYEE__'; + + return filter; +} + +export const getSkillSettingsFromMain = async (ctx: Context) => { + const sessionId = ctx.action?.params?.values?.sessionId; + if (!sessionId) { + return null; + } + const aiConversation = await ctx.db.getRepository('aiConversations').findOne({ + filter: { + sessionId, + userId: ctx.auth?.user?.id, + }, + }); + return aiConversation?.options?.skillSettings; +}; + +export const updateMessageMetadata = async ( + ctx: Context, + toolCallId: string, + subSessionId: string, + status: 'pending' | 'completed', +) => { + const sessionId = ctx.action?.params?.values?.sessionId; + if (!sessionId) { + return; + } + const aiToolMessage = await ctx.db.getRepository('aiToolMessages').findOne({ + filter: { + sessionId, + toolCallId, + }, + }); + if (!aiToolMessage) { + return; + } + const aiMessage = await ctx.db.getRepository('aiMessages').findOne({ + filter: { + sessionId, + messageId: aiToolMessage.messageId, + }, + }); + if (!aiMessage) { + return; + } + const metadata = aiMessage.metadata ?? {}; + if (!metadata.subAgentConversations) { + metadata.subAgentConversations = []; + } + + const subAgentConversations = metadata.subAgentConversations as SubAgentConversationMetadata[]; + const existingConversation = subAgentConversations.find((item) => item.sessionId === subSessionId); + + if (existingConversation) { + existingConversation.toolCallId = toolCallId; + existingConversation.status = status; + } else { + subAgentConversations.push({ + sessionId: subSessionId, + toolCallId, + status, + }); + } + + metadata.subAgentConversations = subAgentConversations; + + await ctx.db.getRepository('aiMessages').update({ + values: { + metadata, + }, + filter: { + sessionId, + messageId: aiMessage.messageId, + }, + }); +}; diff --git a/packages/plugins/@nocobase/plugin-ai/src/ai/tools/suggestions.ts b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/suggestions.ts index adc3b6d78ca..aed98d284ae 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/ai/tools/suggestions.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/ai/tools/suggestions.ts @@ -32,7 +32,7 @@ export default defineTools({ ), }), }, - invoke: async (ctx: Context, args, id) => { + invoke: async (ctx: Context, args, runtime) => { const { messageId } = ctx.action?.params?.values || {}; if (messageId) { const messageRepo = ctx.app.db.getRepository('aiMessages'); @@ -40,7 +40,7 @@ export default defineTools({ filterByTk: messageId, }); const toolCalls = message?.toolCalls || []; - const index = toolCalls.findIndex((toolCall: { id: string }) => toolCall.id === id); + const index = toolCalls.findIndex((toolCall: { id: string }) => toolCall.id === runtime.toolCallId); if (index !== -1) { toolCalls[index] = { ...toolCalls[index], diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/built-in/utils.ts b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/built-in/utils.ts index 4302488ade3..e31b474484f 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/built-in/utils.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/built-in/utils.ts @@ -18,10 +18,15 @@ export const isBuiltIn = (aiEmployee: AIEmployee) => { export const isEngineer = (aiEmployee: AIEmployee) => { return isBuiltIn(aiEmployee) && aiEmployee.username === 'nathan'; }; + export const isDataModelingAssistant = (aiEmployee: AIEmployee) => { return isBuiltIn(aiEmployee) && aiEmployee.username === 'orin'; }; +export const isLeader = (aiEmployee: AIEmployee) => { + return isBuiltIn(aiEmployee) && aiEmployee.username === 'atlas'; +}; + export const isHide = (aiEmployee: AIEmployee) => { return isBuiltIn(aiEmployee) && BUILDER_AI_USERNAMES.includes(aiEmployee.username); }; diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/AIEmployeeSwitch.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/AIEmployeeSwitch.tsx index 445832c6885..8a56892f4b9 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/AIEmployeeSwitch.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/AIEmployeeSwitch.tsx @@ -7,20 +7,16 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React, { useEffect, useMemo, useState } from 'react'; -import { Avatar, Button, Divider, Dropdown, Flex, Popover, Tag } from 'antd'; -import { UserAddOutlined, CloseCircleOutlined, CheckOutlined, DownOutlined } from '@ant-design/icons'; +import React, { useEffect, useState } from 'react'; +import { Avatar, Dropdown } from 'antd'; +import { CheckOutlined, DownOutlined } from '@ant-design/icons'; import { useToken } from '@nocobase/client'; import { observer } from '@nocobase/flow-engine'; import { useT } from '../../locale'; import { AIEmployeeListItem } from '../AIEmployeeListItem'; import { avatars } from '../avatars'; -import { ProfileCard } from '../ProfileCard'; -import { AttachmentsHeader } from './AttachmentsHeader'; -import { ContextItemsHeader } from './ContextItemsHeader'; import { useChatBoxStore } from './stores/chat-box'; import { useChatBoxActions } from './hooks/useChatBoxActions'; -import { EditMessageHeader } from './EditMessageHeader'; import { useAIConfigRepository } from '../../repositories/hooks/useAIConfigRepository'; export const AIEmployeeSwitcher: React.FC = observer(() => { @@ -57,7 +53,14 @@ export const AIEmployeeSwitcher: React.FC = observer(() => { {isSelected && } ), - onClick: () => switchAIEmployee(employee), + onClick: () => + switchAIEmployee(employee, { + clear: { + sender: false, + attachments: false, + contextItems: false, + }, + }), }; }); @@ -108,123 +111,3 @@ export const AIEmployeeSwitcher: React.FC = observer(() => { ); }); - -export const SenderHeader: React.FC = observer(() => { - const aiConfigRepository = useAIConfigRepository(); - const aiEmployees = aiConfigRepository.aiEmployees; - const { token } = useToken(); - const t = useT(); - - const currentEmployee = useChatBoxStore.use.currentEmployee(); - const isEditingMessage = useChatBoxStore.use.isEditingMessage(); - - const { switchAIEmployee } = useChatBoxActions(); - - useEffect(() => { - aiConfigRepository.getAIEmployees(); - }, [aiConfigRepository]); - - const items = aiEmployees?.map((employee) => ({ - key: employee.username, - label: ( - { - switchAIEmployee(employee); - }} - /> - ), - })); - - const avatar = useMemo(() => { - if (!currentEmployee) { - return null; - } - return avatars(currentEmployee.avatar); - }, [currentEmployee]); - - return ( -
-
- {isEditingMessage ? ( -
- -
- ) : null} - {!currentEmployee ? ( - - ) : ( - - -
- } - onClose={() => { - switchAIEmployee(null); - }} - style={{ - display: 'inline-flex', - alignItems: 'center', - background: token.colorBgContainer, - }} - > - - } placement="leftTop"> - - - -
{currentEmployee.nickname}
- -
- {currentEmployee.position} -
-
-
- - )} -
- {currentEmployee ? : null} - {currentEmployee ? : null} - - ); -}); diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/ChatButton.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/ChatButton.tsx index 04d56332a77..804ff80ba78 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/ChatButton.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/ChatButton.tsx @@ -17,7 +17,7 @@ import { useChatBoxStore } from './stores/chat-box'; import { useChatBoxActions } from './hooks/useChatBoxActions'; import { useAIConfigRepository } from '../../repositories/hooks/useAIConfigRepository'; import { FlowRuntimeContext, observer, useFlowContext } from '@nocobase/flow-engine'; -import { isHide } from '../built-in/utils'; +import { isHide, isLeader } from '../built-in/utils'; import { useChatConversationsStore } from './stores/chat-conversations'; import { useLocation } from 'react-router-dom'; @@ -42,24 +42,6 @@ export const ChatButton: React.FC = observer(() => { const { switchAIEmployee } = useChatBoxActions(); - const setWebSearch = useChatConversationsStore.use.setWebSearch(); - - const items = aiEmployees - ?.filter((employee) => !isHide(employee)) - .map((employee) => ({ - key: employee.username, - label: ( - { - setWebSearch(true); - setOpen(true); - switchAIEmployee(employee); - }} - /> - ), - })); - if (open || !aiEmployees?.length || isV1Page || !pathname.startsWith('/admin')) { return null; } @@ -68,55 +50,50 @@ export const ChatButton: React.FC = observer(() => { return ( !isMobileLayout && ( - setDropdownOpen(nextOpen)} - > -
{ - setDropdownOpen(false); - setOpen(true); - }} - className={css` - z-index: 1050; - position: fixed; - bottom: 42px; - inset-inline-end: -8px; - padding: 9px 22px 9px 10px; - border-radius: 31px 0 0 31px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; +
{ + setDropdownOpen(false); + setOpen(true); + const leaderEmployee = aiEmployees.find(isLeader); + if (leaderEmployee) { + switchAIEmployee(leaderEmployee); + } + }} + className={css` + z-index: 1050; + position: fixed; + bottom: 42px; + inset-inline-end: -8px; + padding: 9px 22px 9px 10px; + border-radius: 31px 0 0 31px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; - opacity: 0.7; - background: ${token.colorBgElevated}; - box-shadow: ${buttonShadow}; - transform: translateX(0); - will-change: transform; - transition: - transform 0.6s cubic-bezier(0.22, 1, 0.36, 1), - opacity 0.2s ease; - &:hover { - opacity: 1; - transform: translateX(-8px); - } + opacity: 0.7; + background: ${token.colorBgElevated}; + box-shadow: ${buttonShadow}; + transform: translateX(0); + will-change: transform; + transition: + transform 0.6s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.2s ease; + &:hover { + opacity: 1; + transform: translateX(-8px); + } - ${dropdownOpen - ? ` + ${dropdownOpen + ? ` opacity: 1; transform: translateX(-8px); ` - : ''} - `} - > - -
- + : ''} + `} + > + +
) ); }); diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/MessageRenderer.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/MessageRenderer.tsx index 311f98c02f8..8190be27b14 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/MessageRenderer.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/MessageRenderer.tsx @@ -186,24 +186,26 @@ export const AIMessage: React.FC<{ const messageActions = msg.type !== 'greeting' ? ( - + + ); + }; + + const renderConversationCompletedDivider = (item: Extract) => { + const nickname = item.roleName ? (roles[item.roleName] as any)?.nickname || item.roleName : undefined; + + return ( + + {t('{{ nickname }} has completed the work', { nickname })} + + ); + }; + + const renderItem = (item: RenderedItem, indexPath: string) => { + if (item.type === 'conversation-group') { + const collapsed = item.status === 'completed' ? collapsedConversationKeys[item.key] !== false : false; + + if (collapsed) { + return renderConversationToggleDivider(item, true); + } + + return ( + + {item.status === 'completed' ? renderConversationToggleDivider(item, false) : null} + {item.items.map((child, childIndex) => renderItem(child, `${indexPath}-${childIndex}`))} + {item.status === 'completed' ? renderConversationCompletedDivider(item) : null} + + ); + } + + const msg = item.message; + const role = roles[msg.role]; + if (!role) { + return null; + } + + return indexPath === String(firstMessageIndex) ? ( +
+ +
+ ) : ( + + ); + }; + return ( { }} /> )} - {messages?.length ? ( -
- {messages.map((msg, index) => { - const role = roles[msg.role]; - if (!role) { - return null; - } - return index === 0 && msg.content?.type !== 'greeting' ? ( -
- -
- ) : ( - - ); - })} -
+ {renderedMessages.length ? ( +
{renderedMessages.map((item, index) => renderItem(item, String(index)))}
) : (
{ const { conversationsService } = useChatConversationActions(); const { sendMessages } = useChatMessageActions(); - const clear = () => { - setSenderValue(''); - setSystemMessage(''); - setAttachments([]); - setContextItems([]); - setTaskVariables({}); - setOpenToolModal(false); - setActiveTool(null); - setActiveMessageId(''); - setSkillSettings(undefined); + const clear = (options?: ClearOptions) => { + const { + sender, + systemMessage, + attachments, + contextItems, + taskVariables, + toolModal, + activeTool, + activeMessageId, + skillSettings, + } = options ?? {}; + if (sender !== false) { + setSenderValue(''); + } + if (systemMessage !== false) { + setSystemMessage(''); + } + if (attachments !== false) { + setAttachments([]); + } + if (contextItems !== false) { + setContextItems([]); + } + if (taskVariables !== false) { + setTaskVariables({}); + } + if (toolModal !== false) { + setOpenToolModal(false); + } + if (activeTool !== false) { + setActiveTool(null); + } + if (activeMessageId !== false) { + setActiveMessageId(''); + } + if (skillSettings !== false) { + setSkillSettings(undefined); + } }; const send = (options: SendOptions) => { @@ -138,10 +167,10 @@ export const useChatBoxActions = () => { }, [currentEmployee]); const switchAIEmployee = useCallback( - (aiEmployee: AIEmployee) => { + (aiEmployee: AIEmployee, options?: { clear?: ClearOptions }) => { setCurrentEmployee(aiEmployee); setCurrentConversation(undefined); - clear(); + clear(options?.clear); setModel(null); if (aiEmployee) { const greetingMsg = { diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/hooks/useChatMessageActions.ts b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/hooks/useChatMessageActions.ts index 34368b21d74..b892e55f871 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/hooks/useChatMessageActions.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/hooks/useChatMessageActions.ts @@ -8,7 +8,7 @@ */ import { useChatMessagesStore } from '../stores/chat-messages'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useAPIClient, useApp, usePlugin, useRequest } from '@nocobase/client'; import { AIEmployee, Message, ResendOptions, SendOptions } from '../../types'; import PluginAIClient from '../../..'; @@ -17,7 +17,7 @@ import { useLoadMoreObserver } from './useLoadMoreObserver'; import { useT } from '../../../locale'; import { useChatConversationsStore } from '../stores/chat-conversations'; import { useChatBoxStore } from '../stores/chat-box'; -import { parseWorkContext } from '../utils'; +import { flattenMessages, parseWorkContext } from '../utils'; import { aiDebugLogger } from '../../../debug-logger'; // [AI_DEBUG] import { useChatToolCallStore } from '../stores/chat-tool-call'; import { useAIConfigRepository } from '../../../repositories/hooks/useAIConfigRepository'; @@ -30,6 +30,7 @@ export const useChatMessageActions = () => { const plugin = usePlugin('ai') as PluginAIClient; const aiConfigRepository = useAIConfigRepository(); + const isEditingMessage = useChatBoxStore.use.isEditingMessage(); const setIsEditingMessage = useChatBoxStore.use.setIsEditingMessage(); const setEditingMessageId = useChatBoxStore.use.setEditingMessageId(); const setModel = useChatBoxStore.use.setModel(); @@ -44,12 +45,18 @@ export const useChatMessageActions = () => { const setAttachments = useChatMessagesStore.use.setAttachments(); const setContextItems = useChatMessagesStore.use.setContextItems(); const setWebSearching = useChatMessagesStore.use.setWebSearching(); + const addSubAgentMessage = useChatMessagesStore.use.addSubAgentMessage(); + const addSubAgentMessages = useChatMessagesStore.use.addSubAgentMessages(); + const updateLastSubAgentMessage = useChatMessagesStore.use.updateLastSubAgentMessage(); + const updateSubAgentConversationStatus = useChatMessagesStore.use.updateSubAgentConversationStatus(); const currentConversation = useChatConversationsStore.use.currentConversation(); const currentWebSearch = useChatConversationsStore.use.webSearch(); const updateToolCallInvokeStatus = useChatToolCallStore.use.updateToolCallInvokeStatus(); + const renderedMessages = useMemo(() => flattenMessages(messages), [messages]); + const ensureModelFromStore = useCallback( async (username?: string) => { const state = useChatBoxStore.getState(); @@ -137,6 +144,182 @@ export const useChatMessageActions = () => { let result = ''; let error = false; + type MessagesStore = { + addMessage: (msg: Message) => void; + updateLast: (updater: (msg: Message) => Message) => void; + }; + + const processStreamStart = (data: any) => { + if (data.type === 'stream_start') { + console.debug('stream_start', data.from, data.sessionId); + } + }; + + const processStreamEnd = (data: any) => { + if (data.type === 'stream_end') { + console.debug('stream_end', data.from, data.sessionId); + } + }; + + const processReasoning = (data: any, store: MessagesStore) => { + if (data.type === 'reasoning' && data.body?.content && typeof data.body.content === 'string') { + // [AI_DEBUG] stream_reasoning + aiDebugLogger.log(data.sessionId, 'stream_reasoning', { + phase: 'delta', + preview: data.body.content?.slice?.(0, 120) || '', + }); + store.updateLast((last) => ({ + ...last, + content: { + ...last.content, + reasoning: { + status: data.body.status, + content: `${last.content.reasoning?.content ?? ''}${data.body.content}`, + }, + }, + loading: false, + })); + } + }; + + const processContent = (data: any, store: MessagesStore) => { + if (data.type === 'content' && data.body && typeof data.body === 'string') { + // [AI_DEBUG] stream_text + aiDebugLogger.log(data.sessionId, 'stream_text', { + preview: data.body?.slice?.(0, 100) || '', + }); + store.updateLast((last) => ({ + ...last, + createdAt: new Date().toISOString(), + content: { + ...last.content, + from: data.from, + content: (last.content as any).content + data.body, + }, + loading: false, + })); + } + }; + + const processToolCallChunks = (data: any, store: MessagesStore) => { + if (data.type === 'tool_call_chunks' && data.body?.length > 0) { + // [AI_DEBUG] stream_delta + aiDebugLogger.log(data.sessionId, 'stream_delta', { + chunk: (data.body.toolCalls ?? [])[0], + }); + store.updateLast((last) => { + const toolCalls = last.content.tool_calls || []; + const toolCallChunk = data.body[0]; + if (toolCallChunk.name) { + toolCalls.push(toolCallChunk); + } else if (toolCalls.length > 0) { + toolCalls[toolCalls.length - 1].args += data.body[0].args; + } + return { + ...last, + createdAt: new Date().toISOString(), + content: { + ...last.content, + from: data.from, + tool_calls: toolCalls, + }, + loading: false, + }; + }); + } + }; + + const processToolCall = (data: any, store: MessagesStore) => { + if (data.type === 'tool_calls' && data.body?.toolCalls?.length > 0) { + store.updateLast((last) => { + return { + ...last, + createdAt: new Date().toISOString(), + content: { + ...last.content, + from: data.from, + tool_calls: data.body.toolCalls, + }, + loading: false, + }; + }); + } + }; + + const processToolCallStatus = (data: any, store: MessagesStore) => { + if (data.type === 'tool_call_status') { + if (data.body?.toolCall) { + const { toolCall, invokeStatus } = data.body; + if (toolCall.willInterrupt) { + updateToolCallInvokeStatus(toolCall.messageId, toolCall.id, invokeStatus); + } + } + store.updateLast((last) => { + const toolCalls = last.content.tool_calls || []; + const toolCallId = data.body?.toolCall?.id; + const nextToolCalls = toolCalls.map((t) => + t.id === toolCallId + ? { + ...t, + invokeStatus: data.body?.invokeStatus ?? t.invokeStatus, + status: data.body?.status ?? t.status, + } + : t, + ); + return { + ...last, + content: { + ...last.content, + tool_calls: nextToolCalls, + }, + loading: false, + }; + }); + } + }; + + const processWebSearch = (data: any) => { + if (data.type === 'web_search' && data.body?.length) { + // [AI_DEBUG] stream_search + aiDebugLogger.log(data.sessionId, 'stream_search', { + actions: data.body, + }); + for (const item of data.body) { + setWebSearching(item); + } + } + }; + + const processNewMessage = (data: any, store: MessagesStore) => { + if (data.type === 'new_message') { + // [AI_DEBUG] stream_start + aiDebugLogger.log(data.sessionId, 'stream_start', {}); + store.addMessage({ + key: uid(), + role: aiEmployee.username, + createdAt: new Date().toISOString(), + content: { from: data.from, type: 'text', content: '' }, + loading: true, + }); + } + }; + + const processError = (data: any) => { + if (data.type === 'error') { + // [AI_DEBUG] stream_error + aiDebugLogger.log(data.sessionId, 'stream_error', { + message: data.body, + }); + error = true; + result = data.errorName ? data.errorName : data.body; + } + }; + + const mainAgentMessageStore = { + addMessage, + updateLast: updateLastMessage, + }; + try { // eslint-disable-next-line no-constant-condition while (true) { @@ -153,134 +336,44 @@ export const useChatMessageActions = () => { for (const line of lines) { try { const data = JSON.parse(line.replace(/^data: /, '')); - if (data.type === 'stream_start') { - console.log('stream_start', sessionId); - } - if (data.type === 'stream_end') { - console.log('stream_end', sessionId); - } - if (data.type === 'reasoning' && data.body?.content && typeof data.body.content === 'string') { - // [AI_DEBUG] stream_reasoning - aiDebugLogger.log(sessionId, 'stream_reasoning', { - phase: 'delta', - preview: data.body.content?.slice?.(0, 120) || '', - }); - updateLastMessage((last) => ({ - ...last, - content: { - ...last.content, - reasoning: { - status: data.body.status, - content: `${last.content.reasoning?.content ?? ''}${data.body.content}`, - }, - }, - loading: false, - })); - } - if (data.type === 'content' && data.body && typeof data.body === 'string') { - // [AI_DEBUG] stream_text - aiDebugLogger.log(sessionId, 'stream_text', { - preview: data.body?.slice?.(0, 100) || '', - }); - updateLastMessage((last) => ({ - ...last, - content: { - ...last.content, - content: (last.content as any).content + data.body, - }, - loading: false, - })); - } - if (data.type === 'tool_call_chunks' && data.body?.length > 0) { - // [AI_DEBUG] stream_delta - aiDebugLogger.log(sessionId, 'stream_delta', { - chunk: (data.body.toolCalls ?? [])[0], - }); - updateLastMessage((last) => { - const toolCalls = last.content.tool_calls || []; - const toolCallChunk = data.body[0]; - if (toolCallChunk.name) { - toolCalls.push(toolCallChunk); - } else if (toolCalls.length > 0) { - toolCalls[toolCalls.length - 1].args += data.body[0].args; - } - return { - ...last, - content: { - ...last.content, - tool_calls: toolCalls, - }, - loading: false, - }; - }); - } - if (data.type === 'tool_calls' && data.body?.toolCalls?.length > 0) { - updateLastMessage((last) => { - return { - ...last, - content: { - ...last.content, - tool_calls: data.body.toolCalls, - }, - loading: false, - }; - }); - } - if (data.type === 'tool_call_status') { - if (data.body?.toolCall) { - const { toolCall, invokeStatus } = data.body; - if (toolCall.willInterrupt) { - updateToolCallInvokeStatus(toolCall.messageId, toolCall.id, invokeStatus); - } + if (data.from === 'main-agent') { + if (sessionId !== data.sessionId) { + console.warn('invalid session id, ignore chunks', data); + continue; } - updateLastMessage((last) => { - const toolCalls = last.content.tool_calls || []; - const toolCallId = data.body?.toolCall?.id; - const nextToolCalls = toolCalls.map((t) => - t.id === toolCallId - ? { - ...t, - invokeStatus: data.body?.invokeStatus ?? t.invokeStatus, - status: data.body?.status ?? t.status, - } - : t, - ); - return { - ...last, - content: { - ...last.content, - tool_calls: nextToolCalls, - }, - loading: false, - }; - }); - } - if (data.type === 'web_search' && data.body?.length) { - // [AI_DEBUG] stream_search - aiDebugLogger.log(sessionId, 'stream_search', { - actions: data.body, - }); - for (const item of data.body) { - setWebSearching(item); + processStreamStart(data); + processStreamEnd(data); + processNewMessage(data, mainAgentMessageStore); + processReasoning(data, mainAgentMessageStore); + processContent(data, mainAgentMessageStore); + processToolCallChunks(data, mainAgentMessageStore); + processToolCall(data, mainAgentMessageStore); + processToolCallStatus(data, mainAgentMessageStore); + processWebSearch(data); + processError(data); + } else if (data.from === 'sub-agent') { + const subAgentMessageStore = { + addMessage: (msg: Message) => { + msg.role = data.username; + addSubAgentMessage(data.sessionId, msg); + }, + updateLast: (updater: (msg: Message) => Message) => { + updateLastSubAgentMessage(data.sessionId, data.username, updater); + }, + }; + + if (data.type === 'sub_agent_completed') { + updateSubAgentConversationStatus(data.sessionId, 'completed'); } - } - if (data.type === 'new_message') { - // [AI_DEBUG] stream_start - aiDebugLogger.log(sessionId, 'stream_start', {}); - addMessage({ - key: uid(), - role: aiEmployee.username, - content: { type: 'text', content: '' }, - loading: true, - }); - } - if (data.type === 'error') { - // [AI_DEBUG] stream_error - aiDebugLogger.log(sessionId, 'stream_error', { - message: data.body, - }); - error = true; - result = data.errorName ? data.errorName : data.body; + + processNewMessage(data, subAgentMessageStore); + processReasoning(data, subAgentMessageStore); + processContent(data, subAgentMessageStore); + processToolCallChunks(data, subAgentMessageStore); + processToolCall(data, subAgentMessageStore); + processToolCallStatus(data, subAgentMessageStore); + processWebSearch(data); + processError(data); } } catch (e) { console.error('Error parsing stream data:', e); @@ -357,6 +450,7 @@ export const useChatMessageActions = () => { if (last?.role === 'error') { setMessages((prev) => prev.slice(0, -1)); } + const lastRenderedMessage = renderedMessages.at(-1); const parsedWorkContext = await parseWorkContext(app, workContext); const msgs = sendMsgs.map((msg, index) => ({ @@ -366,17 +460,32 @@ export const useChatMessageActions = () => { attachments: index === 0 ? attachments : undefined, workContext: index === 0 ? parsedWorkContext : undefined, })); - addMessages( - sendMsgs.map((msg, index) => ({ - key: uid(), - role: 'user', - content: { - ...msg, - attachments: index === 0 ? attachments : undefined, - workContext: index === 0 ? workContext : undefined, - }, - })), - ); + if (lastRenderedMessage?.type === 'conversation-group' && !isEditingMessage) { + addSubAgentMessages( + lastRenderedMessage.key, + sendMsgs.map((msg, index) => ({ + key: uid(), + role: 'user', + content: { + ...msg, + attachments: index === 0 ? attachments : undefined, + workContext: index === 0 ? workContext : undefined, + }, + })), + ); + } else { + addMessages( + sendMsgs.map((msg, index) => ({ + key: uid(), + role: 'user', + content: { + ...msg, + attachments: index === 0 ? attachments : undefined, + workContext: index === 0 ? workContext : undefined, + }, + })), + ); + } if (!sessionId) { const createRes = await api.resource('aiConversations').create({ @@ -389,12 +498,22 @@ export const useChatMessageActions = () => { } setResponseLoading(true); - addMessage({ - key: uid(), - role: aiEmployee.username, - content: { type: 'text', content: '' }, - loading: true, - }); + + if (lastRenderedMessage?.type === 'conversation-group' && !isEditingMessage) { + addSubAgentMessage(lastRenderedMessage.key, { + key: uid(), + role: lastRenderedMessage.roleName, + content: { type: 'text', content: '' }, + loading: true, + }); + } else { + addMessage({ + key: uid(), + role: aiEmployee.username, + content: { type: 'text', content: '' }, + loading: true, + }); + } const controller = new AbortController(); setAbortController(controller); diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/roles.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/roles.tsx index a8eedb17f63..74e3cfd5ddc 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/roles.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/roles.tsx @@ -60,6 +60,7 @@ export const defaultRoles: GetProp = { }; export const aiEmployeeRole = (aiEmployee: AIEmployee) => ({ + nickname: aiEmployee.nickname || aiEmployee.username, placement: 'start', // avatar: aiEmployee.avatar ? ( // } placement="leftTop"> diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/stores/chat-messages.ts b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/stores/chat-messages.ts index 2de00bfcf07..f638bd74471 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/stores/chat-messages.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/stores/chat-messages.ts @@ -11,6 +11,7 @@ import { create } from 'zustand'; import { Message, Attachment, ContextItem, SkillSettings, WebSearching } from '../../types'; import { createSelectors } from './create-selectors'; import { EditorRef } from '@nocobase/client'; +import { uid } from '@formily/shared'; type ChatMessagesState = { messages: Message[]; @@ -54,6 +55,11 @@ export interface ChatMessagesActions { setWebSearching: (webSearching: WebSearching) => void; setFlowContext: (ctx: any) => void; + + addSubAgentMessage: (sessionId: string, msg: Message) => void; + addSubAgentMessages: (sessionId: string, msgs: Message[]) => void; + updateLastSubAgentMessage: (sessionId: string, username: string, updater: (msg: Message) => Message) => void; + updateSubAgentConversationStatus: (sessionId: string, status: 'pending' | 'completed') => void; } const store = create((set, get) => ({ @@ -166,6 +172,98 @@ const store = create((set, get) => ({ setFlowContext(flowContext) { set({ flowContext }); }, + + addSubAgentMessage(sessionId, msg) { + get().addSubAgentMessages(sessionId, [msg]); + }, + + addSubAgentMessages(sessionId, msgs) { + get().updateLastMessage((last) => { + return { + ...last, + content: { + ...last.content, + subAgentConversations: last.content.subAgentConversations?.map((it) => { + if (it.sessionId !== sessionId) { + return it; + } + + return { + ...it, + messages: [...it.messages, ...msgs], + }; + }) ?? [ + { + sessionId, + messages: msgs, + }, + ], + }, + loading: false, + }; + }); + }, + + updateLastSubAgentMessage: (sessionId: string, username: string, updater: (msg: Message) => Message) => { + get().updateLastMessage((last) => { + return { + ...last, + content: { + ...last.content, + subAgentConversations: last.content.subAgentConversations?.map((it) => { + if (it.sessionId !== sessionId) { + return it; + } + + const prev = [...it.messages]; + const i = prev.length - 1; + if (i >= 0) prev[i] = updater(prev[i]); + + return { + ...it, + messages: prev, + }; + }) ?? [ + { + sessionId, + messages: [ + updater({ + key: uid(), + role: username, + createdAt: new Date().toISOString(), + content: { type: 'text', content: '' }, + loading: true, + }), + ], + }, + ], + }, + loading: false, + }; + }); + }, + + updateSubAgentConversationStatus: (sessionId: string, status: 'pending' | 'completed') => { + get().updateLastMessage((last) => { + return { + ...last, + content: { + ...last.content, + subAgentConversations: last.content.subAgentConversations?.map((it) => { + if (it.sessionId !== sessionId) { + return it; + } + + return { + ...it, + status, + }; + }), + }, + loading: false, + }; + }); + }, })); export const useChatMessagesStore = createSelectors(store); diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/utils.ts b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/utils.ts index 1a97d924f91..0e0b7eb557a 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/utils.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/utils.ts @@ -8,9 +8,13 @@ */ import { Application } from '@nocobase/client'; -import { ContextItem, SkillSettings, TaskMessage } from '../types'; +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import { ContextItem, SkillSettings, TaskMessage, Message } from '../types'; import PluginAIClient from '../..'; +dayjs.extend(duration); + async function replaceVariables(template, variables, localVariables = {}) { const regex = /\{\{\s*(.*?)\s*\}\}/g; let result = template; @@ -151,3 +155,90 @@ export function getFileIconByExt(fileName: string): string { } return UNKNOWN_FILE_ICON; } + +export const formatConversationDuration = (durationMs?: number) => { + if (durationMs === undefined) { + return '--'; + } + + const value = dayjs.duration(Math.max(0, durationMs)); + + if (value.asDays() >= 1) { + return `${Math.floor(value.asDays())}d`; + } + if (value.asHours() >= 1) { + return `${Math.floor(value.asHours())}h`; + } + if (value.asMinutes() >= 1) { + return `${Math.floor(value.asMinutes())}min`; + } + return `${Math.round(value.asSeconds())}s`; +}; + +export type RenderedItem = + | { + type: 'message'; + message: Message; + isRoot: boolean; + } + | { + type: 'conversation-group'; + key: string; + roleName?: string; + status?: 'pending' | 'completed'; + durationMs?: number; + items: RenderedItem[]; + }; + +const toTimestamp = (value?: string | Date) => { + if (!value) { + return null; + } + const timestamp = new Date(value).getTime(); + return Number.isNaN(timestamp) ? null : timestamp; +}; + +const getConversationDurationMs = (messages: Message[] = []) => { + const firstTimestamp = toTimestamp(messages[0]?.createdAt); + const lastTimestamp = toTimestamp(messages[messages.length - 1]?.createdAt); + + if (firstTimestamp === null || lastTimestamp === null) { + return undefined; + } + + return Math.max(0, lastTimestamp - firstTimestamp); +}; + +export const flattenMessages = (messages: Message[] = [], isRoot = true): RenderedItem[] => { + return messages.flatMap((msg) => { + const subAgentItems = + msg.content?.subAgentConversations?.flatMap((conversation) => { + const [first, ...rest] = conversation.messages; + const conversationMessages = flattenMessages(first?.role === 'user' ? rest : conversation.messages, false); + + if (!conversationMessages.length) { + return []; + } + + return [ + { + type: 'conversation-group' as const, + key: conversation.sessionId, + roleName: conversation.messages.find((subMessage) => subMessage.role !== 'user')?.role, + status: conversation.status, + durationMs: getConversationDurationMs(conversation.messages), + items: conversationMessages, + }, + ]; + }) ?? []; + + return [ + { + type: 'message' as const, + message: msg, + isRoot, + }, + ...subAgentItems, + ]; + }); +}; diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/sub-agents/tools/index.ts b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/sub-agents/tools/index.ts new file mode 100644 index 00000000000..23b43cfa21b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/sub-agents/tools/index.ts @@ -0,0 +1,21 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { ToolsOptions, lazy } from '@nocobase/client'; + +const { SubAgentDispatchCard } = lazy(() => import('../ui/SubAgentDispatchCard'), 'SubAgentDispatchCard'); + +export const dispatchSubAgentTaskTool: [string, ToolsOptions] = [ + 'dispatch-sub-agent-task', + { + ui: { + card: SubAgentDispatchCard, + }, + }, +]; diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/sub-agents/ui/SubAgentDispatchCard.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/sub-agents/ui/SubAgentDispatchCard.tsx new file mode 100644 index 00000000000..25487782025 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/sub-agents/ui/SubAgentDispatchCard.tsx @@ -0,0 +1,169 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React, { useEffect, useState } from 'react'; +import { Avatar, Flex, Spin, Typography } from 'antd'; +import { LoadingOutlined, RightOutlined, RobotOutlined, UpOutlined } from '@ant-design/icons'; +import { ToolsUIProperties, useToken } from '@nocobase/client'; +import { observer } from '@nocobase/flow-engine'; +import { useT } from '../../../locale'; +import { useAIConfigRepository } from '../../../repositories/hooks/useAIConfigRepository'; +import { avatars } from '../../avatars'; +import { useChatMessagesStore } from '../../chatbox/stores/chat-messages'; +import { AIEmployee } from '../../types'; + +export const SubAgentDispatchCard: React.FC< + ToolsUIProperties<{ + username?: string; + question?: string; + }> +> = observer(({ messageId, toolCall }) => { + const t = useT(); + const { token } = useToken(); + const aiConfigRepository = useAIConfigRepository(); + const responseLoading = useChatMessagesStore.use.responseLoading(); + const messages = useChatMessagesStore.use.messages(); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + aiConfigRepository.getAIEmployees(); + }, [aiConfigRepository]); + + const lastMessage = messages[messages.length - 1]; + const generating = responseLoading && lastMessage?.content?.messageId === messageId; + const username = typeof toolCall.args?.username === 'string' ? toolCall.args.username : ''; + const question = typeof toolCall.args?.question === 'string' ? toolCall.args.question : ''; + const employee = resolveSubAgentDisplayInfo(aiConfigRepository.aiEmployees, username); + const showLoading = generating; + const avatarSrc = employee.avatar ? avatars(employee.avatar) : undefined; + const canExpand = !!question; + const toggleExpanded = () => { + if (!canExpand) { + return; + } + setExpanded((value) => !value); + }; + + return ( +
{ + if (!canExpand) { + return; + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggleExpanded(); + } + }} + role={canExpand ? 'button' : undefined} + tabIndex={canExpand ? 0 : undefined} + aria-expanded={canExpand ? expanded : undefined} + style={{ + margin: '8px 0', + padding: '6px 10px', + borderRadius: token.borderRadiusLG, + background: token.colorFillTertiary, + cursor: canExpand ? 'pointer' : 'default', + }} + > + + + : undefined} + style={{ + flexShrink: 0, + background: avatarSrc ? undefined : token.colorFillSecondary, + color: token.colorTextSecondary, + }} + /> + + + {`@${employee.nickname}`} + + {employee.position ? ( + + {employee.position} + + ) : null} + + + + {showLoading ? } size="small" /> : null} + {canExpand ? ( + + {expanded ? : } + + ) : null} + + + {expanded && question ? ( +
+ + {question} + +
+ ) : null} +
+ ); +}); + +export type SubAgentDisplayInfo = { + username: string; + nickname: string; + position?: string; + avatar?: string; + matched: boolean; +}; + +const resolveSubAgentDisplayInfo = ( + aiEmployees: AIEmployee[] | undefined, + username: string | undefined, +): SubAgentDisplayInfo => { + const normalizedUsername = username || ''; + const employee = aiEmployees?.find((item) => item.username === normalizedUsername); + + return { + username: normalizedUsername, + nickname: employee?.nickname || normalizedUsername, + position: employee?.position, + avatar: employee?.avatar, + matched: !!employee, + }; +}; diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/types.ts b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/types.ts index 3a9d906570f..21fd25ec66e 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/types.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/types.ts @@ -69,6 +69,7 @@ export type MessageType = 'text' | 'greeting'; export type Message = Omit & { key?: string | number; role?: string; + createdAt?: string | Date; content: { content: any; ref?: React.MutableRefObject; @@ -92,6 +93,13 @@ export type Message = Omit & { url: string; }[]; reasoning?: { status: string; content: string }; + subAgentConversations?: { + sessionId: string; + toolCallId?: string; + status?: 'pending' | 'completed'; + messages: Message[]; + }[]; + from?: 'main-agent' | 'sub-agent'; }; }; export type Action = { @@ -100,6 +108,18 @@ export type Action = { onClick: (content: string) => void; }; +export type ClearOptions = { + sender?: boolean; + systemMessage?: boolean; + attachments?: boolean; + contextItems?: boolean; + taskVariables?: boolean; + toolModal?: boolean; + activeTool?: boolean; + activeMessageId?: boolean; + skillSettings?: boolean; +}; + export type SendOptions = { sessionId?: string; aiEmployee?: AIEmployee; diff --git a/packages/plugins/@nocobase/plugin-ai/src/client/index.tsx b/packages/plugins/@nocobase/plugin-ai/src/client/index.tsx index 32b8171a531..3988406a539 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client/index.tsx @@ -48,6 +48,7 @@ import { } from './ai-employees/ai-coding/tools/context-tools'; import { vizSwitchModesTool, vizRunQueryTool } from './ai-employees/data-visualization/tools'; import { suggestionsTool } from './ai-employees/suggestions/tools'; +import { dispatchSubAgentTaskTool } from './ai-employees/sub-agents/tools'; import { setupAICoding } from './ai-employees/ai-coding/setup'; import { setupDataModeling } from './ai-employees/data-modeling/setup'; const { AIEmployeesProvider } = lazy(() => import('./ai-employees/AIEmployeesProvider'), 'AIEmployeesProvider'); @@ -181,6 +182,7 @@ export class PluginAIClient extends Plugin { this.ai.toolsManager.registerTools(...listCodeSnippetTool); this.ai.toolsManager.registerTools(...getCodeSnippetTool); this.ai.toolsManager.registerTools(...suggestionsTool); + this.ai.toolsManager.registerTools(...dispatchSubAgentTaskTool); this.ai.toolsManager.registerTools(...getContextApisTool); this.ai.toolsManager.registerTools(...getContextEnvsTool); this.ai.toolsManager.registerTools(...getContextVarsTool); diff --git a/packages/plugins/@nocobase/plugin-ai/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-ai/src/locale/en-US.json index ab3c7441e30..7e20545a4d9 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/locale/en-US.json +++ b/packages/plugins/@nocobase/plugin-ai/src/locale/en-US.json @@ -89,6 +89,8 @@ "Diagnose and fix the error": "Diagnose and fix the error", "Direction": "Direction", "Disable search": "Disable search", + "Switching to {{ nickname }}": "Switching to {{ nickname }}", + "{{ nickname }} has completed the work": "{{ nickname }} has completed the work", "Displays the AI employee’s assigned tasks on the profile when hovering over the button.": "Displays the AI employee’s assigned tasks on the profile when hovering over the button.", "Down": "Down", "Edit AI employee": "Edit AI employee", @@ -339,5 +341,12 @@ "Hello, I'm Cole. I have access to the NocoBase knowledge base. Ask me anything about setting up or using the platform.": "Hello, I'm Cole. I have access to the NocoBase knowledge base. Ask me anything about setting up or using the platform.", "Frontend code engineer": "Frontend code engineer", "An frontend engineer specializing in JavaScript, HTML, and CSS.": "An frontend engineer specializing in JavaScript, HTML, and CSS.", - "Hello, I'm Nathan, your frontend code engineer. I'll generate high-quality JavaScript / HTML / CSS code for you. What would you like me to build today?": "Hello, I'm Nathan, your frontend code engineer. I'll generate high-quality JavaScript / HTML / CSS code for you. What would you like me to build today?" + "Hello, I'm Nathan, your frontend code engineer. I'll generate high-quality JavaScript / HTML / CSS code for you. What would you like me to build today?": "Hello, I'm Nathan, your frontend code engineer. I'll generate high-quality JavaScript / HTML / CSS code for you. What would you like me to build today?", + "AI employee task dispatching": "AI employee task dispatching", + "Awaken and assign specific tasks to ai employees": "Awaken and assign specific tasks to ai employees", + "List AI employee": "List AI employee", + "Get the list of available AI employees": "Get the list of available AI employees", + "Get AI employee": "Get AI employee", + "Get the detailed definition of AI employee": "Get the detailed definition of AI employee", + "Done in {{ durationText }}": "Done in {{ durationText }}" } diff --git a/packages/plugins/@nocobase/plugin-ai/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-ai/src/locale/zh-CN.json index 504399c6eba..e6125220941 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-ai/src/locale/zh-CN.json @@ -89,6 +89,8 @@ "Diagnose and fix the error": "诊断并修复错误", "Direction": "排序方向", "Disable search": "禁用搜索", + "Switching to {{ nickname }}": "切换为 {{ nickname }}", + "{{ nickname }} has completed the work": "{{ nickname }} 已完成工作", "Displays the AI employee’s assigned tasks on the profile when hovering over the button.": "当鼠标悬停在按钮上时,会在员工资料卡片上显示任务描述。", "Down": "下移", "Edit AI employee": "编辑 AI 员工", @@ -345,5 +347,12 @@ "Hello, I'm Cole. I have access to the NocoBase knowledge base. Ask me anything about setting up or using the platform.": "你好,我是Cole。我可以进入NocoBase的知识库。问我任何关于设置或使用平台的问题。", "Frontend code engineer": "前端工程师", "An frontend engineer specializing in JavaScript, HTML, and CSS.": "一个擅长 JavaScript、HTML 和 CSS 的前端工程师。", - "Hello, I'm Nathan, your frontend code engineer. I'll generate high-quality JavaScript / HTML / CSS code for you. What would you like me to build today?": "嗨,我是 Nathan,你的前端工程师。我将为您生成高质量 JavaScript / HTML / CSS 代码。今天您想创建什么?" + "Hello, I'm Nathan, your frontend code engineer. I'll generate high-quality JavaScript / HTML / CSS code for you. What would you like me to build today?": "嗨,我是 Nathan,你的前端工程师。我将为您生成高质量 JavaScript / HTML / CSS 代码。今天您想创建什么?", + "AI employee task dispatching": "AI员工任务分派", + "Awaken and assign specific tasks to ai employees": "唤醒 AI 员工并分派具体任务", + "List AI employee": "列出 AI 员工", + "Get the list of available AI employees": "获取可用的 AI 员工列表", + "Get AI employee": "获取 AI 员工", + "Get the detailed definition of AI employee": "获取 AI 员工的详细定义", + "Done in {{ durationText }}": "已完成 {{ durationText }}" } diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/ai-conversations.ts b/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/ai-conversations.ts new file mode 100644 index 00000000000..be5a8b71a7e --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/ai-conversations.ts @@ -0,0 +1,325 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Model, Op } from '@nocobase/database'; +import PluginAIServer from '../plugin'; +import { AIMessage, AIToolCall, AIToolMessage, SubAgentConversationMetadata, UserDecision } from '../types'; +import { parseResponseMessage } from '../utils'; + +export type AIConversationsOptions = { + systemMessage?: unknown; + skillSettings?: unknown; + conversationSettings?: unknown; + [key: string]: unknown; +}; + +export type AIConversationFilterParams = { + filter: { + userId: string; + }; +}; + +export type CreateAIConversationParams = { + userId: string; + aiEmployee: { username: string }; + title?: string; + options?: AIConversationsOptions; + from?: 'main-agent' | 'sub-agent'; +}; + +export type UpdateAIConversationParams = { + userId: string; + sessionId: string; + title?: string; + options?: AIConversationsOptions; +}; + +export type GetAIConversationMessagesParams = { + userId: string; + sessionId: string; + cursor?: string; + paginate?: boolean; +}; + +export type ParsedMessageRow = AIMessage & Model; + +export type GetAIConversationMessagesResult = { + rows: any[]; + hasMore?: boolean; + cursor?: string | null; +}; + +export class AIConversationsManager { + constructor(protected plugin: PluginAIServer) {} + + async create({ userId, aiEmployee, title, options = {}, from = 'main-agent' }: CreateAIConversationParams) { + return await this.aiConversationsRepo.create({ + values: { + userId, + title, + aiEmployee, + options, + thread: 1, + from, + }, + }); + } + + async update({ userId, sessionId, title, options: inputOptions }: UpdateAIConversationParams) { + const conversation = await this.aiConversationsRepo.findOne({ + filter: { + sessionId, + userId, + }, + }); + + if (!conversation) { + throw new Error('invalid sessionId'); + } + + const { systemMessage, skillSettings, conversationSettings } = inputOptions ?? {}; + const options = conversation.options ?? {}; + if (systemMessage) { + options['systemMessage'] = systemMessage; + } + if (skillSettings) { + options['skillSettings'] = skillSettings; + } + if (conversationSettings) { + options['conversationSettings'] = conversationSettings; + } + const values: Record = { options }; + if (title) { + values.title = title; + } + + return await this.aiConversationsRepo.update({ + filter: { + userId, + sessionId, + }, + values, + }); + } + + async getMessages({ + userId, + sessionId, + cursor, + paginate = true, + }: GetAIConversationMessagesParams): Promise { + const conversation = await this.aiConversationsRepo.findOne({ + filter: { + sessionId, + userId, + }, + }); + + if (!conversation) { + throw new Error('invalid sessionId'); + } + + const pageSize = 10; + const maxLimit = 200; + const messageRepository = this.plugin.db.getRepository('aiConversations.messages', sessionId); + const filter = { + role: { + $notIn: ['tool'], + }, + }; + if (paginate && cursor) { + filter['messageId'] = { + $lt: cursor, + }; + } + const rows = await messageRepository.find({ + sort: ['-messageId'], + limit: paginate ? pageSize + 1 : maxLimit, + filter, + }); + + const hasMore = paginate && rows.length > pageSize; + const data = hasMore ? rows.slice(0, -1) : rows; + const newCursor = data.length ? data[data.length - 1].messageId : null; + + const subAgentConversations = data + .filter((row: ParsedMessageRow) => row.metadata?.subAgentConversations?.length ?? 0 > 0) + .flatMap((row: ParsedMessageRow) => row.metadata.subAgentConversations as SubAgentConversationMetadata[]); + const subAgentConversationSessionIds = [...new Set(subAgentConversations.map((item) => item.sessionId))]; + const subAgentConversationMessages = subAgentConversationSessionIds.length + ? await this.aiMessagesRepo.find({ + sort: ['messageId'], + filter: { + sessionId: { + $in: subAgentConversationSessionIds, + }, + role: { + $notIn: ['tool'], + }, + }, + }) + : []; + const subAgentConversationMessageMap = new Map(); + + const toolCallIds = [ + ...data + .filter((row: ParsedMessageRow) => row?.toolCalls?.length ?? 0 > 0) + .flatMap((row: ParsedMessageRow) => row.toolCalls) + .map((toolCall: AIToolCall) => toolCall.id), + ...subAgentConversationMessages + .filter((row: ParsedMessageRow) => row?.toolCalls?.length ?? 0 > 0) + .flatMap((row: ParsedMessageRow) => row.toolCalls) + .map((toolCall: AIToolCall) => toolCall.id), + ]; + const toolMessages = await this.aiToolMessagesRepo.find({ + filter: { + toolCallId: { + $in: toolCallIds, + }, + }, + }); + const toolMessageKey = (messageId: string, toolCallId: string) => `${messageId}:${toolCallId}`; + const toolMessageMap = new Map( + toolMessages.map((toolMessage: AIToolMessage & Model) => [ + toolMessageKey(toolMessage.messageId, toolMessage.toolCallId), + toolMessage, + ]), + ); + + const toolsList = await this.plugin.aiManager.toolManager.listTools(); + const toolsMap = new Map( + toolsList + .map((group) => group.tools) + .flat() + .map((tool) => [tool.name, tool]), + ); + + const parseMessageRow = (row: ParsedMessageRow) => { + if (row?.toolCalls?.length ?? 0 > 0) { + for (const toolCall of row.toolCalls) { + const tool = toolsMap.get(toolCall.name); + const toolMessage = toolMessageMap.get(toolMessageKey(row.messageId, toolCall.id)); + toolCall.invokeStatus = toolMessage?.invokeStatus; + toolCall.auto = toolMessage?.auto; + toolCall.status = toolMessage?.status; + toolCall.content = toolMessage?.content; + toolCall.execution = tool?.execution; + toolCall.willInterrupt = tool?.execution === 'frontend' || toolMessage?.auto === false; + toolCall.defaultPermission = tool?.defaultPermission; + } + } + + const providerOptions = this.plugin.aiManager.llmProviders.get(row.metadata?.provider); + if (!providerOptions) { + return parseResponseMessage(row); + } + const Provider = providerOptions.provider; + const provider = new Provider({ + app: this.plugin.app, + }); + return provider.parseResponseMessage(row); + }; + + for (const row of subAgentConversationMessages as ParsedMessageRow[]) { + const sessionMessages = subAgentConversationMessageMap.get(row.sessionId) ?? []; + sessionMessages.push(parseMessageRow(row)); + sessionMessages.forEach((it) => (it.content.from = 'sub-agent')); + subAgentConversationMessageMap.set(row.sessionId, sessionMessages); + } + + return { + rows: data.map((row: ParsedMessageRow) => { + const parsedRow = parseMessageRow(row); + const subAgentConversationItems = (row.metadata?.subAgentConversations as SubAgentConversationMetadata[]) ?? []; + if (subAgentConversationItems.length) { + parsedRow.content.subAgentConversations = subAgentConversationItems.map((item) => ({ + sessionId: item.sessionId, + toolCallId: item.toolCallId, + status: item.status, + messages: subAgentConversationMessageMap.get(item.sessionId) ?? [], + })); + } + parsedRow.content.from = 'main-agent'; + return parsedRow; + }), + ...(paginate && { + hasMore, + cursor: newCursor, + }), + }; + } + + async getUserDecisions(messageId: string): Promise<{ interruptId?: string; decisions: UserDecision[] } | null> { + const allInterruptedToolCall = await this.aiToolMessagesRepo.find({ + filter: { + messageId, + interruptActionOrder: { [Op.not]: null }, + }, + order: [['interruptActionOrder', 'ASC']], + }); + if (!allInterruptedToolCall.every((t) => t.invokeStatus === 'waiting')) { + return null; + } + + const message = await this.aiMessagesRepo.findOne({ + filter: { + messageId, + }, + }); + const interruptId = message?.get('metadata')?.interruptId; + return { + interruptId, + decisions: allInterruptedToolCall.map((item) => item.userDecision as UserDecision), + }; + } + + async resolveSubAgentConversation(sessionId: string, toolCallId: string): Promise { + if (!sessionId || !toolCallId) { + return null; + } + const toolMessage = await this.aiToolMessagesRepo.findOne({ + filter: { + sessionId, + toolCallId, + }, + }); + if (!toolMessage) { + return null; + } + const aiMessage = await this.aiMessagesRepo.findOne({ + filter: { + sessionId, + messageId: toolMessage.messageId, + }, + }); + if (!aiMessage) { + return null; + } + if (!aiMessage.metadata?.subAgentConversations?.length) { + return null; + } + const subAgentConversation = aiMessage.metadata.subAgentConversations.find((it) => it.toolCallId == toolCallId); + if (!subAgentConversation) { + return null; + } + return subAgentConversation; + } + + private get aiConversationsRepo() { + return this.plugin.db.getRepository('aiConversations'); + } + + private get aiMessagesRepo() { + return this.plugin.db.getRepository('aiMessages'); + } + + private get aiToolMessagesRepo() { + return this.plugin.db.getRepository('aiToolMessages'); + } +} diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/ai-employee.ts b/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/ai-employee.ts index 6ae7f3217f8..c720ace2a7d 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/ai-employee.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/ai-employee.ts @@ -8,10 +8,10 @@ */ import { Model, Op, Transaction } from '@nocobase/database'; -import { LLMProvider, ToolDefinition } from '../llm-providers/provider'; +import { LLMProvider } from '../llm-providers/provider'; import { Database } from '@nocobase/database'; import PluginAIServer from '../plugin'; -import { sendSSEError, parseVariables } from '../utils'; +import { sendSSEError, parseVariables, buildTool } from '../utils'; import { getSystemPrompt } from './prompts'; import _ from 'lodash'; import { AIChatContext, AIChatConversation, AIMessage, AIMessageInput, AIToolCall, UserDecision } from '../types'; @@ -37,36 +37,52 @@ import { convertAIMessage } from './utils'; import { BaseCallbackHandler } from '@langchain/core/callbacks/base'; import { LLMResult } from '@langchain/core/outputs'; import { Context } from '@nocobase/actions'; +import { listAccessibleAIEmployees, serializeEmployeeSummary } from '../../ai/tools/sub-agents/shared'; export interface ModelRef { llmService: string; model: string; } +export interface AIEmployeeOptions { + ctx: Context; + employee: Model; + sessionId: string; + systemMessage?: string; + skillSettings?: Record; + webSearch?: boolean; + model?: ModelRef; + legacy?: boolean; + from?: 'main-agent' | 'sub-agent'; +} + export class AIEmployee { + sessionId: string; + from = 'main-agent'; employee: Model; aiChatConversation: AIChatConversation; skillSettings?: Record; private plugin: PluginAIServer; private db: Database; - private sessionId: string; + private ctx: Context; private systemMessage: string; + private protocol: ChatStreamProtocol; private webSearch?: boolean; private model?: ModelRef; private legacy?: boolean; - private protocol: ChatStreamProtocol; - constructor( - ctx: Context, - employee: Model, - sessionId: string, - systemMessage?: string, - skillSettings?: Record, - webSearch?: boolean, - model?: ModelRef, - legacy?: boolean, - ) { + constructor({ + ctx, + employee, + sessionId, + systemMessage, + skillSettings, + webSearch, + model, + legacy, + from = 'main-agent', + }: AIEmployeeOptions) { this.employee = employee; this.ctx = ctx; this.plugin = ctx.app.pm.get('ai') as PluginAIServer; @@ -77,11 +93,21 @@ export class AIEmployee { this.skillSettings = skillSettings; this.model = model; this.legacy = legacy; + this.from = from; const builtInManager = this.plugin.builtInManager; builtInManager.setupBuiltInInfo(ctx, this.employee as unknown as AIEmployeeType); this.webSearch = webSearch; - this.protocol = ChatStreamProtocol.create(ctx); + this.protocol = ChatStreamProtocol.fromContext(ctx); + } + + async getFormatMessages(userMessages: AIMessageInput[]) { + const { provider } = await this.getLLMService(); + const { messages } = await this.aiChatConversation.getChatContext({ + userMessages, + formatMessages: (messages) => this.formatMessages({ messages, provider }), + }); + return messages; } // === Chat flow === @@ -141,7 +167,10 @@ export class AIEmployee { }: { messageId?: string; userMessages?: AIMessageInput[]; - userDecisions?: UserDecision[]; + userDecisions?: { + interruptId?: string; + decisions: UserDecision[]; + }; }) { const { provider, model, service } = await this.getLLMService(); const { historyMessages, tools, middleware, config, state } = await this.initSession({ @@ -170,7 +199,10 @@ export class AIEmployee { }: { messageId?: string; userMessages?: AIMessageInput[]; - userDecisions?: UserDecision[]; + userDecisions?: { + interruptId?: string; + decisions: UserDecision[]; + }; }) { try { const { providerName, model, provider, chatContext, config, state } = await this.buildChatContext({ @@ -208,10 +240,17 @@ export class AIEmployee { messageId, userMessages = [], userDecisions, + writer, + context, }: { messageId?: string; userMessages?: AIMessageInput[]; - userDecisions?: UserDecision[]; + userDecisions?: { + interruptId?: string; + decisions: UserDecision[]; + }; + writer?: (chunk: any) => void; + context?: any; }) { try { const { provider, chatContext, config, state } = await this.buildChatContext({ @@ -219,15 +258,24 @@ export class AIEmployee { userMessages, userDecisions, }); - const { threadId } = await this.getCurrentThread(); + const invokeConfig = { - configurable: { thread_id: threadId }, - context: { ctx: this.ctx }, + context: { ctx: this.ctx, decisions: chatContext.decisions, ...context }, recursionLimit: 100, + writer, ...config, }; + + if (this.from === 'main-agent') { + const { threadId } = await this.getCurrentThread(); + invokeConfig.configurable = { thread_id: threadId }; + } + return await this.agentInvoke(provider, chatContext, invokeConfig, state); } catch (err) { + if (err.name === 'GraphInterrupt') { + throw err; + } this.ctx.log.error(err); throw err; } @@ -291,18 +339,27 @@ export class AIEmployee { middleware?: any[]; }) { const model = provider.createModel(); - const toolDefinitions = tools?.map(ToolDefinition.from('ToolsEntry')) ?? []; - const allTools = provider.resolveTools(toolDefinitions); - const checkpointer = new SequelizeCollectionSaver(() => this.ctx.app.mainDataSource); - return createLangChainAgent({ model, tools: allTools, middleware, systemPrompt, checkpointer }); + const allTools = provider.resolveTools(tools?.map(buildTool) ?? []); + if (this.from === 'main-agent') { + const checkpointer = new SequelizeCollectionSaver(() => this.ctx.app.mainDataSource); + return createLangChainAgent({ model, tools: allTools, middleware, systemPrompt, checkpointer }); + } else { + return createLangChainAgent({ model, tools: allTools, middleware, systemPrompt }); + } } private getAgentInput(context: AIChatContext, state?: any) { - if (context.decisions?.length) { + if (context.decisions?.decisions?.length) { return new Command({ - resume: { - decisions: context.decisions, - }, + resume: context.decisions.interruptId + ? { + [context.decisions.interruptId]: { + decisions: context.decisions.decisions, + }, + } + : { + decisions: context.decisions.decisions, + }, }); } if (context.messages) { @@ -315,14 +372,17 @@ export class AIEmployee { const { systemPrompt, tools, middleware } = context; const agent = await this.createAgent({ provider, systemPrompt, tools, middleware }); const input = this.getAgentInput(context, state); - return agent.stream(input, config); + if (this.from === 'sub-agent') { + delete config.configurable; + } + return agent.stream(input, this.withRunMetadata(config)); } async agentInvoke(provider: LLMProvider, context: AIChatContext, config?: any, state?: any): Promise { const { systemPrompt, tools, middleware } = context; const agent = await this.createAgent({ provider, systemPrompt, tools, middleware }); const input = this.getAgentInput(context, state); - return agent.invoke(input, config); + return agent.invoke(input, this.withRunMetadata(config)); } async prepareChatStream({ @@ -347,8 +407,8 @@ export class AIEmployee { { signal, streamMode: ['updates', 'messages', 'custom'], - configurable: { thread_id: threadId }, - context: { ctx: this.ctx }, + configurable: this.from === 'main-agent' ? { thread_id: threadId } : undefined, + context: { ctx: this.ctx, decisions: chatContext.decisions }, recursionLimit: 100, ...config, }, @@ -372,7 +432,7 @@ export class AIEmployee { responseMetadata: Map; }, ) { - let toolCalls: AIToolCall[]; + const aiMessageIdMap = new Map(); const { signal, providerName, model, provider, responseMetadata, allowEmpty = false } = options; let isReasoning = false; @@ -391,62 +451,74 @@ export class AIEmployee { await this.aiChatConversation.withTransaction(async (conversation, transaction) => { const result: AIMessage = await conversation.addMessages(values); - if (toolCalls?.length) { - await this.initToolCall(transaction, result.messageId, toolCalls as any); - } }); } }); try { - this.protocol.startStream(); + const aiEmployeeConversation = { + sessionId: this.sessionId, + from: this.from, + username: this.employee.username, + }; + this.protocol.with(aiEmployeeConversation).startStream(); for await (const [mode, chunks] of stream) { if (mode === 'messages') { - const [chunk] = chunks; + const [chunk, metadata] = chunks; + const { currentConversation } = metadata; if (chunk.type === 'ai') { gathered = gathered !== undefined ? concat(gathered, chunk) : chunk; if (chunk.content) { if (isReasoning) { isReasoning = false; - this.protocol.stopReasoning(); + this.protocol.with(currentConversation).stopReasoning(); } const parsedContent = provider.parseResponseChunk(chunk.content); if (parsedContent) { - this.protocol.content(parsedContent); + this.protocol.with(currentConversation).content(parsedContent); } } if (chunk.tool_call_chunks?.length) { - this.protocol.toolCallChunks(chunk.tool_call_chunks); + this.protocol.with(currentConversation).toolCallChunks(chunk.tool_call_chunks); } const webSearch = provider.parseWebSearchAction(chunk); if (webSearch?.length) { - this.protocol.webSearch(webSearch); + this.protocol.with(currentConversation).webSearch(webSearch); } const reasoningContent = provider.parseReasoningContent(chunk); if (reasoningContent) { isReasoning = true; - this.protocol.reasoning(reasoningContent); + this.protocol.with(currentConversation).reasoning(reasoningContent); } } } else if (mode === 'updates') { if ('__interrupt__' in chunks) { + const interruptId = chunks.__interrupt__[0].id; const interruptActions = this.toInterruptActions(chunks.__interrupt__[0].value); if (interruptActions.size) { - for (const toolCall of toolCalls ?? []) { - const interruptAction = interruptActions.get(toolCall.name); - if (!interruptAction) { + const toolsMap = await this.getToolsMap(); + for (const interruptAction of interruptActions.values()) { + if (!interruptAction.currentConversation || !interruptAction.toolCall) { + this.logger.warn('currentConversation or toolCall not exist in __interrupt__', interruptAction); continue; } - await this.updateToolCallInterrupted(toolCall.messageId, toolCall.id, interruptAction); - this.protocol.toolCallStatus({ + const { sessionId, from, username } = interruptAction.currentConversation; + const { id: toolCallId, name: toolCallName } = interruptAction.toolCall; + const messageId = aiMessageIdMap.get(sessionId); + if (!messageId) { + continue; + } + + await this.updateToolCallInterrupted(sessionId, messageId, toolCallId, interruptId, interruptAction); + this.protocol.with(interruptAction.currentConversation).toolCallStatus({ toolCall: { - messageId: toolCall.messageId, - id: toolCall.id, - name: toolCall.name, - willInterrupt: toolCall.willInterrupt, + messageId: messageId, + id: toolCallId, + name: toolCallName, + willInterrupt: this.shouldInterruptToolCall(toolsMap.get(toolCallName)), }, invokeStatus: 'interrupted', interruptAction, @@ -455,7 +527,10 @@ export class AIEmployee { } } } else if (mode === 'custom') { + const { currentConversation } = chunks; if (chunks.action === 'AfterAIMessageSaved') { + aiMessageIdMap.set(currentConversation.sessionId, chunks.body.messageId); + const data = responseMetadata.get(chunks.body.id); if (data) { const savedMessage = await this.aiMessagesModel.findOne({ @@ -483,12 +558,11 @@ export class AIEmployee { } } } else if (chunks.action === 'initToolCalls') { - toolCalls = chunks.body?.toolCalls ?? []; - this.protocol.toolCalls(chunks.body); + this.protocol.with(currentConversation).toolCalls(chunks.body); } else if (chunks.action === 'beforeToolCall') { const toolsMap = await this.getToolsMap(); const willInterrupt = this.shouldInterruptToolCall(toolsMap.get(chunks.body?.toolCall?.name)); - this.protocol.toolCallStatus({ + this.protocol.with(currentConversation).toolCallStatus({ toolCall: { messageId: chunks.body?.toolCall?.messageId, id: chunks.body?.toolCall?.id, @@ -500,7 +574,7 @@ export class AIEmployee { } else if (chunks.action === 'afterToolCall') { const toolsMap = await this.getToolsMap(); const willInterrupt = this.shouldInterruptToolCall(toolsMap.get(chunks.body?.toolCall?.name)); - this.protocol.toolCallStatus({ + this.protocol.with(currentConversation).toolCallStatus({ toolCall: { messageId: chunks.body?.toolCall?.messageId, id: chunks.body?.toolCall?.id, @@ -521,7 +595,7 @@ export class AIEmployee { for (const { metadata } of messages) { const tools = toolsMap.get(metadata.toolName); const toolCallResult = toolCallResultMap.get(metadata.toolCallId); - this.protocol.toolCallStatus({ + this.protocol.with(currentConversation).toolCallStatus({ toolCall: { messageId, id: metadata.toolCallId, @@ -534,7 +608,9 @@ export class AIEmployee { } } - this.protocol.newMessage(); + this.protocol.with(currentConversation).newMessage(); + } else if (chunks.action === 'afterSubAgentInvoke') { + this.protocol.with(currentConversation).subAgentCompleted(); } } } @@ -544,7 +620,7 @@ export class AIEmployee { return; } - this.protocol.endStream(); + this.protocol.with(aiEmployeeConversation).endStream(); } catch (err) { this.ctx.log.error(err); if (err.name === 'GraphRecursionError') { @@ -553,7 +629,9 @@ export class AIEmployee { this.sendErrorResponse(provider.parseResponseError(err)); } } finally { - this.ctx.res.end(); + if (this.from === 'main-agent') { + this.ctx.res.end(); + } } } @@ -650,6 +728,7 @@ export class AIEmployee { } const availableSkills = await this.getAvailableSkills(); + const availableAIEmployees = await this.getAvailableAIEmployees(); const systemPrompt = getSystemPrompt({ aiEmployee: { @@ -667,6 +746,7 @@ export class AIEmployee { }, knowledgeBase, availableSkills, + availableAIEmployees, }); const { important } = this.ctx.action.params.values || {}; @@ -811,30 +891,68 @@ If information is missing, clearly state it in the summary.`; } async updateToolCallInterrupted( + sessionId: string, messageId: string, toolCallId: string, + interruptId: string, interruptAction: { order: number; description?: string; allowed_decisions?: string[]; }, ) { - const [updated] = await this.aiToolMessagesModel.update( - { - invokeStatus: 'interrupted', - interruptActionOrder: interruptAction.order, - interruptAction, - }, - { - where: { - sessionId: this.sessionId, - messageId, - toolCallId, - invokeStatus: 'init', + return await this.db.sequelize.transaction(async (transaction) => { + const [updated] = await this.aiToolMessagesModel.update( + { + invokeStatus: 'interrupted', + interruptActionOrder: interruptAction.order, + interruptAction, }, - }, - ); - return updated; + { + where: { + sessionId, + messageId, + toolCallId, + invokeStatus: 'init', + }, + transaction, + }, + ); + + if (!updated) { + return updated; + } + + const message = await this.aiMessagesModel.findOne({ + where: { + messageId, + sessionId, + }, + transaction, + }); + + if (!message) { + return updated; + } + + await this.aiMessagesModel.update( + { + metadata: { + ...(message.get('metadata') ?? {}), + interruptId, + }, + }, + { + where: { + messageId, + sessionId, + }, + transaction, + }, + ); + + return updated; + }); } async updateToolCallPending(messageId: string, toolCallId: string) { @@ -900,7 +1018,6 @@ If information is missing, clearly state it in the summary.`; return ( await this.aiToolMessagesModel.findOne({ where: { - sessionId: this.sessionId, messageId, toolCallId, }, @@ -912,7 +1029,6 @@ If information is missing, clearly state it in the summary.`; const list: AIToolMessage[] = ( await this.aiToolMessagesModel.findAll({ where: { - sessionId: this.sessionId, messageId, toolCallId: { [Op.in]: toolCallIds, @@ -923,20 +1039,6 @@ If information is missing, clearly state it in the summary.`; return new Map(list.map((it) => [it.toolCallId, it])); } - async getUserDecisions(messageId: string): Promise { - const allInterruptedToolCall = await this.aiToolMessagesModel.findAll({ - where: { - messageId, - interruptActionOrder: { [Op.not]: null }, - }, - order: [['interruptActionOrder', 'ASC']], - }); - if (!allInterruptedToolCall.every((t) => t.invokeStatus === 'waiting')) { - return []; - } - return allInterruptedToolCall.map((item) => item.userDecision as UserDecision); - } - async cancelToolCall() { let messageId; const historyMessages = await this.db.getRepository('aiConversations.messages', this.sessionId).find({ @@ -951,7 +1053,6 @@ If information is missing, clearly state it in the summary.`; const toolMessages: AIToolMessage[] = ( await this.aiToolMessagesModel.findAll>({ where: { - sessionId: this.sessionId, messageId, invokeStatus: { [Op.ne]: 'confirmed', @@ -1218,7 +1319,20 @@ If information is missing, clearly state it in the summary.`; private toInterruptActions(interrupt: { actionRequests: { name: string; args: unknown; description: string }[]; reviewConfigs: { actionName: string; allowedDecisions: string[] }[]; - }): Map { + }): Map< + string, + { + order: number; + description: string; + allowedDecisions: string[]; + toolCall?: { id: string; name: string }; + currentConversation?: { + sessionId: string; + from: string; + username: string; + }; + } + > { const result = new Map(); const { actionRequests = [], reviewConfigs = [] } = interrupt; if (!actionRequests.length) { @@ -1227,11 +1341,22 @@ If information is missing, clearly state it in the summary.`; let order = 0; const actionRequestsMap = new Map(actionRequests.map((x) => [x.name, x])); const reviewConfigsMap = new Map(reviewConfigs.map((x) => [x.actionName, x])); + for (const [name, actionRequest] of actionRequestsMap.entries()) { + const payload = actionRequest.description ? JSON.parse(actionRequest.description) : null; result.set(name, { order: order++, description: actionRequest.description, allowedDecisions: reviewConfigsMap.get(name)?.allowedDecisions, + toolCall: { + id: payload.toolCallId, + name: payload.toolCallName, + }, + currentConversation: { + sessionId: payload.sessionId, + from: payload.from, + username: payload.username, + }, }); } return result; @@ -1267,7 +1392,7 @@ If information is missing, clearly state it in the summary.`; const specifiedSkillNames = this.employee.skillSettings?.skills ?? []; const specifiedSkills = specifiedSkillNames.length ? await skillsManager.getSkills(specifiedSkillNames) : []; const skillFilter = this.skillSettings?.skills ?? []; - return _.uniqBy([...(generalSkills || []), ...(specifiedSkills || [])], 'name').filter( + return _.uniqBy([...(specifiedSkills || []), ...(generalSkills || [])], 'name').filter( (it) => skillFilter.length === 0 || skillFilter.includes(it.name), ); } @@ -1345,6 +1470,13 @@ If information is missing, clearly state it in the summary.`; return result; } + private async getAvailableAIEmployees() { + const availableAIEmployees = (await listAccessibleAIEmployees(this.ctx)).map((employee) => + serializeEmployeeSummary(this.ctx, employee), + ); + return availableAIEmployees; + } + private getMiddleware(options: { providerName: string; model: string; @@ -1395,6 +1527,20 @@ If information is missing, clearly state it in the summary.`; return this.toolsManager.listTools(filter); } + private withRunMetadata(config?: any) { + return { + ...config, + metadata: { + ...(config?.metadata ?? {}), + currentConversation: { + sessionId: this.sessionId, + from: this.from, + username: this.employee.get('username'), + }, + }, + }; + } + private get toolsManager(): ToolsManager { return this.ctx.app.aiManager.toolsManager; } @@ -1451,7 +1597,7 @@ class AgentThread { } } -class ChatStreamProtocol { +export class ChatStreamProtocol { private _statistics = { sent: 0, addSent: (s: number) => { @@ -1462,79 +1608,94 @@ class ChatStreamProtocol { }, }; - constructor(private readonly ctx: Context) {} + constructor(private readonly streamConsumer: StreamConsumer) {} - static create(ctx: Context) { - return new ChatStreamProtocol(ctx); + static fromContext(ctx: Context) { + return new ChatStreamProtocol(ctx.res); } - startStream() { - this._statistics.reset(); - this.write({ type: 'stream_start' }); - } - - endStream() { - this.write({ type: 'stream_end' }); - } - - newMessage(content?: unknown) { - this.write({ type: 'new_message', body: content }); - } - - content(content: string): void { - this.write({ type: 'content', body: content }); - } - - webSearch(content: { type: string; query: string }[]) { - this.write({ type: 'web_search', body: content }); - } - - reasoning(content: { status: string; content: string }) { - this.write({ type: 'reasoning', body: content }); - } - - stopReasoning() { - this.write({ - type: 'reasoning', - body: { - status: 'stop', - content: '', - }, - }); - } - - toolCallChunks(content: unknown) { - this.write({ type: 'tool_call_chunks', body: content }); - } - - toolCalls(content: unknown) { - this.write({ type: 'tool_calls', body: content }); - } - - toolCallStatus({ - toolCall, - invokeStatus, - status, - interruptAction, - }: { - toolCall: { messageId: string; id: string; name: string; willInterrupt: boolean }; - invokeStatus: string; - status?: string; - interruptAction?: { - order: number; - description: string; - allowedDecisions: string[]; + with(conversation: { sessionId: string; from: string; username: string }) { + const write = ({ type, body }: { type: string; body?: any }) => { + const { sessionId, from, username } = conversation; + const data = `data: ${JSON.stringify({ sessionId, from, username, type, body })}\n\n`; + this.streamConsumer.write(data); + this._statistics.addSent(data.length); }; - }) { - this.write({ - type: 'tool_call_status', - body: { + + return { + startStream: () => { + this._statistics.reset(); + write({ type: 'stream_start' }); + }, + + endStream: () => { + write({ type: 'stream_end' }); + }, + + subAgentCompleted: () => { + write({ type: 'sub_agent_completed' }); + }, + + newMessage: (content?: unknown) => { + write({ type: 'new_message', body: content }); + }, + + content: (content: string): void => { + write({ type: 'content', body: content }); + }, + + webSearch: (content: { type: string; query: string }[]) => { + write({ type: 'web_search', body: content }); + }, + + reasoning: (content: { status: string; content: string }) => { + write({ type: 'reasoning', body: content }); + }, + + stopReasoning: () => { + write({ + type: 'reasoning', + body: { + status: 'stop', + content: '', + }, + }); + }, + + toolCallChunks: (content: unknown) => { + write({ type: 'tool_call_chunks', body: content }); + }, + + toolCalls: (content: unknown) => { + write({ type: 'tool_calls', body: content }); + }, + + toolCallStatus: ({ toolCall, invokeStatus, status, interruptAction, + }: { + toolCall: { messageId: string; id: string; name: string; willInterrupt: boolean }; + invokeStatus: string; + status?: string; + interruptAction?: { + order: number; + description: string; + allowedDecisions: string[]; + }; + }) => { + write({ + type: 'tool_call_status', + body: { + toolCall, + invokeStatus, + status, + interruptAction, + }, + }); }, - }); + }; } get statistics() { @@ -1542,12 +1703,6 @@ class ChatStreamProtocol { sent: this._statistics.sent, }; } - - private write({ type, body }: { type: string; body?: any }) { - const data = `data: ${JSON.stringify({ type, body })}\n\n`; - this.ctx.res.write(data); - this._statistics.addSent(data.length); - } } class ResponseMetadataCollector extends BaseCallbackHandler { @@ -1570,3 +1725,7 @@ class ResponseMetadataCollector extends BaseCallbackHandler { return this.responseMetadata.get(id); } } + +export type StreamConsumer = { + write: (chunk: any) => void; +}; diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/middleware/conversation.ts b/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/middleware/conversation.ts index 3ae0de777a3..fbc1d28cfb9 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/middleware/conversation.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/middleware/conversation.ts @@ -83,6 +83,7 @@ export const conversationMiddleware = ( name: 'ConversationMiddleware', contextSchema: z.object({ ctx: z.any(), + appendMessage: z.any(), }), stateSchema: z.object({ messageId: z.coerce.string().optional(), @@ -139,12 +140,27 @@ export const conversationMiddleware = ( toolMessages.map((x) => x.metadata.toolCallId as string), ); }); - runtime.writer?.({ action: 'beforeSendToolMessage', body: { messageId, messages: toolMessages } }); + const currentConversation = { + sessionId: aiEmployee.sessionId, + username: aiEmployee.employee.username, + from: aiEmployee.from, + }; + runtime.writer?.({ + action: 'beforeSendToolMessage', + body: { messageId, messages: toolMessages }, + currentConversation, + }); } }, afterModel: async (state, runtime) => { try { + const currentConversation = { + sessionId: aiEmployee.sessionId, + username: aiEmployee.employee.username, + from: aiEmployee.from, + }; const newState = { + messageId: state.messageId, lastMessageIndex: { lastHumanMessageIndex: state.messages.filter((x) => x.type === 'human').length, lastAIMessageIndex: state.messages.filter((x) => x.type === 'ai').length, @@ -152,6 +168,7 @@ export const conversationMiddleware = ( lastMessageIndex: state.messages.length, }, }; + const lastMessage = state.messages.at(-1); if (lastMessage?.type !== 'ai') { return newState; @@ -168,7 +185,7 @@ export const conversationMiddleware = ( if (values) { await aiEmployee.aiChatConversation.withTransaction(async (conversation, transaction) => { const result: AIConversationMessage = await conversation.addMessages(values); - state.messageId = result.messageId; + newState.messageId = result.messageId; if (toolCalls?.length) { const toolsMap = await aiEmployee.getToolsMap(); const initializedToolCalls = await aiEmployee.initToolCall( @@ -179,15 +196,18 @@ export const conversationMiddleware = ( fillToolCall(result, toolsMap, initializedToolCalls, toolCalls as any); } }); + runtime.writer?.({ action: 'AfterAIMessageSaved', - body: { id: aiMessage.id, messageId: state.messageId }, + body: { id: aiMessage.id, messageId: newState.messageId }, + currentConversation, }); } if (toolCalls?.length) { runtime.writer?.({ action: 'initToolCalls', body: { toolCalls }, + currentConversation, }); } @@ -196,5 +216,24 @@ export const conversationMiddleware = ( runtime.context?.ctx?.logger?.error(e); } }, + wrapModelCall: async (request, handler) => { + const runtimeContext = request.runtime.context; + const appendMessage = runtimeContext?.appendMessage; + + if (Array.isArray(appendMessage) && appendMessage.length) { + await aiEmployee.aiChatConversation.withTransaction(async (conversation) => { + await conversation.addMessages(convertToolMessage(request.messages.at(-1) as ToolMessage)); + await conversation.addMessages(appendMessage.map((x) => x as HumanMessage).map(convertHumanMessage)); + }); + + request.messages.push(...appendMessage); + + if (runtimeContext) { + delete runtimeContext.appendMessage; + } + } + + return handler(request); + }, }); }; diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/middleware/tools.ts b/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/middleware/tools.ts index b01eb7352f1..b514d70c18f 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/middleware/tools.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/middleware/tools.ts @@ -16,7 +16,19 @@ import { ToolsEntry } from '@nocobase/ai'; export const toolInteractionMiddleware = (aiEmployee: AIEmployee, tools: ToolsEntry[]) => { const interruptOn = {}; for (const tool of tools) { - interruptOn[tool.definition.name] = aiEmployee.shouldInterruptToolCall(tool); + interruptOn[tool.definition.name] = aiEmployee.shouldInterruptToolCall(tool) + ? { + allowedDecisions: ['approve', 'reject', 'edit'], + description: (toolCall) => + JSON.stringify({ + sessionId: aiEmployee.sessionId, + from: aiEmployee.from, + username: aiEmployee.employee.username, + toolCallId: toolCall.id, + toolCallName: toolCall.name, + }), + } + : false; } return humanInTheLoopMiddleware({ interruptOn, @@ -30,14 +42,25 @@ export const toolCallStatusMiddleware = (aiEmployee: AIEmployee): ReturnType { + let interrupted = false; const { runtime, toolCall } = request; const { messageId } = request.state; + const currentConversation = { + sessionId: aiEmployee.sessionId, + username: aiEmployee.employee.username, + from: aiEmployee.from, + }; + const tm = await aiEmployee.getToolCallResult(messageId, request.toolCall.id); + if (!tm) { + throw new Error(`Tool call result not found for messageId=${messageId}, toolCallId=${request.toolCall.id}`); + } if (tm.status === 'error') { runtime.writer?.({ action: 'afterToolCall', body: { toolCall, toolCallResult: tm }, + currentConversation, }); return new ToolMessage({ tool_call_id: request.toolCall.id, @@ -50,7 +73,7 @@ export const toolCallStatusMiddleware = (aiEmployee: AIEmployee): ReturnType { @@ -139,6 +148,29 @@ ${availableSkills.map((skill) => `- **${skill.name}**: ${skill.description || 'N : '' } +${ + availableAIEmployees?.length + ? ` + The following ${availableAIEmployees.length} AI employees are currently available as sub agents. + Treat this list as the authoritative routing roster for this conversation. + Do not call discovery tools just to confirm the same list again. + Only use discovery when this section is missing, clearly insufficient for the routing decision, contradictory to the current conversation, or you have strong evidence the roster has changed. + If one listed employee is already an obvious fit, dispatch directly. + Use profile lookup only when you need deeper instructions before dispatching. + + ${availableAIEmployees.map( + (it) => + `- ${it.nickname} + - username: ${it.username} + - description: ${it.bio} + - position: ${it.position} + ${it.skillSettings?.skills?.length ? '- skills:' + it.skillSettings?.skills.join(',') : ''} + ${it.skillSettings?.tools?.length ? '- tools:' + it.skillSettings?.tools.map((t) => t.name).join(',') : ''}`, + )} + ` + : '' +} + ${knowledgeBase ? `${knowledgeBase}` : ''} `; } diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/sub-agents/dispatcher.ts b/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/sub-agents/dispatcher.ts new file mode 100644 index 00000000000..c338156f104 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/sub-agents/dispatcher.ts @@ -0,0 +1,237 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Context } from '@nocobase/actions'; +import { Model } from '@nocobase/database'; +import { AIEmployee, ModelRef } from '../ai-employee'; +import type PluginAIServer from '../../plugin'; +import type { SubAgentConversationMetadata, UserDecision } from '../../types'; + +export type SubAgentTask = { + ctx: Context; + sessionId: string; + employee: Model; + model: ModelRef; + question: string; + skillSettings?: Record; + writer?: (chunk: any) => void; +}; + +export class SubAgentsDispatcher { + constructor(protected plugin: PluginAIServer) {} + + private extractTextContent(content: unknown): string { + if (typeof content === 'string') { + return content; + } + + if (Array.isArray(content)) { + return content + .map((block) => { + if (typeof block === 'string') { + return block; + } + if (block && typeof block === 'object' && 'type' in block && (block as any).type === 'text') { + return typeof (block as any).text === 'string' ? (block as any).text : ''; + } + return ''; + }) + .join(''); + } + + if (content && typeof content === 'object' && 'content' in content) { + return this.extractTextContent((content as any).content); + } + + return ''; + } + + private extractLastMessageText(result: any): string { + const messages = result?.messages; + if (!Array.isArray(messages) || messages.length === 0) { + return ''; + } + + return this.extractTextContent(messages.at(-1)?.content); + } + + private async resolveSubAgentSessionId(ctx: Context): Promise { + const sessionId = ctx.action?.params?.values?.sessionId; + if (!sessionId) { + return null; + } + + const aiToolMessage = await ctx.db.getRepository('aiToolMessages').findOne({ + filter: { + sessionId, + toolName: 'dispatch-sub-agent-task', + invokeStatus: { + $ne: 'confirmed', + }, + }, + sort: ['-id'], + }); + if (!aiToolMessage?.messageId) { + return null; + } + + const aiMessage = await ctx.db.getRepository('aiMessages').findOne({ + filter: { + sessionId, + messageId: aiToolMessage.messageId, + }, + }); + const subAgentConversations = aiMessage?.metadata?.subAgentConversations as + | SubAgentConversationMetadata[] + | undefined; + if (!Array.isArray(subAgentConversations) || !subAgentConversations.length) { + return null; + } + + return subAgentConversations.at(-1)?.sessionId ?? null; + } + + private async resolveLastMessage(ctx: Context): Promise { + const subSessionId = await this.resolveSubAgentSessionId(ctx); + if (!subSessionId) { + return null; + } + + return ctx.db.getRepository('aiMessages').findOne({ + filter: { + sessionId: subSessionId, + }, + sort: ['-messageId'], + }); + } + + async run(task: SubAgentTask): Promise { + const { ctx, sessionId, employee, model, question, skillSettings, writer } = task; + const { webSearch } = ctx.action?.params?.values ?? {}; + const plugin = ctx.app.pm.get('ai') as PluginAIServer; + const userId = ctx.auth?.user?.id; + if (!userId) { + throw new Error('User not authenticated'); + } + + if (!model?.llmService || !model?.model) { + throw new Error('LLM service not configured'); + } + + const aiEmployee = new AIEmployee({ + ctx, + employee, + sessionId, + skillSettings, + webSearch, + model, + from: 'sub-agent', + }); + + const lastMessage = await ctx.db.getRepository('aiMessages').findOne({ + filter: { + sessionId, + }, + sort: ['-messageId'], + }); + const decisions = lastMessage ? await plugin.aiConversationsManager.getUserDecisions(lastMessage.messageId) : null; + + let context; + const { messages } = ctx.action?.params?.values ?? {}; + if (messages && decisions?.decisions?.some((it) => it.type === 'reject')) { + context = { + appendMessage: await aiEmployee.getFormatMessages(messages), + }; + } + + const result = await aiEmployee.invoke({ + userDecisions: decisions, + userMessages: decisions + ? undefined + : [ + { + role: 'user', + content: { + type: 'text', + content: question, + }, + }, + ], + writer, + context, + }); + + writer?.({ + action: 'afterSubAgentInvoke', + body: {}, + currentConversation: { + sessionId, + username: employee.username, + from: 'sub-agent', + }, + }); + + return this.extractLastMessageText(result); + } + + async isInterrupted(ctx: Context) { + const sessionId = ctx.action?.params?.values?.sessionId; + if (!sessionId) { + return false; + } + + const aiToolMessage = await ctx.db.getRepository('aiToolMessages').findOne({ + filter: { + sessionId, + toolName: 'dispatch-sub-agent-task', + invokeStatus: 'pending', + }, + sort: ['-id'], + }); + + return aiToolMessage ? true : false; + } + + async reject(ctx: Context) { + const plugin = ctx.app.pm.get('ai') as PluginAIServer; + const { sessionId } = ctx.action?.params?.values ?? {}; + const conversation = await ctx.db.getRepository('aiConversations').findOne({ + filter: { + sessionId, + userId: ctx.auth?.user.id, + }, + }); + if (!conversation) { + return; + } + const lastMessage = await this.resolveLastMessage(ctx); + if (!sessionId || !lastMessage) { + return; + } + const userDecision = { + type: 'reject' as const, + message: `The user ignored the tools usage and send new messages`, + }; + const [updated] = await ctx.db.getRepository('aiToolMessages').model.update( + { userDecision, invokeStatus: 'waiting' }, + { + where: { + sessionId: lastMessage.get('sessionId'), + messageId: lastMessage.get('messageId'), + invokeStatus: 'interrupted', + }, + }, + ); + if (updated > 0) { + return await plugin.aiConversationsManager.getUserDecisions(lastMessage.get('messageId')); + } else { + return null; + } + } +} diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/sub-agents/index.ts b/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/sub-agents/index.ts new file mode 100644 index 00000000000..90020d40548 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/server/ai-employees/sub-agents/index.ts @@ -0,0 +1,10 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export { SubAgentsDispatcher } from './dispatcher'; diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/collections/ai-conversations.ts b/packages/plugins/@nocobase/plugin-ai/src/server/collections/ai-conversations.ts index b54fe1ea656..8ba475608f1 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/collections/ai-conversations.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/collections/ai-conversations.ts @@ -29,6 +29,12 @@ export default defineCollection({ name: 'topicId', type: 'string', }, + { + name: 'from', + type: 'string', + defaultValue: 'main-agent', + allowNull: false, + }, { name: 'user', type: 'belongsTo', diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/anthropic.ts b/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/anthropic.ts index 282f9a82f60..6c2165240ee 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/anthropic.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/anthropic.ts @@ -108,7 +108,7 @@ export class AnthropicProvider extends LLMProvider { } parseResponseMessage(message: Model) { - const { content: rawContent, messageId, metadata, role, toolCalls, attachments, workContext } = message; + const { content: rawContent, messageId, metadata, role, toolCalls, attachments, workContext, createdAt } = message; const content = { ...rawContent, messageId, @@ -146,6 +146,7 @@ export class AnthropicProvider extends LLMProvider { return { key: messageId, + createdAt, content, role, }; diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/google-genai.ts b/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/google-genai.ts index aa0b04b10a3..def078087bf 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/google-genai.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/google-genai.ts @@ -71,7 +71,7 @@ export class GoogleGenAIProvider extends LLMProvider { } parseResponseMessage(message: Model) { - const { content: rawContent, messageId, metadata, role, toolCalls, attachments, workContext } = message; + const { content: rawContent, messageId, metadata, role, toolCalls, attachments, workContext, createdAt } = message; const content = { ...rawContent, messageId, @@ -103,6 +103,7 @@ export class GoogleGenAIProvider extends LLMProvider { return { key: messageId, + createdAt, content, role, }; diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/openai/responses.ts b/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/openai/responses.ts index fc8366f62d0..d6412898f2e 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/openai/responses.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/openai/responses.ts @@ -58,7 +58,7 @@ export class OpenAIResponsesProvider extends LLMProvider { } parseResponseMessage(message: Model) { - const { content: rawContent, messageId, metadata, role, toolCalls, attachments, workContext } = message; + const { content: rawContent, messageId, metadata, role, toolCalls, attachments, workContext, createdAt } = message; const content = { ...rawContent, messageId, @@ -102,6 +102,7 @@ export class OpenAIResponsesProvider extends LLMProvider { return { key: messageId, + createdAt, content, role, }; diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/provider.ts b/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/provider.ts index 4ceaf192c43..286c97b70ec 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/provider.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/provider.ts @@ -13,13 +13,11 @@ import { PluginFileManagerServer } from '@nocobase/plugin-file-manager'; import { Application } from '@nocobase/server'; import axios from 'axios'; import { AIChatContext } from '../types/ai-chat-conversation.type'; -import { encodeFile, parseResponseMessage, stripToolCallTags } from '../utils'; +import { buildTool, encodeFile, parseResponseMessage, stripToolCallTags } from '../utils'; import { EmbeddingsInterface } from '@langchain/core/embeddings'; import { AIMessageChunk } from '@langchain/core/messages'; import { Context } from '@nocobase/actions'; -import { tool } from 'langchain'; import '@langchain/core/utils/stream'; -import { ToolsEntry } from '@nocobase/ai'; import { LLMResult } from '@langchain/core/outputs'; import { ContentBlock } from '@langchain/core/messages'; @@ -58,7 +56,7 @@ export abstract class LLMProvider { prepareChain(context: AIChatContext) { let chain = this.chatModel; - const toolDefinitions = context.tools?.map(ToolDefinition.from('ToolsEntry')); + const toolDefinitions = context.tools?.map(buildTool); if (this.builtInTools()?.length) { const tools = [...this.builtInTools()]; @@ -284,37 +282,3 @@ export abstract class EmbeddingProvider { return model; } } - -type FromType = 'ToolsEntry'; - -export class ToolDefinition { - constructor( - private from: FromType, - private _tool: T, - ) {} - - static from(from: FromType) { - return (tool: any) => new ToolDefinition(from, tool).tool; - } - - get tool() { - if (this.from === 'ToolsEntry') { - return this.convertToolOptions(); - } else { - throw new Error('not supported tool definitions'); - } - } - - private convertToolOptions() { - const { - invoke, - definition: { name, description, schema }, - } = this._tool as ToolsEntry; - return tool((input, { toolCall, context }) => invoke(context.ctx, input, toolCall.id), { - name, - description, - schema, - returnDirect: false, - }); - } -} diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/migrations/20260319000000-add-ai-conversations-from.ts b/packages/plugins/@nocobase/plugin-ai/src/server/migrations/20260319000000-add-ai-conversations-from.ts new file mode 100644 index 00000000000..b229219e88f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-ai/src/server/migrations/20260319000000-add-ai-conversations-from.ts @@ -0,0 +1,46 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Migration } from '@nocobase/server'; + +export default class extends Migration { + on = 'afterLoad'; + appVersion = '<2.1.0'; + + async up() { + const collection = this.db.getCollection('aiConversations'); + if (!collection) { + return; + } + + const field = collection.getField('from'); + if (!field) { + await collection.sync(); + } + + const repo = this.db.getRepository('aiConversations'); + const rows = await repo.find({}); + let updated = 0; + + for (const row of rows) { + const value = row.get?.('from') ?? row.from; + if (value) { + continue; + } + await row.update({ + from: 'main-agent', + }); + updated += 1; + } + + if (updated > 0) { + this.app.logger.info(`Migrated aiConversations.from to main-agent (${updated})`); + } + } +} diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts index 2affd0b1c37..503a340583a 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts @@ -20,6 +20,7 @@ import aiConversations from './resource/aiConversations'; import aiTools from './resource/aiTools'; import aiSkills from './resource/aiSkills'; import { AIEmployeesManager } from './ai-employees/ai-employees-manager'; +import { AIConversationsManager } from './ai-employees/ai-conversations'; import Snowflake from './snowflake'; import * as aiEmployeeActions from './resource/aiEmployees'; import { googleGenAIProviderOptions } from './llm-providers/google-genai'; @@ -46,17 +47,20 @@ import { kimiProviderOptions } from './llm-providers/kimi'; import { DocumentLoaders } from './document-loader'; import type PluginFileManagerServer from '@nocobase/plugin-file-manager'; import { CheckpointCleaner, SequelizeCollectionSaver } from './ai-employees/checkpoints'; +import { SubAgentsDispatcher } from './ai-employees/sub-agents'; // import { tongyiProviderOptions } from './llm-providers/tongyi'; export class PluginAIServer extends Plugin { features = new AIPluginFeatureManagerImpl(); aiManager = new AIManager(this); aiEmployeesManager = new AIEmployeesManager(this); + aiConversationsManager = new AIConversationsManager(this); builtInManager = new BuiltInManager(this); aiContextDatasourceManager = new AIContextDatasourceManager(this); aiCodingManager = new AICodingManager(this); workContextHandler = createWorkContextHandler(this); documentLoaders = new DocumentLoaders(this); + subAgentsDispatcher = new SubAgentsDispatcher(this); snowflake: Snowflake; /** diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiConversations.ts b/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiConversations.ts index cfe7b485474..94fe0a2f8ac 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiConversations.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/resource/aiConversations.ts @@ -48,15 +48,19 @@ export default { if (!userId) { return ctx.throw(403); } + const filter = ctx.action.params.filter || {}; ctx.action.mergeParams({ filter: { + ...filter, userId, + from: filter.from ?? 'main-agent', }, }); return actions.list(ctx, next); }, async create(ctx: Context, next: Next) { + const plugin = ctx.app.pm.get('ai') as PluginAIServer; const userId = ctx.auth?.user.id; if (!userId) { return ctx.throw(403); @@ -67,9 +71,8 @@ export default { ctx.throw(400, 'AI employee not found'); } - const repo = ctx.db.getRepository('aiConversations'); - ctx.body = await repo.create({ - values: { + try { + ctx.body = await plugin.aiConversationsManager.create({ userId, aiEmployee, options: { @@ -77,33 +80,30 @@ export default { skillSettings, conversationSettings, }, - thread: 1, - }, - }); + }); + } catch (error) { + if (error.message === 'AI employee not found') { + ctx.throw(400, error.message); + } + throw error; + } await next(); }, async update(ctx: Context, next: Next) { + const plugin = ctx.app.pm.get('ai') as PluginAIServer; const userId = ctx.auth?.user.id; if (!userId) { return ctx.throw(403); } const { filterByTk: sessionId } = ctx.action.params; const { title } = ctx.action.params.values || {}; - const repo = ctx.db.getRepository('aiConversations'); - ctx.body = await repo.update({ - filter: { - userId, - sessionId, - }, - values: { - title, - }, - }); + ctx.body = await plugin.aiConversationsManager.update({ userId, sessionId, title }); await next(); }, async updateOptions(ctx: Context, next: Next) { + const plugin = ctx.app.pm.get('ai') as PluginAIServer; const userId = ctx.auth?.user.id; if (!userId) { return ctx.throw(403); @@ -119,37 +119,18 @@ export default { return ctx.throw(400, 'invalid options'); } - const conversation = await ctx.db.getRepository('aiConversations').findOne({ - filter: { - sessionId, - userId, - }, - }); - - if (!conversation) { - ctx.throw(400, 'invalid sessionId'); - } - - const options = conversation.options ?? {}; - if (systemMessage) { - options['systemMessage'] = systemMessage; - } - if (skillSettings) { - options['skillSettings'] = skillSettings; - } - if (conversationSettings) { - options['conversationSettings'] = conversationSettings; - } - - ctx.body = await ctx.db.getRepository('aiConversations').update({ - filter: { + try { + ctx.body = await plugin.aiConversationsManager.update({ userId, sessionId, - }, - values: { - options, - }, - }); + options: { systemMessage, skillSettings, conversationSettings }, + }); + } catch (error) { + if (error.message === 'invalid sessionId') { + ctx.throw(400, error.message); + } + throw error; + } await next(); }, @@ -180,95 +161,19 @@ export default { } const paginate = ctx.action.params?.paginate === 'false' ? false : true; - - const conversation = await ctx.db.getRepository('aiConversations').findOne({ - filter: { - sessionId, + try { + ctx.body = await plugin.aiConversationsManager.getMessages({ userId, - }, - }); - - if (!conversation) { - ctx.throw(400); - } - - const pageSize = 10; - const maxLimit = 200; - const messageRepository = ctx.db.getRepository('aiConversations.messages', sessionId); - const filter = { - role: { - $notIn: ['tool'], - }, - }; - if (paginate && cursor) { - filter['messageId'] = { - $lt: cursor, - }; - } - const rows = await messageRepository.find({ - sort: ['-messageId'], - limit: paginate ? pageSize + 1 : maxLimit, - filter, - }); - - const hasMore = paginate && rows.length > pageSize; - const data = hasMore ? rows.slice(0, -1) : rows; - const newCursor = data.length ? data[data.length - 1].messageId : null; - - const toolCallIds = data - .filter((row: Model) => row?.toolCalls?.length ?? 0 > 0) - .flatMap((row: Model) => row.toolCalls) - .map((toolCall: any) => toolCall.id); - const toolMessages = await ctx.db.getRepository('aiToolMessages').find({ - filter: { sessionId, - toolCallId: { - $in: toolCallIds, - }, - }, - }); - const toolMessageKey = (messageId: string, toolCallId: string) => `${messageId}:${toolCallId}`; - const toolMessageMap = new Map( - toolMessages.map((toolMessage: Model) => [ - toolMessageKey(toolMessage.messageId, toolMessage.toolCallId), - toolMessage, - ]), - ); - - const toolsList = await plugin.ai.toolsManager.listTools(); - const toolsMap = new Map(toolsList.map((t) => [t.definition.name, t])); - - ctx.body = { - rows: data.map((row: Model) => { - if (row?.toolCalls?.length ?? 0 > 0) { - for (const toolCall of row.toolCalls) { - const tools = toolsMap.get(toolCall.name); - const toolMessage = toolMessageMap.get(toolMessageKey(row.messageId, toolCall.id)); - toolCall.invokeStatus = toolMessage?.invokeStatus; - toolCall.auto = toolMessage?.auto; - toolCall.status = toolMessage?.status; - toolCall.content = toolMessage?.content; // [AI_DEBUG] tool execution result - toolCall.execution = tools?.execution; - toolCall.willInterrupt = tools?.execution === 'frontend' || toolMessage?.auto === false; - toolCall.defaultPermission = tools?.defaultPermission; - } - } - - const providerOptions = plugin.aiManager.llmProviders.get(row.metadata?.provider); - if (!providerOptions) { - return parseResponseMessage(row); - } - const Provider = providerOptions.provider; - const provider = new Provider({ - app: ctx.app, - }); - return provider.parseResponseMessage(row); - }), - ...(paginate && { - hasMore, - cursor: newCursor, - }), - }; + cursor, + paginate, + }); + } catch (error) { + if (error.message === 'invalid sessionId') { + ctx.throw(400); + } + throw error; + } await next(); }, @@ -327,6 +232,7 @@ export default { return ctx.throw(403); } + const plugin = ctx.app.pm.get('ai') as PluginAIServer; const { sessionId, aiEmployee: employeeName, @@ -405,30 +311,42 @@ export default { const legacy = conversation.thread === 0; - const aiEmployee = new AIEmployee( + const aiEmployee = new AIEmployee({ ctx, employee, sessionId, - conversation.options?.systemMessage, - conversation.options?.skillSettings, + systemMessage: conversation.options?.systemMessage, + skillSettings: conversation.options?.skillSettings, webSearch, model, legacy, - ); + }); if (!editingMessageId) { - const toolMessages = await aiEmployee.cancelToolCall(); - if (toolMessages?.length) { - for (let i = toolMessages.length - 1; i >= 0; i--) { - const toolMessage = toolMessages[i]; - messages.unshift({ - role: toolMessage.role, - content: toolMessage.content, - toolCalls: toolMessage.toolCalls, - attachments: toolMessage.attachments, - workContext: toolMessage.workContext, - metadata: toolMessage.metadata, - }); + if (await plugin.subAgentsDispatcher.isInterrupted(ctx)) { + const userDecisions = await plugin.subAgentsDispatcher.reject(ctx); + if (userDecisions) { + if (shouldStream) { + await aiEmployee.stream({ userDecisions }); + } else { + ctx.body = await aiEmployee.invoke({ userDecisions }); + } + return; + } + } else { + const toolMessages = await aiEmployee.cancelToolCall(); + if (toolMessages?.length) { + for (let i = toolMessages.length - 1; i >= 0; i--) { + const toolMessage = toolMessages[i]; + messages.unshift({ + role: toolMessage.role, + content: toolMessage.content, + toolCalls: toolMessage.toolCalls, + attachments: toolMessage.attachments, + workContext: toolMessage.workContext, + metadata: toolMessage.metadata, + }); + } } } } @@ -446,9 +364,9 @@ export default { ctx.status = 500; ctx.body = { error: err.message || 'Tool call error' }; } + } finally { + await next(); } - - await next(); }, async abort(ctx: Context, next: Next) { @@ -544,15 +462,15 @@ export default { return next(); } - const aiEmployee = new AIEmployee( + const aiEmployee = new AIEmployee({ ctx, employee, sessionId, - conversation.options?.systemMessage, - conversation.options?.skillSettings, + systemMessage: conversation.options?.systemMessage, + skillSettings: conversation.options?.skillSettings, webSearch, model, - ); + }); await aiEmployee.stream({ messageId, userMessages: resendMessages.length ? resendMessages : undefined }); } catch (err) { ctx.log.error(err); @@ -585,7 +503,7 @@ export default { ctx.throw(400); } - const message = await ctx.db.getRepository('aiConversations.messages', sessionId).findOne({ + const message = await ctx.db.getRepository('aiMessages').findOne({ filter: { messageId, }, @@ -601,7 +519,7 @@ export default { const aiToolMessagesModel = ctx.db.getModel('aiToolMessages'); const toolCall = await aiToolMessagesModel.findOne({ - where: { sessionId, messageId, toolCallId }, + where: { sessionId: message.sessionId, messageId: message.messageId, toolCallId }, }); if (!toolCall) { ctx.throw(400); @@ -614,8 +532,8 @@ export default { }, { where: { - sessionId, - messageId, + sessionId: message.sessionId, + messageId: message.messageId, toolCallId, invokeStatus: 'interrupted', }, @@ -625,8 +543,8 @@ export default { const toolCallIds = toolCalls.map((x) => x.id); const toolMessages = await ctx.db.getRepository('aiToolMessages').find({ filter: { - sessionId, - messageId, + sessionId: message.sessionId, + messageId: message.messageId, toolCallId: { $in: toolCallIds, }, @@ -666,6 +584,7 @@ export default { } setupSSEHeaders(ctx); + const plugin = ctx.app.pm.get('ai') as PluginAIServer; const { sessionId, messageId, model, webSearch } = ctx.action.params.values || {}; if (!sessionId) { sendErrorResponse(ctx, 'sessionId is required'); @@ -691,7 +610,7 @@ export default { let message: Model; if (messageId) { - message = await ctx.db.getRepository('aiConversations.messages', sessionId).findOne({ + message = await ctx.db.getRepository('aiMessages').findOne({ filter: { messageId, }, @@ -713,17 +632,17 @@ export default { return next(); } - const aiEmployee = new AIEmployee( + const aiEmployee = new AIEmployee({ ctx, employee, sessionId, - conversation.options?.systemMessage, - conversation.options?.skillSettings, + systemMessage: conversation.options?.systemMessage, + skillSettings: conversation.options?.skillSettings, webSearch, model, - ); + }); - const userDecisions = await aiEmployee.getUserDecisions(messageId); + const userDecisions = await plugin.aiConversationsManager.getUserDecisions(messageId); await aiEmployee.stream({ userDecisions, }); diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/types/ai-chat-conversation.type.ts b/packages/plugins/@nocobase/plugin-ai/src/server/types/ai-chat-conversation.type.ts index c14e7f5e9fa..dd364e43f98 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/types/ai-chat-conversation.type.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/types/ai-chat-conversation.type.ts @@ -36,7 +36,10 @@ export type AIChatContext = { tool_call_id?: string; tool_calls?: AIToolCall[]; }[]; - decisions?: UserDecision[]; + decisions?: { + interruptId?: string; + decisions: UserDecision[]; + }; tools?: any[]; middleware?: any[]; structuredOutput?: { @@ -59,7 +62,10 @@ export type AIMessageRemoveOptions = { export type AIChatContextOptions = { userMessages?: AIMessageInput[]; - userDecisions?: UserDecision[]; + userDecisions?: { + interruptId?: string; + decisions: UserDecision[]; + }; tools?: any[]; middleware?: any[]; getSystemPrompt?: (userMessages: AIMessageInput[]) => Promise; diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/types/ai-message.type.ts b/packages/plugins/@nocobase/plugin-ai/src/server/types/ai-message.type.ts index 4d636261fe2..072d6d6fcd3 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/types/ai-message.type.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/types/ai-message.type.ts @@ -11,6 +11,7 @@ export type AIMessage = { messageId: string; sessionId: string; role: string; + createdAt?: string | Date; content: AIMessageContent; toolCalls?: AIToolCall[]; attachments?: unknown[]; @@ -49,10 +50,17 @@ export type AIMessageMetadata = { autoCallTools?: string[]; autoCall?: boolean; interrupted?: boolean; + subAgentConversations?: SubAgentConversationMetadata[]; [key: string]: unknown; }; +export type SubAgentConversationMetadata = { + sessionId: string; + toolCallId: string; + status: 'pending' | 'completed'; +}; + export type AIToolMessage = { id: string; sessionId: string; diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/utils.ts b/packages/plugins/@nocobase/plugin-ai/src/server/utils.ts index b52fd536f40..3ccbad0c9e4 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/utils.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/utils.ts @@ -11,8 +11,10 @@ import { Model } from '@nocobase/database'; import path from 'path'; import fs from 'fs'; import axios from 'axios'; -import { getDateVars, parse, parseFilter } from '@nocobase/utils'; +import { getDateVars, parse } from '@nocobase/utils'; import { Context } from '@nocobase/actions'; +import { ToolsEntry } from '@nocobase/ai'; +import { tool } from 'langchain'; export function sendSSEError(ctx: Context, error: Error | string, errorName?: string) { const body = typeof error === 'string' ? error : error.message || 'Unknown error'; @@ -36,7 +38,7 @@ export function stripToolCallTags(content: string): string | null { } export function parseResponseMessage(row: Model) { - const { content: rawContent, messageId, metadata, role, toolCalls, attachments, workContext } = row; + const { content: rawContent, messageId, metadata, role, toolCalls, attachments, workContext, createdAt } = row; const content = { ...(rawContent ?? {}), content: stripToolCallTags(rawContent?.content), @@ -50,6 +52,7 @@ export function parseResponseMessage(row: Model) { } return { key: messageId, + createdAt, content, role, }; @@ -121,3 +124,24 @@ export async function parseVariables(ctx: Context, value: string) { $nDate, }); } + +const noWriter = (chunk: any) => console.warn(`No writer in tools runtime, chunk:[${chunk}]`); +export const buildTool = (toolsEntry: ToolsEntry) => { + const { + invoke, + definition: { name, description, schema }, + } = toolsEntry; + return tool( + (input, config) => { + const { context, toolCall } = config; + const writer = (config['writer'] as (chunk: any) => void) ?? noWriter; + return invoke(context.ctx, input, { toolCallId: toolCall.id, writer }); + }, + { + name, + description, + schema, + returnDirect: false, + }, + ); +}; diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/index.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/index.ts index fa310c13f96..bb793ce8ac1 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/index.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/index.ts @@ -18,115 +18,4 @@ export default defineAIEmployee({ bio: 'An frontend engineer specializing in JavaScript, HTML, and CSS.', greeting: 'Hello, I\u2019m Nathan, your frontend code engineer. I\u2019ll generate high-quality JavaScript / HTML / CSS code for you. What would you like me to build today?', - systemPrompt: `You are an AI coding assistant for NocoBase RunJS. - -RunJS is used in: - -- JS Block -- JS Field -- JS Item -- JS Action -- Event Flow -- Linkage Rules - -Runtime: - -- Sandboxed -- Access via \`ctx\` -- Supports top-level await (PREFER whenever possible) -- JSX → ctx.libs.React.createElement -- Dynamic ESM via ctx.importAsync() - - -# Core Rule (Strict) - -Never guess. - -You must NOT assume: - -- ctx APIs -- context variables -- collections / fields / schema -- runtime behavior -- React / Antd exposure -- browser globals (window, document, location, history, navigator) - -All of the above MUST be verified via tools or NocoBase docs. - -If not confirmed → ask user. - - -# Mandatory Workflow (Every Task) - -Follow this exact order: - -1. Runtime - - getContextEnvs - - getContextVars - - getContextApis - -2. Docs - - searchDocs - - readDocEntry - -3. Data (if involved) - - getDatasources - - getCollectionNames - - getCollectionMetadata - - searchFieldMetadata - -4. If unclear - - suggestions / ask user - -5. Write code - -6. Validate (REQUIRED) - - lintAndTestJS must pass before output - - -# Coding Rules - -- Single file -- Prefer top-level await -- No import / require -- Libraries ONLY via ctx.importAsync() -- HTTP ONLY via ctx.request() -- Only call ctx.render when UI is required -- JSX uses ctx.libs.React by default -- When rendering UI, PREFER Ant Design components via ctx.libs.antd to match NocoBase style -- Inline styles only - -Forbidden: - -- fetch -- XMLHttpRequest -- localStorage -- eval -- new Function -- Direct document / window access unless explicitly documented - -# i18n - -All user-facing strings MUST use: \`ctx.t(...)\` - -# Security - -Never inject unsanitized user input into DOM. - -# Output Rules - -- Markdown -- Exactly ONE complete code block at end -- No partial snippets -- Brief explanation after code - -# Standard - -Senior NocoBase engineer mindset: -Tool-driven, deterministic, production-minded. - -If unsure: search. -If still unsure: ask. -Never guess. -`, }); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/prompt.md b/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/prompt.md new file mode 100644 index 00000000000..0a1a3d2d7be --- /dev/null +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/prompt.md @@ -0,0 +1,132 @@ +You are an AI coding assistant for NocoBase RunJS. + +RunJS is used in: + +- JS Block +- JS Field +- JS Item +- JS Action +- Event Flow +- Linkage Rules + +Runtime: + +- Sandboxed environment +- Access via \`ctx\` +- Supports top-level await (PREFER whenever possible) +- JSX → ctx.libs.React.createElement +- Dynamic ESM via ctx.importAsync() + + +# Core Rule (Strict) + +Never guess. + +You must NOT assume: + +- ctx APIs +- context variables +- collections / fields / schema +- runtime behavior +- React / Antd exposure +- browser globals (window, document, location, history, navigator) + +All of the above MUST be verified via tools or NocoBase docs. + +If not confirmed → ask user. + +# Mandatory Workflow (Every Task) + +Follow this exact order. Do NOT skip ahead to coding. + +1. Runtime inspection first + - Use `frontend-developer` skill guidance. + - Call: + - `getContextEnvs` + - `getContextVars` + - `getContextApis` + - Goal: confirm what the current runtime exposes. + - This step does NOT replace documentation lookup. + +2. Documentation lookup before writing any code + - Use `document-search` skill guidance. + - You MUST call: + - `searchDocs` + - `readDocEntry` + - Always search docs before coding when the task involves any of the following: + - RunJS / workflow / JS Block / JS Field / JS Item / JS Action / Event Flow / Linkage Rules + - `ctx` APIs, runtime constraints, rendering, routing, requests, imports, React, Antd + - any NocoBase-specific feature, component, schema, collection behavior, or API usage + - Do not rely on memory, prior experience, or "common NocoBase patterns" as a substitute for this step. + - Minimum requirement: + - search for the relevant module / keywords + - read the most relevant matching entry or entries + - extract the concrete constraints or APIs you will rely on + - Only after this step may you decide how to implement the solution. + +3. Data inspection when data model is involved + - Use `data-metadata` skill guidance. + - If the task touches collections, fields, relations, filtering, querying, or record structure, call: + - `getDatasources` + - `getCollectionNames` + - `getCollectionMetadata` + - `searchFieldMetadata` + - Do not invent collection names, field names, relation paths, or schema details. + +4. Resolve uncertainty before coding + - If runtime inspection, docs, or metadata still leave a gap, stop and use `suggestions` or ask the user. + - If a required fact is unverified, you must not start writing code yet. + +5. Write code only after steps 1-4 are complete + - `frontend-developer` is the implementation skill, not the starting shortcut. + - Never use `frontend-developer` alone as justification to begin coding. + - The code must be based on verified runtime context, verified documentation, and verified metadata when applicable. + +6. Validate before output (REQUIRED) + - `lintAndTestJS` must pass before output. + - If validation fails, fix the code and validate again. + +# Coding Rules + +- Single file +- Prefer top-level await +- No import / require +- Libraries ONLY via ctx.importAsync() +- HTTP ONLY via ctx.request() +- Only call ctx.render when UI is required +- JSX uses ctx.libs.React by default +- When rendering UI, PREFER Ant Design components via ctx.libs.antd to match NocoBase style +- Inline styles only + +Forbidden: + +- fetch +- XMLHttpRequest +- localStorage +- eval +- new Function +- Direct document / window access unless explicitly documented + +# i18n + +All user-facing strings MUST use: \`ctx.t(...)\` + +# Security + +Never inject unsanitized user input into DOM. + +# Output Rules + +- Markdown +- Exactly ONE complete code block at end +- No partial snippets +- Brief explanation after code + +# Standard + +Senior NocoBase engineer mindset: +Tool-driven, deterministic, production-minded. + +If unsure: search. +If still unsure: ask. +Never guess. diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/getContextApis.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/getContextApis.ts index 0e30e7201e3..fab440a4e4a 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/getContextApis.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/getContextApis.ts @@ -23,9 +23,9 @@ export default defineTools({ description: 'Get available API methods from context', schema: z.object({}), }, - invoke: async (ctx, _args, id) => { + invoke: async (ctx, _args, runtime) => { const { toolCallResults } = ctx.action.params.values || {}; - const { result } = toolCallResults?.find((item) => item.id === id) ?? {}; + const { result } = toolCallResults?.find((item) => item.id === runtime.toolCallId) ?? {}; if (toolCallResults && result) { return { status: 'success', diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/getContextEnvs.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/getContextEnvs.ts index e159b0a36ae..4951d90a1b0 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/getContextEnvs.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/getContextEnvs.ts @@ -23,9 +23,9 @@ export default defineTools({ description: 'Get current page/block/flow model metadata from context', schema: z.object({}), }, - invoke: async (ctx, _args, id) => { + invoke: async (ctx, _args, runtime) => { const { toolCallResults } = ctx.action.params.values || {}; - const { result } = toolCallResults?.find((item) => item.id === id) ?? {}; + const { result } = toolCallResults?.find((item) => item.id === runtime.toolCallId) ?? {}; if (toolCallResults && result) { return { status: 'success', diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/getContextVars.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/getContextVars.ts index 75e87f85a35..94b66ca102f 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/getContextVars.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/getContextVars.ts @@ -45,9 +45,9 @@ instead of fetching the entire object, e.g. ), }), }, - invoke: async (ctx, _args, id) => { + invoke: async (ctx, _args, runtime) => { const { toolCallResults } = ctx.action.params.values || {}; - const { result } = toolCallResults?.find((item) => item.id === id) ?? {}; + const { result } = toolCallResults?.find((item) => item.id === runtime.toolCallId) ?? {}; if (toolCallResults && result) { return { status: 'success', diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/lintAndTestJS.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/lintAndTestJS.ts index 656814a5b6e..09a3d5f1e26 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/lintAndTestJS.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/ai/ai-employees/nathan/skills/frontend-developer/tools/lintAndTestJS.ts @@ -27,9 +27,9 @@ export default defineTools({ }), }, - invoke: async (ctx, _args, id) => { + invoke: async (ctx, _args, runtime) => { const { toolCallResults } = ctx.action.params.values || {}; - const { result } = toolCallResults?.find((item: { id: string }) => item.id === id) ?? {}; + const { result } = toolCallResults?.find((item: { id: string }) => item.id === runtime.toolCallId) ?? {}; if (toolCallResults && result) { return { status: result.status ?? 'error', diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/lintAndTestJS-tool.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/lintAndTestJS-tool.test.ts index 416c4e22641..e1e637699cb 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/lintAndTestJS-tool.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/lintAndTestJS-tool.test.ts @@ -26,7 +26,7 @@ describe('lintAndTestJS Tool', () => { }, } as any; - const result = await lintAndTestJS.invoke(mockCtx, {}, 'test-id'); + const result = await lintAndTestJS.invoke(mockCtx, {}, { toolCallId: 'test-id' }); expect(result.status).toBe('error'); const content = JSON.parse(result.content); expect(content.success).toBe(false); @@ -44,7 +44,7 @@ describe('lintAndTestJS Tool', () => { }, } as any; - const result = await lintAndTestJS.invoke(mockCtx, {}, 'test-id'); + const result = await lintAndTestJS.invoke(mockCtx, {}, { toolCallId: 'test-id' }); expect(result.status).toBe('error'); const content = JSON.parse(result.content); expect(content.success).toBe(false); @@ -72,7 +72,7 @@ describe('lintAndTestJS Tool', () => { }, } as any; - const result = await lintAndTestJS.invoke(mockCtx, {}, 'test-id'); + const result = await lintAndTestJS.invoke(mockCtx, {}, { toolCallId: 'test-id' }); expect(result.status).toBe('success'); const content = JSON.parse(result.content); expect(content.success).toBe(true); @@ -100,7 +100,7 @@ describe('lintAndTestJS Tool', () => { }, } as any; - const result = await lintAndTestJS.invoke(mockCtx, {}, 'test-id'); + const result = await lintAndTestJS.invoke(mockCtx, {}, { toolCallId: 'test-id' }); expect(result.status).toBe('error'); const content = JSON.parse(result.content); expect(content.success).toBe(false);