mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-28 17:43:07 +08:00
feat(plugin-ai): add AI chat box block (#10125)
* feat(plugin-ai): add AI chat box demo block * chore(plugin-ai): document chatbox runtime model design * feat(plugin-ai): add chatbox runtime models * feat(plugin-ai): add chatbox runtime context * feat(plugin-ai): migrate chatbox consumers to runtime * test(plugin-ai): cover global chatbox runtime behavior * refactor(plugin-ai): remove obsolete chatbox store facades * test(plugin-ai): complete chatbox runtime regression pass * fix(plugin-ai): make chatbox session state reactive * fix(plugin-ai): expose global chatbox runtime for dependents * fix(plugin-ai): support explicit chatbox runtime injection * docs(plugin-ai): plan conversation runtime migration * docs(plugin-ai): design conversation runtime ownership * feat(plugin-ai): add conversation workflow runtime models * refactor(plugin-ai): migrate chatbox consumers to runtime models * refactor(plugin-ai): remove conversation selector stores * refactor(plugin-ai): align runtime model declarations * fix(plugin-ai): remove stale conversation store export * chore(plugin-ai): preserve demo chatbox runtime * feat(plugin-ai): add chatbox runtime mode and sender state * feat(plugin-ai): add production ai chat box model skeleton * feat(plugin-ai): add ai chat box block layout shell * feat(plugin-ai): add ai chat box settings * feat(plugin-ai): connect ai chat box sender runtime * feat(plugin-ai): add ai chat box conversations * feat(plugin-ai): support chat box conversation scope * test(plugin-ai): cover chat box action runtime integration * feat(plugin-ai): route employee tasks to chat box blocks * docs(plugin-ai): record ai chat box implementation validation * fix(plugin-ai): adjust ai chat box height and layout * fix(plugin-ai): prevent ai chat sender from overlapping messages * fix(plugin-ai): keep chat messages above sender * fix(plugin-ai): constrain demo chat sender layout * fix(plugin-ai): keep block chat sender in flex flow * fix(plugin-ai): expand chat box after adding body blocks * Revert "fix(plugin-ai): expand chat box after adding body blocks" This reverts commit b6a893a6477dcd7a6fd86a55df89e60ff9b11519. * fix(plugin-ai): use common block height settings * fix(plugin-ai): align chat messages scrollbar style * fix(plugin-ai): preserve native chat message scrollbar * fix(plugin-ai): preserve native chat message layout * fix(plugin-ai): restrict chat box display blocks * fix(plugin-ai): set chat box core minimum width * fix(plugin-ai): avoid nested chat message horizontal scroll * fix(plugin-ai): polish AI chat box block * fix(plugin-ai): prevent chat sender overflow * fix(plugin-ai): keep chat core below body blocks * fix(plugin-ai): sync AI chat box block state * fix(plugin-ai): register AI employee action in v1 entry * fix(plugin-ai): preserve chat box scope for employee tasks * fix(plugin-ai): initialize chat box employee shortcuts * fix(plugin-ai): filter chat box conversations by scope * fix(plugin-ai): use block runtime for chat box employee actions * fix(plugin-ai): clean up AI chat box block * fix(plugin-ai): resolve chat box declaration errors * refactor(plugin-ai): centralize chat box default width * fix(plugin-ai): initialize chat box scope * refactor(plugin-ai): organize chat box view state * refactor(plugin-ai): organize chat box sender state * refactor(plugin-ai): simplify AI chat box core view * fix(plugin-ai): preserve chat box model restrictions * refactor(plugin-ai): align AI chat box items * refactor(plugin-ai): align AI chat box sub models * refactor(plugin-ai): resolve chat box scope at runtime * fix(plugin-ai): avoid block chat conversation refresh fanout * refactor(plugin-ai): isolate chat box unread badge rendering * perf(plugin-ai): avoid sender input mobx updates * fix(plugin-ai): adapt chatbox tests to runtime models * feat(plugin-ai): support targeting AI chat box in triggerTask * docs: sync ctx.ai chatBoxUid translations * feat(plugin-ai): support file attachments in RunJS * docs: sync AI file upload translations * feat(plugin-ai): expose response loading state in RunJS * docs: add AI Chat box block guide * docs: link AI Chat box from interface builder * docs: sync AI Chat box guide translations * fix(plugin-ai): skip block shortcut flow context * fix(plugin-ai): keep block picker active in chat box * fix(test): stabilize frontend CI tests * fix(plugin-ai): validate AI chat box scope * fix(plugin-ai): ignore whitespace-only messages * fix(plugin-ai): restrict AI file access to owners * fix(plugin-ai): allow owners to preview AI files * fix(plugin-ai): authorize AI file previews by owner
This commit is contained in:
@@ -80,6 +80,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "页面区块",
|
||||
"items": [
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "内置 AI 员工",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
pkg: '@nocobase/plugin-ai'
|
||||
title: 'AI Chat box 区块'
|
||||
description: '面向 NocoBase 管理员和页面搭建者的 AI Chat box 区块操作手册,介绍添加区块、设置对话能力、配置 Work context、管理会话和添加 Actions。'
|
||||
keywords: 'AI Chat box,AI 员工,页面区块,Work context,Scope,Actions,NocoBase'
|
||||
---
|
||||
|
||||
# AI Chat box 区块
|
||||
|
||||
在 NocoBase 中,**AI Chat box** 是可以直接添加到页面中的 AI 对话区块。你可以把它放在业务页面中,为当前页面提供固定入口的 AI 助手。
|
||||
|
||||
每个 AI Chat box 区块都有独立的当前会话和输入状态。页面搭建者还可以限制可选的 AI 员工、模型、文件上传、联网搜索和工作上下文,让区块更贴合当前业务场景。
|
||||
|
||||
:::tip 使用前准备
|
||||
|
||||
请先完成 [LLM 服务配置](../features/llm-service.md) 并[启用至少一个 AI 员工](../features/enable-ai-employee.md)。
|
||||
|
||||
:::
|
||||
|
||||
## 添加 AI Chat box 区块
|
||||
|
||||
1. 打开需要配置的页面。
|
||||
2. 点击右上角的「UI Editor」,进入页面编辑模式。
|
||||
3. 点击「Add block」。
|
||||
4. 在「Other blocks」中选择「AI chat box」。
|
||||
|
||||

|
||||
|
||||
## 认识区块结构
|
||||
|
||||

|
||||
|
||||
AI Chat box 从上到下分为三个区域:
|
||||
|
||||
- **顶部操作区**——会话列表入口、Actions、自定义动作、新会话按钮;隐藏消息区后还会显示消息按钮
|
||||
- **消息区**——显示当前草稿或会话中的消息
|
||||
- **发送区**——输入框、上下文选择、文件上传、联网搜索、AI 员工选择、模型选择、发送按钮和免责声明
|
||||
|
||||
### 在区块 body 中添加展示内容
|
||||
|
||||
进入页面编辑模式后,点击 AI Chat box 内部的「Add block」,可以在聊天区域上方添加这些区块:
|
||||
|
||||
- JS block
|
||||
- Iframe
|
||||
- Markdown
|
||||
|
||||
这些区块适合展示说明、外部页面或辅助信息。AI Chat box 的内部添加菜单只开放以上三类区块,不能继续嵌套 AI Chat box。
|
||||
|
||||
## 配置 AI Chat box
|
||||
|
||||
将鼠标移到区块上方,打开区块设置菜单。点击「Edit chat box」,可以设置会话范围、默认消息、Work context、AI 员工和模型。
|
||||
|
||||

|
||||
|
||||
### Edit chat box 设置
|
||||
|
||||
| 设置项 | 作用 |
|
||||
| --- | --- |
|
||||
| 「Scope」 | 控制哪些 AI Chat box 共享会话列表。新建区块默认使用自己的区块 UID,用于隔离会话。 |
|
||||
| 「Background」 | 附加到 AI 员工定义后的系统提示词,用于补充当前页面的角色、目标或回答要求。 |
|
||||
| 「Default user message」 | 新会话开始时预填到发送框中的默认用户消息。 |
|
||||
| 「Work context」 | 选择默认放入新草稿的页面区块。 |
|
||||
| 「AI employees」 | 限制可在该区块中选择的业务类 AI 员工。留空时允许全部可用的业务类 AI 员工。 |
|
||||
| 「Models」 | 限制可在该区块中选择的模型。留空时允许全部可用模型。 |
|
||||
|
||||
### 其他区块设置
|
||||
|
||||
| 设置项 | 作用 |
|
||||
| --- | --- |
|
||||
| 「Show messages」 | 控制消息区是否直接显示在区块中。关闭后通过顶部的消息按钮打开右侧面板。 |
|
||||
| 「Sender placeholder」 | 修改发送框的占位提示。 |
|
||||
| 「Enable add context」 | 显示或隐藏发送框中的上下文选择入口。 |
|
||||
| 「Enable upload files」 | 显示或隐藏文件上传入口。关闭后,粘贴文件也不会触发上传。 |
|
||||
| 「Enable web search」 | 显示或隐藏联网搜索开关。关闭后,当前草稿中的联网搜索状态也会关闭。 |
|
||||
| 「Enable employee select」 | 显示或隐藏 AI 员工选择器。 |
|
||||
| 「Enable model select」 | 显示或隐藏模型选择器。 |
|
||||
| 「Show disclaimer」 | 显示或隐藏发送框下方的 AI 免责声明。 |
|
||||
|
||||
## 配置 Work context
|
||||
|
||||
在「Edit chat box」的「Work context」中点击添加上下文按钮,选择「Pick block」,再点击需要提供给 AI 的页面区块。保存后,选中的区块会作为新会话的默认工作上下文;发送前也可以在发送区中移除。
|
||||
|
||||
## 隐藏消息区并使用右侧面板
|
||||
|
||||
关闭「Show messages」后,区块主体只保留发送区。顶部会出现消息按钮,点击后从右侧打开消息面板。
|
||||
|
||||

|
||||
|
||||
面板打开时,区块其余区域会显示遮罩;点击遮罩或再次点击消息按钮即可关闭面板。
|
||||
|
||||
这种布局适合把 AI Chat box 作为页面中的轻量输入入口——平时只显示发送框,需要回看消息时再打开面板。
|
||||
|
||||
## 管理历史会话
|
||||
|
||||
点击区块左上角的会话列表按钮,可以查看当前 Scope 下的历史会话。
|
||||
|
||||
需要注意这些规则:
|
||||
|
||||
- 使用相同 Scope 的多个 AI Chat box 可以看到相同的会话列表
|
||||
- 每个区块仍有独立的当前会话、发送框草稿、AI 员工、模型、附件和上下文状态
|
||||
- 全局浮动 chatbox 不按区块 Scope 过滤,因此不会隐藏带 Scope 的区块会话
|
||||
- 清空 Scope 后,区块会话列表不再按 Scope 过滤,会显示未设置 Scope 和设置了其他 Scope 的会话
|
||||
|
||||
通常来说,保留新建区块自动生成的 Scope 就可以让每个页面助手的历史会话彼此隔离。只有当你需要多个区块共享同一批历史会话时,才需要给它们配置相同的 Scope。
|
||||
|
||||
## 添加 Actions
|
||||
|
||||
在页面编辑模式下,点击区块顶部的「Actions」,可以添加这些操作:
|
||||
|
||||
- JS Action
|
||||
- AI employee
|
||||
|
||||
添加 AI employee 后,可以继续为员工配置快捷任务。
|
||||
|
||||
快捷任务中的「Chat box uid」可以指定任务要在哪个 AI Chat box 中运行。在 AI Chat box 内直接添加的 AI employee,任务会默认指向当前区块的 UID。
|
||||
|
||||
如果任务指定的 AI Chat box 当前没有挂载,NocoBase 会提示找不到目标区块,不会回退到全局浮动 chatbox。详细配置见 [AI 员工快捷任务](../features/task.md)。
|
||||
|
||||
## 一个页面专用助手的配置流程
|
||||
|
||||
下面的配置可以做出一个页面专用的轻量 AI 助手:
|
||||
|
||||
1. 添加 AI Chat box 区块,并调整到合适的页面位置。
|
||||
2. 在「Edit chat box」中填写页面专用的 Background。
|
||||
3. 选择一个或多个 Work context。
|
||||
4. 在「AI employees」和「Models」中限制可用员工与模型。
|
||||
5. 退出编辑模式,输入问题并发送。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- AI Chat box 区块和右下角的全局浮动 chatbox 是两个独立入口,当前会话和输入状态不会自动同步
|
||||
- 在 AI Chat box 内部的「Add block」中,只能添加 JS block、Iframe 和 Markdown
|
||||
- 修改 Scope 会影响会话列表的查询范围,不会把另一个区块当前打开的会话和草稿状态复制过来
|
||||
@@ -123,6 +123,11 @@
|
||||
"label": "操作面板",
|
||||
"link": "/interface-builder/blocks/other-blocks/action-panel"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Iframe",
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
---
|
||||
title: "ctx.ai"
|
||||
description: "ctx.ai 在 RunJS 中触发 AI 员工任务,支持直接传入任务内容,也支持复用页面上 AI 员工操作中已配置的任务。"
|
||||
keywords: "ctx.ai,AI employee,triggerTask,triggerModelTask,RunJS,NocoBase"
|
||||
description: "ctx.ai 在 RunJS 中触发 AI 员工任务,支持直接传入任务内容、指定 AI Chat Box,也支持复用页面上 AI 员工操作中已配置的任务。"
|
||||
keywords: "ctx.ai,AI employee,uploadFile,attachments,triggerTask,triggerModelTask,onResponseLoadingChange,chatBoxUid,AI Chat Box,RunJS,NocoBase"
|
||||
---
|
||||
|
||||
# ctx.ai
|
||||
|
||||
在 RunJS 中触发 **AI 员工任务**。它适合放在 JSBlock、JSAction 等交互里,让页面上的按钮、表单或业务流程把任务交给指定的 AI 员工。
|
||||
|
||||
`ctx.ai` 只提供任务触发能力,不会返回 AI 员工任务的执行结果。调用后任务会进入 AI 员工对话流程,后续结果由 AI 员工会话处理。
|
||||
`ctx.ai` 提供 AI 任务附件上传和任务触发能力。附件上传可以等待结果,但任务触发不会返回 AI 员工任务的执行结果。触发后任务会进入 AI 员工对话流程,后续结果由 AI 员工会话处理。
|
||||
|
||||
:::warning 注意
|
||||
|
||||
`ctx.ai` 由 AI 插件提供。如果当前应用没有启用 AI 插件,或者当前 RunJS 环境没有加载对应客户端能力,那么 `ctx.ai` 可能不存在。调用前可以先判断 `ctx.ai?.triggerTask` 或 `ctx.ai?.triggerModelTask`。
|
||||
`ctx.ai` 由 AI 插件提供。如果当前应用没有启用 AI 插件,或者当前 RunJS 环境没有加载对应客户端能力,那么 `ctx.ai` 可能不存在。调用前可以先判断 `ctx.ai?.uploadFile`、`ctx.ai?.triggerTask` 或 `ctx.ai?.triggerModelTask`。
|
||||
|
||||
:::
|
||||
|
||||
## 方法
|
||||
|
||||
### ctx.ai.uploadFile()
|
||||
|
||||
上传一个文件,并返回可以直接传给 AI 员工任务的附件对象。
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, options);
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `file` | `File` | 要上传的浏览器文件对象 |
|
||||
| `options.onProgress` | `(percent: number) => void` | 上传进度回调,`percent` 范围为 `0` 到 `100` |
|
||||
| `options.signal` | `AbortSignal` | 用于取消上传的信号 |
|
||||
|
||||
上传使用 AI 插件配置的文件存储,并写入 `aiFiles`。返回值包含文件记录的 `id`、`filename`、`url`、`source` 等信息:
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
console.log('upload progress', percent);
|
||||
},
|
||||
});
|
||||
|
||||
// attachment 可以直接放入 message.attachments
|
||||
```
|
||||
|
||||
上传失败时 Promise 会被拒绝,可以通过 `try/catch` 处理。仅从界面文件列表中移除附件不会删除已经写入 `aiFiles` 的记录,这与默认 AI 聊天窗口的行为一致。
|
||||
|
||||
### ctx.ai.triggerTask()
|
||||
|
||||
直接触发一个 AI 员工任务。
|
||||
@@ -32,8 +60,10 @@ ctx.ai.triggerTask(options: TriggerTaskOptions): void
|
||||
|------|------|------|
|
||||
| `aiEmployee` | `string \| AIEmployee` | AI 员工。传字符串时按 `AIEmployee.username` 精确匹配,且必须是当前用户可访问的 AI 员工 |
|
||||
| `tasks` | `Task[]` | 要触发的任务列表 |
|
||||
| `chatBoxUid` | `string` | 指定承载任务的 AI Chat Box 区块 FlowModel uid |
|
||||
| `open` | `boolean` | 是否打开 AI 员工对话面板 |
|
||||
| `auto` | `boolean` | 是否按 AI 员工操作的自动触发语义处理 |
|
||||
| `onResponseLoadingChange` | `(loading: boolean) => void` | 模型响应加载状态变化回调,仅在本次任务自动发送时触发 |
|
||||
|
||||
`Task` 常用字段如下:
|
||||
|
||||
@@ -42,12 +72,168 @@ ctx.ai.triggerTask(options: TriggerTaskOptions): void
|
||||
| `title` | `string` | 任务标题 |
|
||||
| `message.system` | `string` | 系统消息,用于约束 AI 员工的角色和输出要求 |
|
||||
| `message.user` | `string` | 用户消息,也就是本次任务的主要指令 |
|
||||
| `message.attachments` | `Attachment[]` | 任务使用的附件,通常来自 `ctx.ai.uploadFile()` |
|
||||
| `message.workContext` | `ContextItem[]` | 任务使用的页面区块上下文 |
|
||||
| `autoSend` | `boolean` | 是否自动发送任务消息 |
|
||||
| `webSearch` | `boolean` | 是否允许本次任务使用 Web search |
|
||||
| `model` | `{ llmService: string; model: string } \| null` | 指定本次任务使用的模型 |
|
||||
| `skillSettings` | `SkillSettings` | 指定本次任务使用的 skills / tools 配置 |
|
||||
|
||||
### 监听响应加载状态
|
||||
|
||||
在顶层参数中传入 `onResponseLoadingChange`,可以监听本次任务的模型响应加载状态。开始等待模型响应时回调参数为 `true`;响应完成、取消或失败时为 `false`。如果 React 组件已经通过 `useState` 声明了 `setResponseLoading`,可以这样写:
|
||||
|
||||
```tsx
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
onResponseLoadingChange(loading) {
|
||||
setResponseLoading(loading);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`onResponseLoadingChange` 只跟踪这次 `triggerTask()` 直接启动的模型响应。如果任务设置为 `autoSend: false`,任务内容只会进入聊天窗口草稿,回调不会触发;用户之后在聊天窗口手动发送时,也不会继续使用这次调用传入的回调。
|
||||
|
||||
在 JS 区块的 React 组件中,只要组件仍处于挂载状态,这类状态更新就会触发重新渲染。
|
||||
|
||||
### 指定 AI Chat Box
|
||||
|
||||
在 `triggerTask()` 的顶层参数中设置 `chatBoxUid`,可以让任务在页面上指定的 AI Chat Box 区块中触发,而不是打开全局 AI 员工对话框。
|
||||
|
||||
```ts
|
||||
const chatBoxUid = 'AI_CHAT_BOX_BLOCK_UID';
|
||||
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
chatBoxUid,
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
使用 `chatBoxUid` 时需要注意:
|
||||
|
||||
- `chatBoxUid` 是 `triggerTask()` 的顶层参数,不要放在 `tasks` 数组的具体任务上。
|
||||
- 传入的 uid 必须是 AI Chat Box 外层区块的 FlowModel uid,不是区块内部子模型或 AI 员工操作的 uid。
|
||||
- 目标 AI Chat Box 必须已在当前页面上挂载。如果找不到对应区块,NocoBase 会显示错误,且不会回退到全局对话框。
|
||||
|
||||
如果不设置 `chatBoxUid`,任务会按默认行为在全局 AI 员工对话框中触发。
|
||||
|
||||
### 在 JS 区块中上传并发送附件
|
||||
|
||||
下面的示例在 JS 区块中渲染文件上传、任务说明和发送按钮。文件上传完成后,会作为 `message.attachments` 传给 AI 员工:
|
||||
|
||||
```tsx
|
||||
if (!ctx.ai?.uploadFile || !ctx.ai?.triggerTask) {
|
||||
ctx.message.error(ctx.t('AI 员工任务 API 不可用。'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { React } = ctx.libs;
|
||||
const { useState } = React;
|
||||
const { Button, Card, Input, Space, Upload } = ctx.libs.antd;
|
||||
const { InboxOutlined, SendOutlined } = ctx.libs.antdIcons;
|
||||
|
||||
const AttachmentTask = () => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [fileList, setFileList] = useState([]);
|
||||
|
||||
const uploadAttachment = async ({ file, onError, onProgress, onSuccess }) => {
|
||||
try {
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
onProgress?.({ percent });
|
||||
},
|
||||
});
|
||||
onSuccess?.(attachment);
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error : new Error(ctx.t('文件上传失败')));
|
||||
}
|
||||
};
|
||||
|
||||
const sendTask = () => {
|
||||
const attachments = fileList
|
||||
.filter((file) => file.status === 'done' && file.response)
|
||||
.map((file) => file.response);
|
||||
|
||||
if (!prompt.trim()) {
|
||||
ctx.message.warning(ctx.t('请输入任务说明'));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'viz',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('分析上传的文件'),
|
||||
message: {
|
||||
user: prompt.trim(),
|
||||
attachments,
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
setPrompt('');
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const uploading = fileList.some((file) => file.status === 'uploading');
|
||||
|
||||
return (
|
||||
<Card title={ctx.t('AI 文件分析')}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
fileList={fileList}
|
||||
customRequest={uploadAttachment}
|
||||
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p>{ctx.t('点击或拖拽文件到此处上传')}</p>
|
||||
</Upload.Dragger>
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={ctx.t('请输入希望 AI 完成的任务')}
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
disabled={uploading || !prompt.trim()}
|
||||
onClick={sendTask}
|
||||
>
|
||||
{ctx.t('发送给 AI')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
ctx.render(<AttachmentTask />);
|
||||
```
|
||||
|
||||
如果设置 `autoSend: false`,附件和任务说明会进入 AI 聊天窗口草稿,不会立即发送。
|
||||
|
||||
### 添加页面区块上下文
|
||||
|
||||
`message.workContext` 当前用于传入页面区块。把目标页面区块的 FlowModel uid 放进去即可:
|
||||
@@ -263,9 +449,15 @@ ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTa
|
||||
| `taskIndex` | `number` | 任务下标,从 `0` 开始 |
|
||||
| `options.open` | `boolean` | 是否打开 AI 员工对话面板 |
|
||||
| `options.auto` | `boolean` | 是否按 AI 员工操作的自动触发语义处理 |
|
||||
| `options.attachments` | `Attachment[]` | 动态追加到预配置任务的附件 |
|
||||
| `options.onResponseLoadingChange` | `(loading: boolean) => void` | 模型响应加载状态变化回调,仅在预配置任务自动发送时触发 |
|
||||
|
||||
这个方法会从目标模型读取 AI 员工和任务配置。它适合复用页面上已经配置好的 AI 员工操作——运营人员可以在界面里调整任务内容,JSBlock 只负责触发。
|
||||
|
||||
`triggerModelTask()` 的 `options` 不接收 `chatBoxUid`。如果要在指定 AI Chat Box 中触发,需要在目标 AI 员工操作的对应任务上配置 `chatBoxUid`。`triggerModelTask()` 读取该任务时会一并复用此字段。
|
||||
|
||||
`options.onResponseLoadingChange` 的行为和 `triggerTask()` 相同。是否触发回调取决于目标 AI 员工操作中对应任务的 `autoSend` 配置;如果该任务设置为 `autoSend: false`,回调不会触发。
|
||||
|
||||
```ts
|
||||
if (!ctx.ai?.triggerModelTask) {
|
||||
ctx.message.error(ctx.t('AI 员工任务 API 不可用。'));
|
||||
@@ -276,6 +468,7 @@ const weeklyReviewActionUid = 'AI_EMPLOYEE_ACTION_MODEL_UID';
|
||||
|
||||
ctx.ai.triggerModelTask(weeklyReviewActionUid, 0, {
|
||||
open: true,
|
||||
attachments,
|
||||
});
|
||||
|
||||
ctx.message.success(ctx.t('已触发配置好的 AI 员工任务。'));
|
||||
@@ -295,9 +488,14 @@ ctx.message.success(ctx.t('已触发配置好的 AI 员工任务。'));
|
||||
## 注意事项
|
||||
|
||||
- `triggerTask()` 和 `triggerModelTask()` 都是 fire-and-forget,不返回 AI 员工任务的执行结果。
|
||||
- `uploadFile()` 返回 Promise,必须等上传完成后再触发需要这些附件的任务。
|
||||
- `aiEmployee` 字符串只按 `AIEmployee.username` 精确匹配,不按昵称、职位或翻译后的名称匹配。
|
||||
- `triggerModelTask()` 的 `taskIndex` 从 `0` 开始。
|
||||
- `triggerModelTask()` 读取的是目标 AI 员工操作模型上的任务配置。任务需要使用工作上下文时,请在该任务配置里设置 `message.workContext`。
|
||||
- `triggerTask()` 的顶层 `chatBoxUid` 必须指向当前已挂载的 AI Chat Box 区块。
|
||||
- `triggerModelTask()` 不接收顶层 `chatBoxUid`,它会继续使用预设 Task 上的 `chatBoxUid`。
|
||||
- `triggerModelTask()` 的动态附件会追加到预配置任务已有的 `message.attachments`,不会修改原任务配置。
|
||||
- `onResponseLoadingChange` 只监听由当前调用自动发送的模型响应,不会跟踪之后由用户手动发送的消息。
|
||||
|
||||
## 相关
|
||||
|
||||
|
||||
@@ -80,6 +80,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Seitenblöcke",
|
||||
"items": [
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Integrierte KI-Mitarbeiter",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
pkg: '@nocobase/plugin-ai'
|
||||
title: 'AI Chat box-Block'
|
||||
description: 'Benutzerhandbuch für NocoBase-Administratoren und Seitenersteller zum Hinzufügen eines AI Chat box-Blocks, Konfigurieren der Chatfunktionen, Festlegen des Work context, Verwalten von Unterhaltungen und Hinzufügen von Actions.'
|
||||
keywords: 'AI Chat box,KI-Mitarbeiter,Seitenblock,Work context,Scope,Actions,NocoBase'
|
||||
---
|
||||
|
||||
# AI Chat box-Block
|
||||
|
||||
In NocoBase ist die **AI Chat box** ein KI-Chatblock, der direkt zu einer Seite hinzugefügt werden kann. Sie können ihn auf einer Geschäftsseite platzieren, um einen festen Zugang zu einem KI-Assistenten für diese Seite bereitzustellen.
|
||||
|
||||
Jeder AI Chat box-Block besitzt einen eigenen aktuellen Chat- und Eingabestatus. Seitenersteller können außerdem die verfügbaren KI-Mitarbeiter, Modelle, Datei-Uploads, Websuche und den Arbeitskontext passend zum jeweiligen Geschäftsszenario einschränken.
|
||||
|
||||
:::tip Vorbereitungen
|
||||
|
||||
[Konfigurieren Sie zunächst einen LLM-Dienst](../features/llm-service.md) und [aktivieren Sie mindestens einen KI-Mitarbeiter](../features/enable-ai-employee.md).
|
||||
|
||||
:::
|
||||
|
||||
## AI Chat box-Block hinzufügen
|
||||
|
||||
1. Öffnen Sie die Seite, die Sie konfigurieren möchten.
|
||||
2. Klicken Sie oben rechts auf `UI Editor`, um den Seitenbearbeitungsmodus zu öffnen.
|
||||
3. Klicken Sie auf `Add block`.
|
||||
4. Wählen Sie unter `Other blocks` den Eintrag `AI chat box` aus.
|
||||
|
||||

|
||||
|
||||
## Aufbau des Blocks
|
||||
|
||||

|
||||
|
||||
Die AI Chat box besteht von oben nach unten aus drei Bereichen:
|
||||
|
||||
- **Oberer Aktionsbereich** — Zugang zur Chatliste, Actions, benutzerdefinierte Aktionen und Schaltfläche für einen neuen Chat; bei ausgeblendetem Nachrichtenbereich erscheint zusätzlich eine Nachrichtenschaltfläche
|
||||
- **Nachrichtenbereich** — zeigt die Nachrichten des aktuellen Entwurfs oder Chats an
|
||||
- **Sendebereich** — Eingabefeld, Kontextauswahl, Datei-Upload, Websuche, Auswahl des KI-Mitarbeiters, Modellauswahl, Sendeschaltfläche und Haftungsausschluss
|
||||
|
||||
### Anzeigeinhalte im Block-body hinzufügen
|
||||
|
||||
Klicken Sie im Seitenbearbeitungsmodus innerhalb der AI Chat box auf `Add block`, um oberhalb des Chatbereichs einen der folgenden Blöcke hinzuzufügen:
|
||||
|
||||
- JS block
|
||||
- Iframe
|
||||
- Markdown
|
||||
|
||||
Diese Blöcke eignen sich für Anleitungen, externe Seiten oder ergänzende Informationen. Das interne Menü stellt nur diese drei Blocktypen bereit und erlaubt keine weitere verschachtelte AI Chat box.
|
||||
|
||||
## AI Chat box konfigurieren
|
||||
|
||||
Bewegen Sie den Mauszeiger über den Block und öffnen Sie das Einstellungsmenü. Klicken Sie auf `Edit chat box`, um Chatbereich, Standardnachricht, Work context, KI-Mitarbeiter und Modelle zu konfigurieren.
|
||||
|
||||

|
||||
|
||||
### Einstellungen unter Edit chat box
|
||||
|
||||
| Einstellung | Beschreibung |
|
||||
| --- | --- |
|
||||
| `Scope` | Steuert, welche AI Chat boxes eine Chatliste gemeinsam verwenden. Ein neuer Block nutzt standardmäßig seine eigene Block-UID, um Chats voneinander zu trennen. |
|
||||
| `Background` | Fügt nach der Definition des KI-Mitarbeiters einen System-Prompt hinzu, um Rolle, Ziel oder Antwortanforderungen der aktuellen Seite festzulegen. |
|
||||
| `Default user message` | Füllt beim Start eines neuen Chats eine Standardbenutzernachricht im Sendebereich vor. |
|
||||
| `Work context` | Wählt Seitenblöcke aus, die standardmäßig in einen neuen Entwurf aufgenommen werden. |
|
||||
| `AI employees` | Beschränkt die geschäftlichen KI-Mitarbeiter, die in diesem Block ausgewählt werden können. Leer lassen, um alle verfügbaren geschäftlichen KI-Mitarbeiter zuzulassen. |
|
||||
| `Models` | Beschränkt die Modelle, die in diesem Block ausgewählt werden können. Leer lassen, um alle verfügbaren Modelle zuzulassen. |
|
||||
|
||||
### Weitere Blockeinstellungen
|
||||
|
||||
| Einstellung | Beschreibung |
|
||||
| --- | --- |
|
||||
| `Show messages` | Steuert, ob der Nachrichtenbereich direkt im Block angezeigt wird. Wenn deaktiviert, öffnen Sie das rechte Panel über die Nachrichtenschaltfläche im oberen Bereich. |
|
||||
| `Sender placeholder` | Ändert den Platzhaltertext im Sendebereich. |
|
||||
| `Enable add context` | Blendet den Einstieg zur Kontextauswahl im Sendebereich ein oder aus. |
|
||||
| `Enable upload files` | Blendet den Datei-Upload ein oder aus. Wenn deaktiviert, startet auch das Einfügen einer Datei keinen Upload. |
|
||||
| `Enable web search` | Blendet den Schalter für die Websuche ein oder aus. Beim Deaktivieren wird auch die Websuche des aktuellen Entwurfs ausgeschaltet. |
|
||||
| `Enable employee select` | Blendet die Auswahl des KI-Mitarbeiters ein oder aus. |
|
||||
| `Enable model select` | Blendet die Modellauswahl ein oder aus. |
|
||||
| `Show disclaimer` | Blendet den KI-Haftungsausschluss unter dem Sendebereich ein oder aus. |
|
||||
|
||||
## Work context konfigurieren
|
||||
|
||||
Klicken Sie unter `Edit chat box` bei `Work context` auf die Schaltfläche zum Hinzufügen eines Kontexts, wählen Sie `Pick block` und anschließend den Seitenblock aus, den Sie der KI bereitstellen möchten. Nach dem Speichern wird der ausgewählte Block zum Standardarbeitskontext neuer Chats und kann vor dem Senden im Sendebereich entfernt werden.
|
||||
|
||||
## Nachrichten ausblenden und das rechte Panel verwenden
|
||||
|
||||
Nach dem Deaktivieren von `Show messages` bleibt im Block-body nur der Sendebereich sichtbar. Oben erscheint eine Nachrichtenschaltfläche, über die Sie das Nachrichtenpanel von rechts öffnen können.
|
||||
|
||||

|
||||
|
||||
Wenn das Panel geöffnet ist, wird der restliche Block von einer Überlagerung abgedeckt. Klicken Sie auf die Überlagerung oder erneut auf die Nachrichtenschaltfläche, um das Panel zu schließen.
|
||||
|
||||
Dieses Layout eignet sich, wenn die AI Chat box als kompakter Eingabeeinstieg auf einer Seite verwendet wird: Normalerweise bleibt nur der Sendebereich sichtbar, und das Panel wird bei Bedarf zum Lesen der Nachrichten geöffnet.
|
||||
|
||||
## Chatverlauf verwalten
|
||||
|
||||
Klicken Sie oben links im Block auf die Schaltfläche für die Chatliste, um den Verlauf im aktuellen Scope anzuzeigen.
|
||||
|
||||
Beachten Sie dabei folgende Regeln:
|
||||
|
||||
- Mehrere AI Chat boxes mit demselben Scope können dieselbe Chatliste anzeigen
|
||||
- Jeder Block besitzt weiterhin einen eigenen aktuellen Chat, Sendeentwurf, KI-Mitarbeiter, ein eigenes Modell sowie eigene Anhänge und Kontextzustände
|
||||
- Die globale schwebende Chatbox filtert nicht nach dem Block-Scope und blendet Chats mit Scope daher nicht aus
|
||||
- Nach dem Leeren von Scope wird die Chatliste nicht mehr nach Scope gefiltert und zeigt sowohl Chats ohne Scope als auch Chats mit anderen Scopes an
|
||||
|
||||
Normalerweise reicht es aus, den für einen neuen Block erzeugten Scope beizubehalten, um die Verläufe der Seitenassistenten voneinander zu trennen. Konfigurieren Sie denselben Scope nur, wenn mehrere Blöcke dieselbe Chatliste gemeinsam verwenden sollen.
|
||||
|
||||
## Actions hinzufügen
|
||||
|
||||
Klicken Sie im Seitenbearbeitungsmodus oben im Block auf `Actions`, um eine der folgenden Aktionen hinzuzufügen:
|
||||
|
||||
- JS Action
|
||||
- AI employee
|
||||
|
||||
Nach dem Hinzufügen eines AI employee können Sie Shortcut-Aufgaben für diesen Mitarbeiter konfigurieren.
|
||||
|
||||
Die Einstellung `Chat box uid` einer Shortcut-Aufgabe legt fest, in welcher AI Chat box die Aufgabe ausgeführt wird. Ein direkt innerhalb einer AI Chat box hinzugefügter AI employee verweist standardmäßig auf die UID des aktuellen Blocks.
|
||||
|
||||
Wenn die angegebene AI Chat box nicht eingebunden ist, meldet NocoBase, dass der Zielblock nicht gefunden wurde, und weicht nicht auf die globale schwebende Chatbox aus. Ausführliche Informationen finden Sie unter [Shortcut-Aufgaben für KI-Mitarbeiter](../features/task.md).
|
||||
|
||||
## Seitenspezifischen Assistenten konfigurieren
|
||||
|
||||
Mit den folgenden Schritten erstellen Sie einen kompakten KI-Assistenten für eine Seite:
|
||||
|
||||
1. Fügen Sie einen AI Chat box-Block hinzu und verschieben Sie ihn an die passende Position auf der Seite.
|
||||
2. Geben Sie unter `Edit chat box` einen seitenspezifischen Background ein.
|
||||
3. Wählen Sie einen oder mehrere Work contexts aus.
|
||||
4. Beschränken Sie unter `AI employees` und `Models` die verfügbaren Mitarbeiter und Modelle.
|
||||
5. Beenden Sie den Bearbeitungsmodus, geben Sie eine Frage ein und senden Sie sie.
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Der AI Chat box-Block und die globale schwebende Chatbox unten rechts sind getrennte Einstiegspunkte; der aktuelle Chat und der Eingabestatus werden nicht automatisch synchronisiert
|
||||
- Innerhalb einer AI Chat box können über `Add block` nur JS block, Iframe und Markdown hinzugefügt werden
|
||||
- Eine Änderung von Scope beeinflusst den Abfragebereich der Chatliste und kopiert weder den aktuell in einem anderen Block geöffneten Chat noch dessen Entwurf
|
||||
@@ -123,6 +123,11 @@
|
||||
"label": "Aktionspanel",
|
||||
"link": "/interface-builder/blocks/other-blocks/action-panel"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Iframe",
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
---
|
||||
title: "ctx.ai"
|
||||
description: "Verwenden Sie ctx.ai in RunJS, um KI-Mitarbeiteraufgaben auszulösen, entweder mit direkt übergebenem Aufgabeninhalt oder mit Aufgaben aus einer KI-Mitarbeiteraktion."
|
||||
keywords: "ctx.ai,AI employee,triggerTask,triggerModelTask,RunJS,NocoBase"
|
||||
description: "Verwenden Sie ctx.ai in RunJS, um KI-Mitarbeiteraufgaben im globalen Dialog oder in einer angegebenen AI Chat Box auszulösen, entweder mit direkt übergebenem Aufgabeninhalt oder mit Aufgaben aus einer KI-Mitarbeiteraktion."
|
||||
keywords: "ctx.ai,AI employee,uploadFile,attachments,triggerTask,triggerModelTask,onResponseLoadingChange,chatBoxUid,AI Chat Box,RunJS,NocoBase"
|
||||
---
|
||||
|
||||
# ctx.ai
|
||||
|
||||
Mit `ctx.ai` können Sie in RunJS **KI-Mitarbeiteraufgaben** auslösen. Das ist nützlich in JSBlock, JSAction und anderen Interaktionen, bei denen ein Button, Formular oder Geschäftsablauf Arbeit an einen bestimmten KI-Mitarbeiter übergibt.
|
||||
|
||||
`ctx.ai` löst nur Aufgaben aus. Das Ergebnis der KI-Mitarbeiteraufgabe wird nicht zurückgegeben. Nach dem Aufruf läuft die Aufgabe im Konversationsfluss des KI-Mitarbeiters weiter.
|
||||
`ctx.ai` lädt Anhänge für KI-Aufgaben hoch und löst Aufgaben aus. Datei-Uploads können abgewartet werden, das Auslösen einer Aufgabe gibt jedoch kein Ausführungsergebnis zurück. Nach dem Aufruf läuft die Aufgabe im Konversationsfluss des KI-Mitarbeiters weiter.
|
||||
|
||||
:::warning Hinweis
|
||||
|
||||
`ctx.ai` wird vom AI-Plugin bereitgestellt. Wenn das AI-Plugin nicht aktiviert ist oder die aktuelle RunJS-Umgebung die passende Client-Funktion nicht geladen hat, kann `ctx.ai` fehlen. Prüfen Sie vor dem Aufruf `ctx.ai?.triggerTask` oder `ctx.ai?.triggerModelTask`.
|
||||
`ctx.ai` wird vom AI-Plugin bereitgestellt. Wenn das AI-Plugin nicht aktiviert ist oder die aktuelle RunJS-Umgebung die passende Client-Funktion nicht geladen hat, kann `ctx.ai` fehlen. Prüfen Sie vor dem Aufruf `ctx.ai?.uploadFile`, `ctx.ai?.triggerTask` oder `ctx.ai?.triggerModelTask`.
|
||||
|
||||
:::
|
||||
|
||||
## Methoden
|
||||
|
||||
### ctx.ai.uploadFile()
|
||||
|
||||
Lädt eine Datei hoch und gibt ein Anhangsobjekt zurück, das direkt an eine KI-Mitarbeiteraufgabe übergeben werden kann.
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, options);
|
||||
```
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
|------|------|------|
|
||||
| `file` | `File` | Browser-Dateiobjekt, das hochgeladen werden soll. |
|
||||
| `options.onProgress` | `(percent: number) => void` | Rückruf für den Upload-Fortschritt. `percent` liegt zwischen `0` und `100`. |
|
||||
| `options.signal` | `AbortSignal` | Signal zum Abbrechen des Uploads. |
|
||||
|
||||
Der Upload verwendet den im AI-Plugin konfigurierten Dateispeicher und erstellt einen Datensatz in `aiFiles`. Das zurückgegebene Objekt enthält unter anderem `id`, `filename`, `url` und `source`:
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
console.log('upload progress', percent);
|
||||
},
|
||||
});
|
||||
|
||||
// attachment kann direkt in message.attachments verwendet werden
|
||||
```
|
||||
|
||||
Bei einem Fehler wird das Promise abgelehnt. Wenn ein Anhang nur aus der lokalen Dateiliste entfernt wird, bleibt der bereits in `aiFiles` erstellte Datensatz bestehen. Dieses Verhalten entspricht dem standardmäßigen KI-Chatfenster.
|
||||
|
||||
### ctx.ai.triggerTask()
|
||||
|
||||
Löst eine KI-Mitarbeiteraufgabe direkt aus.
|
||||
@@ -30,8 +58,10 @@ ctx.ai.triggerTask(options: TriggerTaskOptions): void
|
||||
|------|------|------|
|
||||
| `aiEmployee` | `string \| AIEmployee` | KI-Mitarbeiter. Bei einer Zeichenkette wird exakt gegen `AIEmployee.username` abgeglichen, und der KI-Mitarbeiter muss für den aktuellen Benutzer zugänglich sein. |
|
||||
| `tasks` | `Task[]` | Auszulösende Aufgaben. |
|
||||
| `chatBoxUid` | `string` | FlowModel uid des AI-Chat-Box-Blocks, der die Aufgabe empfangen soll. |
|
||||
| `open` | `boolean` | Ob das Konversationspanel des KI-Mitarbeiters geöffnet wird. |
|
||||
| `auto` | `boolean` | Ob die automatische Auslösesemantik einer KI-Mitarbeiteraktion verwendet wird. |
|
||||
| `onResponseLoadingChange` | `(loading: boolean) => void` | Callback für den Ladezustand der Modellantwort. Er wird nur ausgeführt, wenn diese Aufgabe automatisch gesendet wird. |
|
||||
|
||||
Häufige Felder von `Task`:
|
||||
|
||||
@@ -40,12 +70,160 @@ Häufige Felder von `Task`:
|
||||
| `title` | `string` | Aufgabentitel. |
|
||||
| `message.system` | `string` | Systemnachricht zur Eingrenzung von Rolle und Ausgabeanforderungen. |
|
||||
| `message.user` | `string` | Benutzernachricht, also die Hauptanweisung der Aufgabe. |
|
||||
| `message.attachments` | `Attachment[]` | Anhänge der Aufgabe, normalerweise aus `ctx.ai.uploadFile()`. |
|
||||
| `message.workContext` | `ContextItem[]` | Seitenblockkontext, den die Aufgabe verwendet. |
|
||||
| `autoSend` | `boolean` | Ob die Aufgabennachricht automatisch gesendet wird. |
|
||||
| `webSearch` | `boolean` | Ob diese Aufgabe Web search verwenden darf. |
|
||||
| `model` | `{ llmService: string; model: string } \| null` | Modell für diese Aufgabe. |
|
||||
| `skillSettings` | `SkillSettings` | Skills / tools für diese Aufgabe. |
|
||||
|
||||
### Ladezustand der Antwort verfolgen
|
||||
|
||||
Übergeben Sie `onResponseLoadingChange` in den Optionen auf oberster Ebene, um den Ladezustand der Modellantwort zu verfolgen. Der Callback erhält `true`, sobald NocoBase auf die Modellantwort wartet, und `false`, wenn sie abgeschlossen, abgebrochen oder fehlgeschlagen ist. Wenn die React-Komponente `setResponseLoading` bereits mit `useState` deklariert hat, können Sie Folgendes schreiben:
|
||||
|
||||
```tsx
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
onResponseLoadingChange(loading) {
|
||||
setResponseLoading(loading);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`onResponseLoadingChange` verfolgt nur die Modellantwort, die direkt durch diesen `triggerTask()`-Aufruf gestartet wurde. Bei `autoSend: false` bleibt die Aufgabe im Chatentwurf und der Callback wird nicht ausgeführt. Wenn der Benutzer den Entwurf später manuell sendet, wird dieser Callback nicht wiederverwendet.
|
||||
|
||||
In einer React-Komponente eines JS-Blocks löst diese Zustandsänderung ein erneutes Rendern aus, solange die Komponente eingebunden bleibt.
|
||||
|
||||
### Eine AI Chat Box als Ziel festlegen
|
||||
|
||||
Setzen Sie `chatBoxUid` in den Optionen von `triggerTask()` auf oberster Ebene, um die Aufgabe in einem eingebundenen AI-Chat-Box-Block statt im globalen KI-Mitarbeiterdialog auszulösen.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
chatBoxUid: 'AI_CHAT_BOX_BLOCK_UID',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Die uid muss zum äußeren AI-Chat-Box-Block gehören, der aktuell auf der Seite eingebunden ist. Legen Sie diesen Routing-Wert nicht in `tasks` ab. Wenn der Zielblock nicht gefunden wird, meldet NocoBase einen Fehler und fällt nicht auf den globalen Dialog zurück. Ohne `chatBoxUid` wird die Aufgabe im globalen KI-Mitarbeiterdialog ausgelöst.
|
||||
|
||||
### Anhänge in JSBlock hochladen und senden
|
||||
|
||||
Das folgende Beispiel rendert in JSBlock einen Datei-Upload, ein Feld für die Aufgabenanweisung und einen Senden-Button. Hochgeladene Dateien werden über `message.attachments` an den KI-Mitarbeiter übergeben:
|
||||
|
||||
```tsx
|
||||
if (!ctx.ai?.uploadFile || !ctx.ai?.triggerTask) {
|
||||
ctx.message.error(ctx.t('AI employee task API is not available.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { React } = ctx.libs;
|
||||
const { useState } = React;
|
||||
const { Button, Card, Input, Space, Upload } = ctx.libs.antd;
|
||||
const { InboxOutlined, SendOutlined } = ctx.libs.antdIcons;
|
||||
|
||||
const AttachmentTask = () => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [fileList, setFileList] = useState([]);
|
||||
|
||||
const uploadAttachment = async ({ file, onError, onProgress, onSuccess }) => {
|
||||
try {
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
onProgress?.({ percent });
|
||||
},
|
||||
});
|
||||
onSuccess?.(attachment);
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error : new Error(ctx.t('File upload failed')));
|
||||
}
|
||||
};
|
||||
|
||||
const sendTask = () => {
|
||||
const attachments = fileList
|
||||
.filter((file) => file.status === 'done' && file.response)
|
||||
.map((file) => file.response);
|
||||
|
||||
if (!prompt.trim()) {
|
||||
ctx.message.warning(ctx.t('Enter task instructions'));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'viz',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Analyze uploaded files'),
|
||||
message: {
|
||||
user: prompt.trim(),
|
||||
attachments,
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
setPrompt('');
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const uploading = fileList.some((file) => file.status === 'uploading');
|
||||
|
||||
return (
|
||||
<Card title={ctx.t('AI file analysis')}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
fileList={fileList}
|
||||
customRequest={uploadAttachment}
|
||||
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p>{ctx.t('Click or drag files here to upload')}</p>
|
||||
</Upload.Dragger>
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={ctx.t('Describe the task for the AI employee')}
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
disabled={uploading || !prompt.trim()}
|
||||
onClick={sendTask}
|
||||
>
|
||||
{ctx.t('Send to AI')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
ctx.render(<AttachmentTask />);
|
||||
```
|
||||
|
||||
Mit `autoSend: false` werden Anhänge und Aufgabenanweisung als Entwurf in den KI-Chat übernommen, aber nicht sofort gesendet.
|
||||
|
||||
### Seitenblockkontext hinzufügen
|
||||
|
||||
`message.workContext` wird aktuell verwendet, um Seitenblöcke zu übergeben. Tragen Sie dort die FlowModel uid des Zielblocks ein:
|
||||
@@ -151,6 +329,8 @@ Wenn `aiEmployee` eine Zeichenkette ist, sucht NocoBase exakt nach `username` un
|
||||
|
||||
Liest eine Aufgabe aus einem KI-Mitarbeiteraktionsmodell auf der Seite und löst sie aus.
|
||||
|
||||
Die öffentlichen Optionen von `triggerModelTask()` akzeptieren kein `chatBoxUid`. Um eine AI Chat Box als Ziel festzulegen, konfigurieren Sie `chatBoxUid` in der vordefinierten Aufgabe der KI-Mitarbeiteraktion. `triggerModelTask()` verwendet diesen voreingestellten Wert weiterhin.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTaskOptions): void
|
||||
```
|
||||
@@ -161,6 +341,10 @@ ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTa
|
||||
| `taskIndex` | `number` | Aufgabenindex, beginnend bei `0`. |
|
||||
| `options.open` | `boolean` | Ob das Konversationspanel des KI-Mitarbeiters geöffnet wird. |
|
||||
| `options.auto` | `boolean` | Ob die automatische Auslösesemantik einer KI-Mitarbeiteraktion verwendet wird. |
|
||||
| `options.attachments` | `Attachment[]` | Anhänge, die dynamisch an die konfigurierte Aufgabe angefügt werden. |
|
||||
| `options.onResponseLoadingChange` | `(loading: boolean) => void` | Callback für den Ladezustand der Modellantwort. Er wird nur ausgeführt, wenn die konfigurierte Aufgabe automatisch gesendet wird. |
|
||||
|
||||
`options.onResponseLoadingChange` verhält sich wie bei `triggerTask()`. Ob der Callback ausgeführt wird, hängt vom Wert `autoSend` der konfigurierten Aufgabe ab. Bei `autoSend: false` wird er nicht ausgeführt.
|
||||
|
||||
```ts
|
||||
if (!ctx.ai?.triggerModelTask) {
|
||||
@@ -172,6 +356,7 @@ const weeklyReviewActionUid = 'AI_EMPLOYEE_ACTION_MODEL_UID';
|
||||
|
||||
ctx.ai.triggerModelTask(weeklyReviewActionUid, 0, {
|
||||
open: true,
|
||||
attachments,
|
||||
});
|
||||
|
||||
ctx.message.success(ctx.t('Configured AI employee task triggered.'));
|
||||
@@ -182,9 +367,14 @@ Wenn das Zielmodell nicht existiert, kein KI-Mitarbeiter konfiguriert ist oder d
|
||||
## Hinweise
|
||||
|
||||
- `triggerTask()` und `triggerModelTask()` sind fire-and-forget. Sie geben kein Ausführungsergebnis der KI-Mitarbeiteraufgabe zurück.
|
||||
- `uploadFile()` gibt ein Promise zurück. Warten Sie auf den Abschluss des Uploads, bevor Sie eine Aufgabe mit diesem Anhang auslösen.
|
||||
- Zeichenketten in `aiEmployee` werden nur exakt gegen `AIEmployee.username` abgeglichen.
|
||||
- `triggerModelTask()` verwendet einen `taskIndex`, der bei `0` beginnt.
|
||||
- `message.workContext` beschreibt aktuell nur Seitenblockkontext.
|
||||
- `triggerTask().chatBoxUid` auf oberster Ebene muss auf einen aktuell eingebundenen AI-Chat-Box-Block verweisen.
|
||||
- `triggerModelTask()` verwendet weiterhin das in der vordefinierten Aufgabe konfigurierte `chatBoxUid`.
|
||||
- Dynamische Anhänge von `triggerModelTask()` werden an vorhandene `message.attachments` der vordefinierten Aufgabe angefügt, ohne die gespeicherte Konfiguration zu ändern.
|
||||
- `onResponseLoadingChange` verfolgt nur eine von diesem Aufruf automatisch gesendete Modellantwort. Eine später manuell gesendete Nachricht wird nicht verfolgt.
|
||||
|
||||
## Verwandte Themen
|
||||
|
||||
|
||||
@@ -80,6 +80,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Page Blocks",
|
||||
"items": [
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Built-in AI Employees",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
pkg: '@nocobase/plugin-ai'
|
||||
title: 'AI Chat box block'
|
||||
description: 'User guide for NocoBase administrators and page builders covering how to add an AI Chat box block, configure conversation capabilities, set Work context, manage conversations, and add Actions.'
|
||||
keywords: 'AI Chat box,AI Employee,page block,Work context,Scope,Actions,NocoBase'
|
||||
---
|
||||
|
||||
# AI Chat box block
|
||||
|
||||
In NocoBase, **AI Chat box** is an AI conversation block that can be added directly to a page. You can place it on a business page to provide a fixed AI assistant entry point for that page.
|
||||
|
||||
Each AI Chat box block has its own current conversation and input state. Page builders can also restrict the available AI employees, models, file uploads, web search, and work context to suit the current business scenario.
|
||||
|
||||
:::tip Before you start
|
||||
|
||||
First [configure an LLM service](../features/llm-service.md) and [enable at least one AI employee](../features/enable-ai-employee.md).
|
||||
|
||||
:::
|
||||
|
||||
## Add an AI Chat box block
|
||||
|
||||
1. Open the page you want to configure.
|
||||
2. Click `UI Editor` in the upper-right corner to enter page editing mode.
|
||||
3. Click `Add block`.
|
||||
4. Under `Other blocks`, select `AI chat box`.
|
||||
|
||||

|
||||
|
||||
## Understand the block structure
|
||||
|
||||

|
||||
|
||||
An AI Chat box contains three areas from top to bottom:
|
||||
|
||||
- **Top action area** — conversation list, Actions, custom actions, and new conversation buttons; a messages button also appears when the messages area is hidden
|
||||
- **Messages area** — displays messages in the current draft or conversation
|
||||
- **Sender area** — input box, context selection, file upload, web search, AI employee selection, model selection, send button, and disclaimer
|
||||
|
||||
### Add display content inside the block body
|
||||
|
||||
In page editing mode, click `Add block` inside the AI Chat box to add any of the following blocks above the chat area:
|
||||
|
||||
- JS block
|
||||
- Iframe
|
||||
- Markdown
|
||||
|
||||
These blocks are useful for displaying instructions, external pages, or supporting information. The internal Add block menu only provides these three block types and does not allow another AI Chat box to be nested inside it.
|
||||
|
||||
## Configure the AI Chat box
|
||||
|
||||
Move the pointer over the block and open its settings menu. Click `Edit chat box` to configure the conversation scope, default message, Work context, AI employees, and models.
|
||||
|
||||

|
||||
|
||||
### Edit chat box settings
|
||||
|
||||
| Setting | Description |
|
||||
| --- | --- |
|
||||
| `Scope` | Controls which AI Chat boxes share a conversation list. A new block uses its own block UID by default to keep conversations separate. |
|
||||
| `Background` | Adds a system prompt after the AI employee definition to provide the role, objective, or response requirements for the current page. |
|
||||
| `Default user message` | Prefills a default user message in the sender when a new conversation starts. |
|
||||
| `Work context` | Selects page blocks to place in a new draft by default. |
|
||||
| `AI employees` | Restricts the business AI employees that can be selected in this block. Leave it empty to allow all available business AI employees. |
|
||||
| `Models` | Restricts the models that can be selected in this block. Leave it empty to allow all available models. |
|
||||
|
||||
### Other block settings
|
||||
|
||||
| Setting | Description |
|
||||
| --- | --- |
|
||||
| `Show messages` | Controls whether the messages area is displayed directly in the block. When disabled, use the messages button at the top to open the right-side panel. |
|
||||
| `Sender placeholder` | Changes the placeholder shown in the sender. |
|
||||
| `Enable add context` | Shows or hides the context selection entry in the sender. |
|
||||
| `Enable upload files` | Shows or hides the file upload entry. When disabled, pasting a file does not start an upload. |
|
||||
| `Enable web search` | Shows or hides the web search switch. Disabling it also turns off web search for the current draft. |
|
||||
| `Enable employee select` | Shows or hides the AI employee selector. |
|
||||
| `Enable model select` | Shows or hides the model selector. |
|
||||
| `Show disclaimer` | Shows or hides the AI disclaimer below the sender. |
|
||||
|
||||
## Configure Work context
|
||||
|
||||
In `Work context` under `Edit chat box`, click the add context button, select `Pick block`, and then select the page block you want to provide to the AI. After saving, the selected block becomes the default work context for new conversations and can be removed from the sender before sending.
|
||||
|
||||
## Hide the messages area and use the right-side panel
|
||||
|
||||
After disabling `Show messages`, the block body only keeps the sender area. A messages button appears at the top; click it to open the messages panel from the right.
|
||||
|
||||

|
||||
|
||||
When the panel is open, the rest of the block is covered by an overlay. Click the overlay or click the messages button again to close the panel.
|
||||
|
||||
This layout works well when the AI Chat box is used as a lightweight input entry on a page: keep only the sender visible and open the panel when you need to review messages.
|
||||
|
||||
## Manage conversation history
|
||||
|
||||
Click the conversation list button in the upper-left corner of the block to view conversation history under the current Scope.
|
||||
|
||||
Keep these rules in mind:
|
||||
|
||||
- Multiple AI Chat boxes with the same Scope can see the same conversation list
|
||||
- Each block still has its own current conversation, sender draft, AI employee, model, attachments, and context state
|
||||
- The global floating chatbox does not filter by block Scope, so it does not hide conversations with a Scope
|
||||
- After clearing Scope, the block no longer filters the conversation list by Scope and displays both conversations without a Scope and conversations using other Scopes
|
||||
|
||||
Normally, keeping the Scope generated for a new block is enough to separate the history of each page assistant. Only configure the same Scope when multiple blocks need to share the same conversation list.
|
||||
|
||||
## Add Actions
|
||||
|
||||
In page editing mode, click `Actions` at the top of the block to add either of the following actions:
|
||||
|
||||
- JS Action
|
||||
- AI employee
|
||||
|
||||
After adding an AI employee, you can configure shortcut tasks for that employee.
|
||||
|
||||
The `Chat box uid` setting in a shortcut task specifies which AI Chat box runs the task. An AI employee added directly inside an AI Chat box points to the current block UID by default.
|
||||
|
||||
If the specified AI Chat box is not mounted, NocoBase reports that the target block cannot be found and does not fall back to the global floating chatbox. See [AI employee shortcut tasks](../features/task.md) for detailed configuration.
|
||||
|
||||
## Configure a page-specific assistant
|
||||
|
||||
The following steps create a lightweight AI assistant for a page:
|
||||
|
||||
1. Add an AI Chat box block and move it to the appropriate position on the page.
|
||||
2. Enter a page-specific Background under `Edit chat box`.
|
||||
3. Select one or more Work contexts.
|
||||
4. Restrict the available employees and models under `AI employees` and `Models`.
|
||||
5. Exit editing mode, enter a question, and send it.
|
||||
|
||||
## Notes
|
||||
|
||||
- The AI Chat box block and the global floating chatbox in the lower-right corner are separate entry points; their current conversations and input states are not synchronized automatically
|
||||
- Only JS block, Iframe, and Markdown can be added from `Add block` inside an AI Chat box
|
||||
- Changing Scope affects the conversation list query and does not copy the conversation or draft currently open in another block
|
||||
@@ -123,6 +123,11 @@
|
||||
"label": "Action Panel",
|
||||
"link": "/interface-builder/blocks/other-blocks/action-panel"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Iframe",
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
---
|
||||
title: "ctx.ai"
|
||||
description: "Use ctx.ai in RunJS to trigger AI employee tasks, either with inline task content or with tasks configured on an AI employee action."
|
||||
keywords: "ctx.ai,AI employee,triggerTask,triggerModelTask,RunJS,NocoBase"
|
||||
description: "Use ctx.ai in RunJS to trigger AI employee tasks in the global conversation or a specified AI Chat Box, either with inline task content or with tasks configured on an AI employee action."
|
||||
keywords: "ctx.ai,AI employee,uploadFile,attachments,triggerTask,triggerModelTask,onResponseLoadingChange,chatBoxUid,AI Chat Box,RunJS,NocoBase"
|
||||
---
|
||||
|
||||
# ctx.ai
|
||||
|
||||
Use `ctx.ai` in RunJS to trigger **AI employee tasks**. It works well in JSBlock, JSAction, and other interactions where a button, form, or business flow needs to hand work to a specific AI employee.
|
||||
|
||||
`ctx.ai` only triggers tasks. It does not return the execution result of the AI employee task. After the call, the task enters the AI employee conversation flow, and the result is handled by the AI employee session.
|
||||
`ctx.ai` uploads AI task attachments and triggers tasks. File uploads can be awaited, but task triggering does not return the execution result of the AI employee task. After the call, the task enters the AI employee conversation flow, and the result is handled by the AI employee session.
|
||||
|
||||
:::warning Note
|
||||
|
||||
`ctx.ai` is provided by the AI plugin. If the AI plugin is not enabled, or the current RunJS environment has not loaded the corresponding client capability, `ctx.ai` may not exist. You can check `ctx.ai?.triggerTask` or `ctx.ai?.triggerModelTask` before calling it.
|
||||
`ctx.ai` is provided by the AI plugin. If the AI plugin is not enabled, or the current RunJS environment has not loaded the corresponding client capability, `ctx.ai` may not exist. You can check `ctx.ai?.uploadFile`, `ctx.ai?.triggerTask`, or `ctx.ai?.triggerModelTask` before calling it.
|
||||
|
||||
:::
|
||||
|
||||
## Methods
|
||||
|
||||
### ctx.ai.uploadFile()
|
||||
|
||||
Upload one file and return an attachment object that can be passed directly to an AI employee task.
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, options);
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|------|------|------|
|
||||
| `file` | `File` | Browser file object to upload. |
|
||||
| `options.onProgress` | `(percent: number) => void` | Upload progress callback. `percent` ranges from `0` to `100`. |
|
||||
| `options.signal` | `AbortSignal` | Signal used to cancel the upload. |
|
||||
|
||||
The upload uses the file storage configured by the AI plugin and creates a record in `aiFiles`. The returned object includes fields such as `id`, `filename`, `url`, and `source`:
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
console.log('upload progress', percent);
|
||||
},
|
||||
});
|
||||
|
||||
// attachment can be placed directly in message.attachments
|
||||
```
|
||||
|
||||
The Promise is rejected when the upload fails. Removing an attachment from the local UI does not delete the record already created in `aiFiles`, matching the behavior of the default AI chat window.
|
||||
|
||||
### ctx.ai.triggerTask()
|
||||
|
||||
Trigger an AI employee task directly.
|
||||
@@ -30,8 +58,10 @@ ctx.ai.triggerTask(options: TriggerTaskOptions): void
|
||||
|------|------|------|
|
||||
| `aiEmployee` | `string \| AIEmployee` | AI employee. When a string is passed, NocoBase matches `AIEmployee.username` exactly, and the AI employee must be accessible to the current user. |
|
||||
| `tasks` | `Task[]` | Tasks to trigger. |
|
||||
| `chatBoxUid` | `string` | FlowModel uid of the AI Chat Box block that should receive the task. |
|
||||
| `open` | `boolean` | Whether to open the AI employee conversation panel. |
|
||||
| `auto` | `boolean` | Whether to use the auto-trigger semantics of an AI employee action. |
|
||||
| `onResponseLoadingChange` | `(loading: boolean) => void` | Model response loading callback. It only runs when this task is sent automatically. |
|
||||
|
||||
Common `Task` fields:
|
||||
|
||||
@@ -40,12 +70,160 @@ Common `Task` fields:
|
||||
| `title` | `string` | Task title. |
|
||||
| `message.system` | `string` | System message, used to constrain the AI employee's role and output requirements. |
|
||||
| `message.user` | `string` | User message, which is the main instruction for this task. |
|
||||
| `message.attachments` | `Attachment[]` | Attachments used by the task, usually returned by `ctx.ai.uploadFile()`. |
|
||||
| `message.workContext` | `ContextItem[]` | Page block context used by the task. |
|
||||
| `autoSend` | `boolean` | Whether to send the task message automatically. |
|
||||
| `webSearch` | `boolean` | Whether Web search is allowed for this task. |
|
||||
| `model` | `{ llmService: string; model: string } \| null` | Model used by this task. |
|
||||
| `skillSettings` | `SkillSettings` | Skills and tools available to this task. |
|
||||
|
||||
### Track the Response Loading State
|
||||
|
||||
Pass `onResponseLoadingChange` in the top-level options to track the model response loading state for this task. The callback receives `true` when NocoBase starts waiting for the model response, and `false` when the response completes, is canceled, or fails. If the React component has declared `setResponseLoading` with `useState`, you can write:
|
||||
|
||||
```tsx
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
onResponseLoadingChange(loading) {
|
||||
setResponseLoading(loading);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`onResponseLoadingChange` only tracks the model response started directly by this `triggerTask()` call. With `autoSend: false`, the task is placed in the chat draft and the callback does not run. If the user sends the draft later, that manual send does not reuse this callback.
|
||||
|
||||
In a React component inside a JS block, this state update rerenders the component while it remains mounted.
|
||||
|
||||
### Target an AI Chat Box
|
||||
|
||||
Set `chatBoxUid` on the top-level `triggerTask()` options to trigger the task in a mounted AI Chat Box block instead of the global AI employee dialog.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
chatBoxUid: 'AI_CHAT_BOX_BLOCK_UID',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
The uid must belong to the outer AI Chat Box block currently mounted on the page. Do not put this routing value inside `tasks`. If the target block cannot be found, NocoBase reports an error and does not fall back to the global dialog. When `chatBoxUid` is omitted, the task uses the global AI employee dialog.
|
||||
|
||||
### Upload and Send Attachments in JSBlock
|
||||
|
||||
The following example renders file upload, task instructions, and a send button in JSBlock. Uploaded files are passed to the AI employee through `message.attachments`:
|
||||
|
||||
```tsx
|
||||
if (!ctx.ai?.uploadFile || !ctx.ai?.triggerTask) {
|
||||
ctx.message.error(ctx.t('AI employee task API is not available.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { React } = ctx.libs;
|
||||
const { useState } = React;
|
||||
const { Button, Card, Input, Space, Upload } = ctx.libs.antd;
|
||||
const { InboxOutlined, SendOutlined } = ctx.libs.antdIcons;
|
||||
|
||||
const AttachmentTask = () => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [fileList, setFileList] = useState([]);
|
||||
|
||||
const uploadAttachment = async ({ file, onError, onProgress, onSuccess }) => {
|
||||
try {
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
onProgress?.({ percent });
|
||||
},
|
||||
});
|
||||
onSuccess?.(attachment);
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error : new Error(ctx.t('File upload failed')));
|
||||
}
|
||||
};
|
||||
|
||||
const sendTask = () => {
|
||||
const attachments = fileList
|
||||
.filter((file) => file.status === 'done' && file.response)
|
||||
.map((file) => file.response);
|
||||
|
||||
if (!prompt.trim()) {
|
||||
ctx.message.warning(ctx.t('Enter task instructions'));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'viz',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Analyze uploaded files'),
|
||||
message: {
|
||||
user: prompt.trim(),
|
||||
attachments,
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
setPrompt('');
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const uploading = fileList.some((file) => file.status === 'uploading');
|
||||
|
||||
return (
|
||||
<Card title={ctx.t('AI file analysis')}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
fileList={fileList}
|
||||
customRequest={uploadAttachment}
|
||||
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p>{ctx.t('Click or drag files here to upload')}</p>
|
||||
</Upload.Dragger>
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={ctx.t('Describe the task for the AI employee')}
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
disabled={uploading || !prompt.trim()}
|
||||
onClick={sendTask}
|
||||
>
|
||||
{ctx.t('Send to AI')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
ctx.render(<AttachmentTask />);
|
||||
```
|
||||
|
||||
With `autoSend: false`, the attachments and task instructions are placed in the AI chat draft instead of being sent immediately.
|
||||
|
||||
### Add Page Block Context
|
||||
|
||||
`message.workContext` currently passes page blocks. Put the FlowModel uid of the target page block into it:
|
||||
@@ -163,9 +341,15 @@ ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTa
|
||||
| `taskIndex` | `number` | Task index, starting from `0`. |
|
||||
| `options.open` | `boolean` | Whether to open the AI employee conversation panel. |
|
||||
| `options.auto` | `boolean` | Whether to use the auto-trigger semantics of an AI employee action. |
|
||||
| `options.attachments` | `Attachment[]` | Attachments dynamically appended to the configured task. |
|
||||
| `options.onResponseLoadingChange` | `(loading: boolean) => void` | Model response loading callback. It only runs when the configured task is sent automatically. |
|
||||
|
||||
This method reads the AI employee and task configuration from the target model. It is useful when the task has already been configured on an AI employee action on the page, and RunJS only needs to trigger it.
|
||||
|
||||
The public `triggerModelTask()` options do not accept `chatBoxUid`. To target an AI Chat Box, configure `chatBoxUid` on the preset task of the AI employee action. `triggerModelTask()` continues to reuse that preset value.
|
||||
|
||||
`options.onResponseLoadingChange` behaves the same as the option on `triggerTask()`. Whether it runs depends on the configured task's `autoSend` value. It does not run when that task uses `autoSend: false`.
|
||||
|
||||
```ts
|
||||
if (!ctx.ai?.triggerModelTask) {
|
||||
ctx.message.error(ctx.t('AI employee task API is not available.'));
|
||||
@@ -176,6 +360,7 @@ const weeklyReviewActionUid = 'AI_EMPLOYEE_ACTION_MODEL_UID';
|
||||
|
||||
ctx.ai.triggerModelTask(weeklyReviewActionUid, 0, {
|
||||
open: true,
|
||||
attachments,
|
||||
});
|
||||
|
||||
ctx.message.success(ctx.t('Configured AI employee task triggered.'));
|
||||
@@ -195,9 +380,14 @@ If the target model does not exist, has no AI employee configured, or the specif
|
||||
## Notes
|
||||
|
||||
- `triggerTask()` and `triggerModelTask()` are fire-and-forget. They do not return the execution result of the AI employee task.
|
||||
- `uploadFile()` returns a Promise. Wait for the upload to finish before triggering a task that uses the attachment.
|
||||
- `aiEmployee` strings only match `AIEmployee.username` exactly. They do not match nicknames, job titles, or translated names.
|
||||
- `triggerModelTask()` uses a `0`-based `taskIndex`.
|
||||
- `triggerModelTask()` reads task configuration from the target AI employee action model. If the task needs work context, configure `message.workContext` on that task.
|
||||
- Top-level `triggerTask().chatBoxUid` must reference an AI Chat Box block currently mounted on the page.
|
||||
- `triggerModelTask()` keeps using `chatBoxUid` configured on its preset task.
|
||||
- Dynamic `triggerModelTask()` attachments are appended to the preset task's existing `message.attachments` without changing the saved task configuration.
|
||||
- `onResponseLoadingChange` only tracks a model response automatically sent by the current call. It does not track a message the user sends manually later.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -80,6 +80,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Bloques de página",
|
||||
"items": [
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Empleados de IA incorporados",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
pkg: '@nocobase/plugin-ai'
|
||||
title: 'Bloque AI Chat box'
|
||||
description: 'Guía para administradores y creadores de páginas de NocoBase sobre cómo añadir un bloque AI Chat box, configurar las capacidades de conversación, definir Work context, gestionar conversaciones y añadir Actions.'
|
||||
keywords: 'AI Chat box,Empleado de IA,bloque de página,Work context,Scope,Actions,NocoBase'
|
||||
---
|
||||
|
||||
# Bloque AI Chat box
|
||||
|
||||
En NocoBase, **AI Chat box** es un bloque de conversación con IA que puede añadirse directamente a una página. Puedes colocarlo en una página de negocio para ofrecer un punto de acceso fijo a un asistente de IA específico para esa página.
|
||||
|
||||
Cada bloque AI Chat box mantiene de forma independiente la conversación actual y el estado de entrada. Los creadores de páginas también pueden limitar los empleados de IA, modelos, carga de archivos, búsqueda web y contexto de trabajo disponibles según el escenario de negocio.
|
||||
|
||||
:::tip Antes de empezar
|
||||
|
||||
Primero [configura un servicio LLM](../features/llm-service.md) y [habilita al menos un empleado de IA](../features/enable-ai-employee.md).
|
||||
|
||||
:::
|
||||
|
||||
## Añadir un bloque AI Chat box
|
||||
|
||||
1. Abre la página que deseas configurar.
|
||||
2. Haz clic en `UI Editor` en la esquina superior derecha para entrar en el modo de edición de la página.
|
||||
3. Haz clic en `Add block`.
|
||||
4. En `Other blocks`, selecciona `AI chat box`.
|
||||
|
||||

|
||||
|
||||
## Estructura del bloque
|
||||
|
||||

|
||||
|
||||
AI Chat box se divide en tres áreas de arriba abajo:
|
||||
|
||||
- **Área de acciones superior** — acceso a la lista de conversaciones, Actions, acciones personalizadas y nueva conversación; cuando se ocultan los mensajes también aparece el botón de mensajes
|
||||
- **Área de mensajes** — muestra los mensajes del borrador o la conversación actual
|
||||
- **Área de envío** — cuadro de entrada, selección de contexto, carga de archivos, búsqueda web, selección de empleado de IA, selección de modelo, botón de envío y aviso legal
|
||||
|
||||
### Añadir contenido dentro del body del bloque
|
||||
|
||||
En el modo de edición de la página, haz clic en `Add block` dentro de AI Chat box para añadir cualquiera de estos bloques encima del área de chat:
|
||||
|
||||
- JS block
|
||||
- Iframe
|
||||
- Markdown
|
||||
|
||||
Estos bloques sirven para mostrar instrucciones, páginas externas o información complementaria. El menú interno solo ofrece estos tres tipos de bloque y no permite anidar otro AI Chat box.
|
||||
|
||||
## Configurar AI Chat box
|
||||
|
||||
Coloca el puntero sobre el bloque y abre su menú de configuración. Haz clic en `Edit chat box` para configurar el alcance de las conversaciones, el mensaje predeterminado, Work context, los empleados de IA y los modelos.
|
||||
|
||||

|
||||
|
||||
### Configuración de Edit chat box
|
||||
|
||||
| Opción | Descripción |
|
||||
| --- | --- |
|
||||
| `Scope` | Controla qué AI Chat box comparten una lista de conversaciones. Los bloques nuevos utilizan su propio UID de bloque de forma predeterminada para mantener las conversaciones separadas. |
|
||||
| `Background` | Añade un prompt del sistema después de la definición del empleado de IA para indicar la función, el objetivo o los requisitos de respuesta de la página actual. |
|
||||
| `Default user message` | Rellena previamente el cuadro de envío con un mensaje de usuario predeterminado al iniciar una conversación nueva. |
|
||||
| `Work context` | Selecciona los bloques de página que se añadirán de forma predeterminada a un borrador nuevo. |
|
||||
| `AI employees` | Limita los empleados de IA de negocio que pueden seleccionarse en este bloque. Déjalo vacío para permitir todos los empleados de IA de negocio disponibles. |
|
||||
| `Models` | Limita los modelos que pueden seleccionarse en este bloque. Déjalo vacío para permitir todos los modelos disponibles. |
|
||||
|
||||
### Otros ajustes del bloque
|
||||
|
||||
| Opción | Descripción |
|
||||
| --- | --- |
|
||||
| `Show messages` | Controla si el área de mensajes se muestra directamente en el bloque. Al desactivarlo, utiliza el botón de mensajes de la parte superior para abrir el panel derecho. |
|
||||
| `Sender placeholder` | Cambia el texto de marcador de posición del cuadro de envío. |
|
||||
| `Enable add context` | Muestra u oculta la entrada de selección de contexto del cuadro de envío. |
|
||||
| `Enable upload files` | Muestra u oculta la entrada de carga de archivos. Al desactivarlo, pegar un archivo tampoco inicia la carga. |
|
||||
| `Enable web search` | Muestra u oculta el interruptor de búsqueda web. Al desactivarlo también se apaga la búsqueda web del borrador actual. |
|
||||
| `Enable employee select` | Muestra u oculta el selector de empleados de IA. |
|
||||
| `Enable model select` | Muestra u oculta el selector de modelos. |
|
||||
| `Show disclaimer` | Muestra u oculta el aviso de IA debajo del cuadro de envío. |
|
||||
|
||||
## Configurar Work context
|
||||
|
||||
En `Work context`, dentro de `Edit chat box`, haz clic en el botón para añadir contexto, selecciona `Pick block` y elige el bloque de página que deseas proporcionar a la IA. Después de guardar, el bloque seleccionado se utilizará como contexto de trabajo predeterminado para las conversaciones nuevas y podrás quitarlo desde el área de envío antes de enviar el mensaje.
|
||||
|
||||
## Ocultar los mensajes y utilizar el panel derecho
|
||||
|
||||
Al desactivar `Show messages`, el cuerpo del bloque solo conserva el área de envío. Aparecerá un botón de mensajes en la parte superior; haz clic en él para abrir el panel de mensajes desde la derecha.
|
||||
|
||||

|
||||
|
||||
Cuando el panel está abierto, el resto del bloque queda cubierto por una capa. Haz clic en la capa o vuelve a hacer clic en el botón de mensajes para cerrar el panel.
|
||||
|
||||
Este diseño funciona bien cuando AI Chat box se utiliza como una entrada ligera en una página: normalmente solo se muestra el cuadro de envío y se abre el panel cuando es necesario consultar los mensajes.
|
||||
|
||||
## Gestionar el historial de conversaciones
|
||||
|
||||
Haz clic en el botón de lista de conversaciones de la esquina superior izquierda del bloque para consultar el historial correspondiente al Scope actual.
|
||||
|
||||
Ten en cuenta estas reglas:
|
||||
|
||||
- Varios AI Chat box con el mismo Scope pueden ver la misma lista de conversaciones
|
||||
- Cada bloque mantiene de forma independiente la conversación actual, el borrador del cuadro de envío, el empleado de IA, el modelo, los archivos adjuntos y el estado del contexto
|
||||
- El chatbox flotante global no filtra por el Scope del bloque, por lo que no oculta las conversaciones que tienen Scope
|
||||
- Al borrar Scope, el bloque deja de filtrar la lista por Scope y muestra tanto las conversaciones sin Scope como las que utilizan otros Scopes
|
||||
|
||||
Normalmente, basta con conservar el Scope generado para un bloque nuevo para separar el historial de cada asistente de página. Configura el mismo Scope solo cuando varios bloques necesiten compartir la misma lista de conversaciones.
|
||||
|
||||
## Añadir Actions
|
||||
|
||||
En el modo de edición de la página, haz clic en `Actions` en la parte superior del bloque para añadir una de estas acciones:
|
||||
|
||||
- JS Action
|
||||
- AI employee
|
||||
|
||||
Después de añadir un AI employee, puedes configurar tareas rápidas para ese empleado.
|
||||
|
||||
El ajuste `Chat box uid` de una tarea rápida especifica en qué AI Chat box se ejecutará la tarea. Un AI employee añadido directamente dentro de un AI Chat box apunta de forma predeterminada al UID del bloque actual.
|
||||
|
||||
Si el AI Chat box especificado no está montado, NocoBase indica que no puede encontrar el bloque de destino y no utiliza el chatbox flotante global como alternativa. Consulta [Tareas rápidas de empleados de IA](../features/task.md) para ver la configuración detallada.
|
||||
|
||||
## Configurar un asistente específico para una página
|
||||
|
||||
Los siguientes pasos permiten crear un asistente de IA ligero para una página:
|
||||
|
||||
1. Añade un bloque AI Chat box y muévelo a la posición adecuada de la página.
|
||||
2. Introduce un Background específico para la página en `Edit chat box`.
|
||||
3. Selecciona uno o varios Work contexts.
|
||||
4. Limita los empleados y modelos disponibles en `AI employees` y `Models`.
|
||||
5. Sal del modo de edición, escribe una pregunta y envíala.
|
||||
|
||||
## Notas
|
||||
|
||||
- El bloque AI Chat box y el chatbox flotante global de la esquina inferior derecha son puntos de acceso independientes; la conversación actual y el estado de entrada no se sincronizan automáticamente
|
||||
- Dentro de un AI Chat box, `Add block` solo permite añadir JS block, Iframe y Markdown
|
||||
- Cambiar Scope afecta al alcance de consulta de la lista de conversaciones y no copia la conversación o el borrador abierto actualmente en otro bloque
|
||||
@@ -123,6 +123,11 @@
|
||||
"label": "Panel de operaciones",
|
||||
"link": "/interface-builder/blocks/other-blocks/action-panel"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Iframe",
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
---
|
||||
title: "ctx.ai"
|
||||
description: "Use ctx.ai en RunJS para activar tareas de empleados de IA, pasando el contenido de la tarea directamente o reutilizando tareas configuradas en una acción de empleado de IA."
|
||||
keywords: "ctx.ai,AI employee,triggerTask,triggerModelTask,RunJS,NocoBase"
|
||||
description: "Use ctx.ai en RunJS para activar tareas de empleados de IA en la conversación global o en un AI Chat Box específico, pasando el contenido directamente o reutilizando tareas configuradas en una acción de empleado de IA."
|
||||
keywords: "ctx.ai,AI employee,uploadFile,attachments,triggerTask,triggerModelTask,onResponseLoadingChange,chatBoxUid,AI Chat Box,RunJS,NocoBase"
|
||||
---
|
||||
|
||||
# ctx.ai
|
||||
|
||||
Use `ctx.ai` en RunJS para activar **tareas de empleados de IA**. Es útil en JSBlock, JSAction y otras interacciones donde un botón, formulario o flujo de negocio necesita enviar trabajo a un empleado de IA específico.
|
||||
|
||||
`ctx.ai` solo activa tareas. No devuelve el resultado de ejecución de la tarea. Después de la llamada, la tarea entra en el flujo de conversación del empleado de IA.
|
||||
`ctx.ai` carga archivos adjuntos para tareas de IA y activa tareas. Las cargas de archivos se pueden esperar, pero la activación de una tarea no devuelve su resultado de ejecución. Después de la llamada, la tarea entra en el flujo de conversación del empleado de IA.
|
||||
|
||||
:::warning Nota
|
||||
|
||||
`ctx.ai` lo proporciona el plugin de IA. Si el plugin no está habilitado, o el entorno RunJS actual no ha cargado la capacidad de cliente correspondiente, `ctx.ai` puede no existir. Puede comprobar `ctx.ai?.triggerTask` o `ctx.ai?.triggerModelTask` antes de llamarlo.
|
||||
`ctx.ai` lo proporciona el plugin de IA. Si el plugin no está habilitado, o el entorno RunJS actual no ha cargado la capacidad de cliente correspondiente, `ctx.ai` puede no existir. Puede comprobar `ctx.ai?.uploadFile`, `ctx.ai?.triggerTask` o `ctx.ai?.triggerModelTask` antes de llamarlo.
|
||||
|
||||
:::
|
||||
|
||||
## Métodos
|
||||
|
||||
### ctx.ai.uploadFile()
|
||||
|
||||
Carga un archivo y devuelve un objeto adjunto que se puede pasar directamente a una tarea de empleado de IA.
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, options);
|
||||
```
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
|------|------|------|
|
||||
| `file` | `File` | Objeto de archivo del navegador que se va a cargar. |
|
||||
| `options.onProgress` | `(percent: number) => void` | Callback del progreso de carga. `percent` va de `0` a `100`. |
|
||||
| `options.signal` | `AbortSignal` | Señal para cancelar la carga. |
|
||||
|
||||
La carga usa el almacenamiento de archivos configurado por el plugin de IA y crea un registro en `aiFiles`. El objeto devuelto incluye campos como `id`, `filename`, `url` y `source`:
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
console.log('upload progress', percent);
|
||||
},
|
||||
});
|
||||
|
||||
// attachment se puede colocar directamente en message.attachments
|
||||
```
|
||||
|
||||
El Promise se rechaza cuando falla la carga. Quitar un archivo adjunto de la lista local no elimina el registro ya creado en `aiFiles`, igual que en la ventana de chat de IA predeterminada.
|
||||
|
||||
### ctx.ai.triggerTask()
|
||||
|
||||
Activa directamente una tarea de empleado de IA.
|
||||
@@ -30,8 +58,10 @@ ctx.ai.triggerTask(options: TriggerTaskOptions): void
|
||||
|------|------|------|
|
||||
| `aiEmployee` | `string \| AIEmployee` | Empleado de IA. Si se pasa una cadena, se compara exactamente con `AIEmployee.username`, y debe ser accesible para el usuario actual. |
|
||||
| `tasks` | `Task[]` | Lista de tareas que se van a activar. |
|
||||
| `chatBoxUid` | `string` | uid de FlowModel del bloque AI Chat Box que debe recibir la tarea. |
|
||||
| `open` | `boolean` | Si se abre el panel de conversación del empleado de IA. |
|
||||
| `auto` | `boolean` | Si se usa la semántica de activación automática de una acción de empleado de IA. |
|
||||
| `onResponseLoadingChange` | `(loading: boolean) => void` | Callback del estado de carga de la respuesta. Solo se ejecuta cuando esta tarea se envía automáticamente. |
|
||||
|
||||
Campos comunes de `Task`:
|
||||
|
||||
@@ -40,12 +70,160 @@ Campos comunes de `Task`:
|
||||
| `title` | `string` | Título de la tarea. |
|
||||
| `message.system` | `string` | Mensaje de sistema para limitar el rol y los requisitos de salida del empleado de IA. |
|
||||
| `message.user` | `string` | Mensaje de usuario, es decir, la instrucción principal de esta tarea. |
|
||||
| `message.attachments` | `Attachment[]` | Archivos adjuntos usados por la tarea, normalmente devueltos por `ctx.ai.uploadFile()`. |
|
||||
| `message.workContext` | `ContextItem[]` | Contexto de bloques de página usado por la tarea. |
|
||||
| `autoSend` | `boolean` | Si el mensaje de la tarea se envía automáticamente. |
|
||||
| `webSearch` | `boolean` | Si esta tarea puede usar Web search. |
|
||||
| `model` | `{ llmService: string; model: string } \| null` | Modelo usado por esta tarea. |
|
||||
| `skillSettings` | `SkillSettings` | Configuración de skills / tools usada por esta tarea. |
|
||||
|
||||
### Seguir el estado de carga de la respuesta
|
||||
|
||||
Pase `onResponseLoadingChange` en las opciones de nivel superior para seguir el estado de carga de la respuesta del modelo. El callback recibe `true` cuando NocoBase empieza a esperar la respuesta y `false` cuando termina, se cancela o falla. Si el componente React ya declaró `setResponseLoading` mediante `useState`, puede escribir:
|
||||
|
||||
```tsx
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
onResponseLoadingChange(loading) {
|
||||
setResponseLoading(loading);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`onResponseLoadingChange` solo sigue la respuesta iniciada directamente por esta llamada a `triggerTask()`. Con `autoSend: false`, la tarea queda en el borrador del chat y el callback no se ejecuta. Si el usuario envía el borrador más tarde, ese envío manual no reutiliza el callback.
|
||||
|
||||
En un componente React de un bloque JS, esta actualización vuelve a renderizar el componente mientras permanezca montado.
|
||||
|
||||
### Seleccionar un AI Chat Box
|
||||
|
||||
Defina `chatBoxUid` en las opciones de nivel superior de `triggerTask()` para activar la tarea en un bloque AI Chat Box montado, en lugar de abrir el diálogo global del empleado de IA.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
chatBoxUid: 'AI_CHAT_BOX_BLOCK_UID',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
El uid debe pertenecer al bloque AI Chat Box exterior que esté montado en la página actual. No coloque este valor de enrutamiento dentro de `tasks`. Si no se encuentra el bloque de destino, NocoBase muestra un error y no vuelve al diálogo global. Si se omite `chatBoxUid`, la tarea usa el diálogo global del empleado de IA.
|
||||
|
||||
### Cargar y enviar archivos adjuntos en JSBlock
|
||||
|
||||
El siguiente ejemplo renderiza la carga de archivos, las instrucciones de la tarea y un botón de envío en JSBlock. Los archivos cargados se pasan al empleado de IA mediante `message.attachments`:
|
||||
|
||||
```tsx
|
||||
if (!ctx.ai?.uploadFile || !ctx.ai?.triggerTask) {
|
||||
ctx.message.error(ctx.t('AI employee task API is not available.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { React } = ctx.libs;
|
||||
const { useState } = React;
|
||||
const { Button, Card, Input, Space, Upload } = ctx.libs.antd;
|
||||
const { InboxOutlined, SendOutlined } = ctx.libs.antdIcons;
|
||||
|
||||
const AttachmentTask = () => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [fileList, setFileList] = useState([]);
|
||||
|
||||
const uploadAttachment = async ({ file, onError, onProgress, onSuccess }) => {
|
||||
try {
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
onProgress?.({ percent });
|
||||
},
|
||||
});
|
||||
onSuccess?.(attachment);
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error : new Error(ctx.t('File upload failed')));
|
||||
}
|
||||
};
|
||||
|
||||
const sendTask = () => {
|
||||
const attachments = fileList
|
||||
.filter((file) => file.status === 'done' && file.response)
|
||||
.map((file) => file.response);
|
||||
|
||||
if (!prompt.trim()) {
|
||||
ctx.message.warning(ctx.t('Enter task instructions'));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'viz',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Analyze uploaded files'),
|
||||
message: {
|
||||
user: prompt.trim(),
|
||||
attachments,
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
setPrompt('');
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const uploading = fileList.some((file) => file.status === 'uploading');
|
||||
|
||||
return (
|
||||
<Card title={ctx.t('AI file analysis')}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
fileList={fileList}
|
||||
customRequest={uploadAttachment}
|
||||
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p>{ctx.t('Click or drag files here to upload')}</p>
|
||||
</Upload.Dragger>
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={ctx.t('Describe the task for the AI employee')}
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
disabled={uploading || !prompt.trim()}
|
||||
onClick={sendTask}
|
||||
>
|
||||
{ctx.t('Send to AI')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
ctx.render(<AttachmentTask />);
|
||||
```
|
||||
|
||||
Con `autoSend: false`, los archivos adjuntos y las instrucciones se colocan en el borrador del chat de IA y no se envían inmediatamente.
|
||||
|
||||
### Agregar contexto de bloques de página
|
||||
|
||||
`message.workContext` se usa actualmente para pasar bloques de página. Coloque ahí el uid de FlowModel del bloque de destino:
|
||||
@@ -151,6 +329,8 @@ Si `aiEmployee` es una cadena, NocoBase busca por coincidencia exacta de `userna
|
||||
|
||||
Lee una tarea desde un modelo de acción de empleado de IA en la página y la activa.
|
||||
|
||||
Las opciones públicas de `triggerModelTask()` no aceptan `chatBoxUid`. Para seleccionar un AI Chat Box, configure `chatBoxUid` en la tarea predefinida de la acción de empleado de IA. `triggerModelTask()` seguirá reutilizando ese valor predefinido.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTaskOptions): void
|
||||
```
|
||||
@@ -161,6 +341,10 @@ ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTa
|
||||
| `taskIndex` | `number` | Índice de la tarea, empezando desde `0`. |
|
||||
| `options.open` | `boolean` | Si se abre el panel de conversación del empleado de IA. |
|
||||
| `options.auto` | `boolean` | Si se usa la semántica de activación automática de una acción de empleado de IA. |
|
||||
| `options.attachments` | `Attachment[]` | Archivos adjuntos que se agregan dinámicamente a la tarea configurada. |
|
||||
| `options.onResponseLoadingChange` | `(loading: boolean) => void` | Callback del estado de carga de la respuesta. Solo se ejecuta cuando la tarea configurada se envía automáticamente. |
|
||||
|
||||
`options.onResponseLoadingChange` se comporta igual que en `triggerTask()`. Su ejecución depende del valor `autoSend` de la tarea configurada. No se ejecuta cuando la tarea usa `autoSend: false`.
|
||||
|
||||
```ts
|
||||
if (!ctx.ai?.triggerModelTask) {
|
||||
@@ -172,6 +356,7 @@ const weeklyReviewActionUid = 'AI_EMPLOYEE_ACTION_MODEL_UID';
|
||||
|
||||
ctx.ai.triggerModelTask(weeklyReviewActionUid, 0, {
|
||||
open: true,
|
||||
attachments,
|
||||
});
|
||||
|
||||
ctx.message.success(ctx.t('Configured AI employee task triggered.'));
|
||||
@@ -182,9 +367,14 @@ Si el modelo de destino no existe, no tiene empleado de IA configurado, o el ín
|
||||
## Notas
|
||||
|
||||
- `triggerTask()` y `triggerModelTask()` son fire-and-forget. No devuelven el resultado de ejecución de la tarea.
|
||||
- `uploadFile()` devuelve un Promise. Espere a que termine la carga antes de activar una tarea que use el archivo adjunto.
|
||||
- Las cadenas de `aiEmployee` solo coinciden exactamente con `AIEmployee.username`.
|
||||
- `triggerModelTask()` usa `taskIndex` empezando desde `0`.
|
||||
- `message.workContext` actualmente solo describe contexto de bloques de página.
|
||||
- El valor de nivel superior `triggerTask().chatBoxUid` debe hacer referencia a un bloque AI Chat Box montado en la página actual.
|
||||
- `triggerModelTask()` sigue usando el `chatBoxUid` configurado en su tarea predefinida.
|
||||
- Los archivos adjuntos dinámicos de `triggerModelTask()` se agregan a los `message.attachments` existentes de la tarea predefinida sin cambiar la configuración guardada.
|
||||
- `onResponseLoadingChange` solo sigue una respuesta enviada automáticamente por la llamada actual. No sigue un mensaje que el usuario envíe manualmente después.
|
||||
|
||||
## Relacionado
|
||||
|
||||
|
||||
@@ -80,6 +80,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Blocs de page",
|
||||
"items": [
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Employés AI intégrés",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
pkg: '@nocobase/plugin-ai'
|
||||
title: 'Bloc AI Chat box'
|
||||
description: 'Guide destiné aux administrateurs et aux créateurs de pages NocoBase pour ajouter un bloc AI Chat box, configurer les fonctions de conversation, définir le Work context, gérer les conversations et ajouter des Actions.'
|
||||
keywords: 'AI Chat box,Employé AI,bloc de page,Work context,Scope,Actions,NocoBase'
|
||||
---
|
||||
|
||||
# Bloc AI Chat box
|
||||
|
||||
Dans NocoBase, **AI Chat box** est un bloc de conversation AI qui peut être ajouté directement à une page. Vous pouvez le placer sur une page métier afin de fournir un point d'accès fixe à un assistant AI dédié à cette page.
|
||||
|
||||
Chaque bloc AI Chat box conserve son propre état de conversation et de saisie. Les créateurs de pages peuvent également limiter les employés AI, les modèles, le téléversement de fichiers, la recherche web et le contexte de travail disponibles pour adapter le bloc au scénario métier.
|
||||
|
||||
:::tip Avant de commencer
|
||||
|
||||
Commencez par [configurer un service LLM](../features/llm-service.md) et [activer au moins un employé AI](../features/enable-ai-employee.md).
|
||||
|
||||
:::
|
||||
|
||||
## Ajouter un bloc AI Chat box
|
||||
|
||||
1. Ouvrez la page à configurer.
|
||||
2. Cliquez sur `UI Editor` en haut à droite pour passer en mode d'édition de page.
|
||||
3. Cliquez sur `Add block`.
|
||||
4. Dans `Other blocks`, sélectionnez `AI chat box`.
|
||||
|
||||

|
||||
|
||||
## Structure du bloc
|
||||
|
||||

|
||||
|
||||
AI Chat box comprend trois zones, de haut en bas :
|
||||
|
||||
- **Zone d'actions supérieure** — accès à la liste des conversations, Actions, actions personnalisées et nouvelle conversation ; lorsque la zone de messages est masquée, un bouton de messages apparaît également
|
||||
- **Zone de messages** — affiche les messages du brouillon ou de la conversation en cours
|
||||
- **Zone d'envoi** — champ de saisie, sélection du contexte, téléversement de fichiers, recherche web, sélection de l'employé AI, sélection du modèle, bouton d'envoi et avertissement
|
||||
|
||||
### Ajouter du contenu dans le body du bloc
|
||||
|
||||
En mode d'édition de page, cliquez sur `Add block` dans AI Chat box pour ajouter l'un des blocs suivants au-dessus de la zone de chat :
|
||||
|
||||
- JS block
|
||||
- Iframe
|
||||
- Markdown
|
||||
|
||||
Ces blocs sont utiles pour afficher des instructions, des pages externes ou des informations complémentaires. Le menu interne propose uniquement ces trois types de blocs et ne permet pas d'imbriquer un autre AI Chat box.
|
||||
|
||||
## Configurer AI Chat box
|
||||
|
||||
Placez le pointeur sur le bloc et ouvrez son menu de configuration. Cliquez sur `Edit chat box` pour configurer la portée des conversations, le message par défaut, le Work context, les employés AI et les modèles.
|
||||
|
||||

|
||||
|
||||
### Paramètres Edit chat box
|
||||
|
||||
| Paramètre | Description |
|
||||
| --- | --- |
|
||||
| `Scope` | Contrôle les AI Chat boxes qui partagent une liste de conversations. Un nouveau bloc utilise par défaut son propre UID afin de séparer les conversations. |
|
||||
| `Background` | Ajoute un prompt système après la définition de l'employé AI afin de préciser le rôle, l'objectif ou les exigences de réponse de la page actuelle. |
|
||||
| `Default user message` | Préremplit la zone d'envoi avec un message utilisateur par défaut au début d'une nouvelle conversation. |
|
||||
| `Work context` | Sélectionne les blocs de page ajoutés par défaut à un nouveau brouillon. |
|
||||
| `AI employees` | Limite les employés AI métier sélectionnables dans ce bloc. Laissez ce champ vide pour autoriser tous les employés AI métier disponibles. |
|
||||
| `Models` | Limite les modèles sélectionnables dans ce bloc. Laissez ce champ vide pour autoriser tous les modèles disponibles. |
|
||||
|
||||
### Autres paramètres du bloc
|
||||
|
||||
| Paramètre | Description |
|
||||
| --- | --- |
|
||||
| `Show messages` | Contrôle l'affichage direct de la zone de messages dans le bloc. Lorsque cette option est désactivée, utilisez le bouton de messages en haut pour ouvrir le panneau droit. |
|
||||
| `Sender placeholder` | Modifie le texte indicatif de la zone d'envoi. |
|
||||
| `Enable add context` | Affiche ou masque l'accès à la sélection du contexte dans la zone d'envoi. |
|
||||
| `Enable upload files` | Affiche ou masque l'accès au téléversement de fichiers. Lorsque cette option est désactivée, coller un fichier ne lance pas de téléversement. |
|
||||
| `Enable web search` | Affiche ou masque le commutateur de recherche web. La désactivation coupe également la recherche web du brouillon actuel. |
|
||||
| `Enable employee select` | Affiche ou masque le sélecteur d'employé AI. |
|
||||
| `Enable model select` | Affiche ou masque le sélecteur de modèle. |
|
||||
| `Show disclaimer` | Affiche ou masque l'avertissement AI sous la zone d'envoi. |
|
||||
|
||||
## Configurer le Work context
|
||||
|
||||
Dans `Work context` sous `Edit chat box`, cliquez sur le bouton d'ajout de contexte, sélectionnez `Pick block`, puis choisissez le bloc de page à fournir à l'AI. Après l'enregistrement, le bloc sélectionné devient le contexte de travail par défaut des nouvelles conversations et peut être retiré de la zone d'envoi avant l'envoi.
|
||||
|
||||
## Masquer les messages et utiliser le panneau droit
|
||||
|
||||
Après avoir désactivé `Show messages`, le corps du bloc conserve uniquement la zone d'envoi. Un bouton de messages apparaît en haut ; cliquez dessus pour ouvrir le panneau de messages depuis la droite.
|
||||
|
||||

|
||||
|
||||
Lorsque le panneau est ouvert, le reste du bloc est recouvert par une superposition. Cliquez sur la superposition ou de nouveau sur le bouton de messages pour fermer le panneau.
|
||||
|
||||
Cette disposition convient lorsque AI Chat box sert de point d'entrée léger sur une page : seule la zone d'envoi reste habituellement visible, et le panneau est ouvert pour consulter les messages.
|
||||
|
||||
## Gérer l'historique des conversations
|
||||
|
||||
Cliquez sur le bouton de liste des conversations en haut à gauche du bloc pour afficher l'historique correspondant au Scope actuel.
|
||||
|
||||
Tenez compte des règles suivantes :
|
||||
|
||||
- Plusieurs AI Chat boxes utilisant le même Scope peuvent afficher la même liste de conversations
|
||||
- Chaque bloc conserve indépendamment sa conversation actuelle, son brouillon, son employé AI, son modèle, ses pièces jointes et son état de contexte
|
||||
- Le chatbox flottant global ne filtre pas selon le Scope du bloc et ne masque donc pas les conversations qui possèdent un Scope
|
||||
- Lorsque Scope est vidé, le bloc ne filtre plus la liste selon Scope et affiche les conversations sans Scope ainsi que celles utilisant d'autres Scopes
|
||||
|
||||
En règle générale, conserver le Scope généré pour un nouveau bloc suffit à séparer l'historique de chaque assistant de page. Configurez le même Scope uniquement lorsque plusieurs blocs doivent partager la même liste de conversations.
|
||||
|
||||
## Ajouter des Actions
|
||||
|
||||
En mode d'édition de page, cliquez sur `Actions` en haut du bloc pour ajouter l'une des actions suivantes :
|
||||
|
||||
- JS Action
|
||||
- AI employee
|
||||
|
||||
Après avoir ajouté un AI employee, vous pouvez configurer des tâches rapides pour cet employé.
|
||||
|
||||
Le paramètre `Chat box uid` d'une tâche rapide indique dans quel AI Chat box la tâche doit s'exécuter. Un AI employee ajouté directement dans un AI Chat box pointe par défaut vers l'UID du bloc actuel.
|
||||
|
||||
Si le AI Chat box indiqué n'est pas monté, NocoBase signale que le bloc cible est introuvable et ne bascule pas vers le chatbox flottant global. Consultez [Tâches rapides des employés AI](../features/task.md) pour la configuration détaillée.
|
||||
|
||||
## Configurer un assistant propre à une page
|
||||
|
||||
Les étapes suivantes permettent de créer un assistant AI léger pour une page :
|
||||
|
||||
1. Ajoutez un bloc AI Chat box et déplacez-le vers la position appropriée sur la page.
|
||||
2. Saisissez un Background propre à la page dans `Edit chat box`.
|
||||
3. Sélectionnez un ou plusieurs Work contexts.
|
||||
4. Limitez les employés et les modèles disponibles dans `AI employees` et `Models`.
|
||||
5. Quittez le mode d'édition, saisissez une question et envoyez-la.
|
||||
|
||||
## Remarques
|
||||
|
||||
- Le bloc AI Chat box et le chatbox flottant global en bas à droite sont des points d'entrée distincts ; la conversation actuelle et l'état de saisie ne sont pas synchronisés automatiquement
|
||||
- Dans un AI Chat box, `Add block` permet uniquement d'ajouter JS block, Iframe et Markdown
|
||||
- La modification de Scope affecte la portée de la liste de conversations et ne copie pas la conversation ou le brouillon actuellement ouvert dans un autre bloc
|
||||
@@ -123,6 +123,11 @@
|
||||
"label": "Panneau d'action",
|
||||
"link": "/interface-builder/blocks/other-blocks/action-panel"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Iframe",
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
---
|
||||
title: "ctx.ai"
|
||||
description: "Utilisez ctx.ai dans RunJS pour déclencher des tâches d'employé IA, avec un contenu de tâche direct ou avec les tâches configurées sur une action d'employé IA."
|
||||
keywords: "ctx.ai,AI employee,triggerTask,triggerModelTask,RunJS,NocoBase"
|
||||
description: "Utilisez ctx.ai dans RunJS pour déclencher des tâches d'employé IA dans la conversation globale ou dans une AI Chat Box précise, avec un contenu direct ou avec les tâches configurées sur une action d'employé IA."
|
||||
keywords: "ctx.ai,AI employee,uploadFile,attachments,triggerTask,triggerModelTask,onResponseLoadingChange,chatBoxUid,AI Chat Box,RunJS,NocoBase"
|
||||
---
|
||||
|
||||
# ctx.ai
|
||||
|
||||
Dans RunJS, `ctx.ai` permet de déclencher des **tâches d'employé IA**. Il est utile dans JSBlock, JSAction et d'autres interactions où un bouton, un formulaire ou un flux métier doit confier un travail à un employé IA précis.
|
||||
|
||||
`ctx.ai` ne fait que déclencher des tâches. Il ne renvoie pas le résultat d'exécution de la tâche. Après l'appel, la tâche entre dans le flux de conversation de l'employé IA.
|
||||
`ctx.ai` charge les pièces jointes des tâches IA et déclenche les tâches. Le chargement d'un fichier peut être attendu, mais le déclenchement d'une tâche ne renvoie pas son résultat d'exécution. Après l'appel, la tâche entre dans le flux de conversation de l'employé IA.
|
||||
|
||||
:::warning Remarque
|
||||
|
||||
`ctx.ai` est fourni par le plugin IA. Si le plugin IA n'est pas activé, ou si l'environnement RunJS actuel n'a pas chargé la capacité cliente correspondante, `ctx.ai` peut ne pas exister. Vous pouvez vérifier `ctx.ai?.triggerTask` ou `ctx.ai?.triggerModelTask` avant l'appel.
|
||||
`ctx.ai` est fourni par le plugin IA. Si le plugin IA n'est pas activé, ou si l'environnement RunJS actuel n'a pas chargé la capacité cliente correspondante, `ctx.ai` peut ne pas exister. Vous pouvez vérifier `ctx.ai?.uploadFile`, `ctx.ai?.triggerTask` ou `ctx.ai?.triggerModelTask` avant l'appel.
|
||||
|
||||
:::
|
||||
|
||||
## Méthodes
|
||||
|
||||
### ctx.ai.uploadFile()
|
||||
|
||||
Charge un fichier et renvoie un objet de pièce jointe qui peut être transmis directement à une tâche d'employé IA.
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, options);
|
||||
```
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|------|------|------|
|
||||
| `file` | `File` | Objet fichier du navigateur à charger. |
|
||||
| `options.onProgress` | `(percent: number) => void` | Callback de progression du chargement. `percent` va de `0` à `100`. |
|
||||
| `options.signal` | `AbortSignal` | Signal utilisé pour annuler le chargement. |
|
||||
|
||||
Le chargement utilise le stockage de fichiers configuré par le plugin IA et crée un enregistrement dans `aiFiles`. L'objet renvoyé contient notamment `id`, `filename`, `url` et `source`:
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
console.log('upload progress', percent);
|
||||
},
|
||||
});
|
||||
|
||||
// attachment peut être placé directement dans message.attachments
|
||||
```
|
||||
|
||||
Le Promise est rejeté si le chargement échoue. Retirer une pièce jointe de la liste locale ne supprime pas l'enregistrement déjà créé dans `aiFiles`, comme dans la fenêtre de chat IA par défaut.
|
||||
|
||||
### ctx.ai.triggerTask()
|
||||
|
||||
Déclenche directement une tâche d'employé IA.
|
||||
@@ -30,8 +58,10 @@ ctx.ai.triggerTask(options: TriggerTaskOptions): void
|
||||
|------|------|------|
|
||||
| `aiEmployee` | `string \| AIEmployee` | Employé IA. Si une chaîne est fournie, NocoBase cherche une correspondance exacte avec `AIEmployee.username`, accessible à l'utilisateur actuel. |
|
||||
| `tasks` | `Task[]` | Liste des tâches à déclencher. |
|
||||
| `chatBoxUid` | `string` | uid FlowModel du bloc AI Chat Box qui doit recevoir la tâche. |
|
||||
| `open` | `boolean` | Indique s'il faut ouvrir le panneau de conversation de l'employé IA. |
|
||||
| `auto` | `boolean` | Indique s'il faut utiliser la sémantique de déclenchement automatique d'une action d'employé IA. |
|
||||
| `onResponseLoadingChange` | `(loading: boolean) => void` | Callback de l'état de chargement de la réponse. Il s'exécute uniquement lorsque cette tâche est envoyée automatiquement. |
|
||||
|
||||
Champs courants de `Task`:
|
||||
|
||||
@@ -40,12 +70,160 @@ Champs courants de `Task`:
|
||||
| `title` | `string` | Titre de la tâche. |
|
||||
| `message.system` | `string` | Message système qui contraint le rôle et les exigences de sortie de l'employé IA. |
|
||||
| `message.user` | `string` | Message utilisateur, c'est-à-dire l'instruction principale de la tâche. |
|
||||
| `message.attachments` | `Attachment[]` | Pièces jointes utilisées par la tâche, généralement renvoyées par `ctx.ai.uploadFile()`. |
|
||||
| `message.workContext` | `ContextItem[]` | Contexte de blocs de page utilisé par la tâche. |
|
||||
| `autoSend` | `boolean` | Indique si le message de tâche est envoyé automatiquement. |
|
||||
| `webSearch` | `boolean` | Indique si la tâche peut utiliser Web search. |
|
||||
| `model` | `{ llmService: string; model: string } \| null` | Modèle utilisé par cette tâche. |
|
||||
| `skillSettings` | `SkillSettings` | Configuration des skills / tools disponibles pour cette tâche. |
|
||||
|
||||
### Suivre l'état de chargement de la réponse
|
||||
|
||||
Passez `onResponseLoadingChange` dans les options de premier niveau pour suivre l'état de chargement de la réponse du modèle. Le callback reçoit `true` lorsque NocoBase commence à attendre la réponse, puis `false` lorsqu'elle se termine, est annulée ou échoue. Si le composant React a déjà déclaré `setResponseLoading` avec `useState`, vous pouvez écrire :
|
||||
|
||||
```tsx
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
onResponseLoadingChange(loading) {
|
||||
setResponseLoading(loading);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`onResponseLoadingChange` suit uniquement la réponse démarrée directement par cet appel à `triggerTask()`. Avec `autoSend: false`, la tâche reste dans le brouillon du chat et le callback ne s'exécute pas. Si l'utilisateur envoie le brouillon plus tard, cet envoi manuel ne réutilise pas le callback.
|
||||
|
||||
Dans un composant React d'un bloc JS, cette mise à jour provoque un nouveau rendu tant que le composant reste monté.
|
||||
|
||||
### Cibler une AI Chat Box
|
||||
|
||||
Définissez `chatBoxUid` dans les options de premier niveau de `triggerTask()` pour déclencher la tâche dans un bloc AI Chat Box monté, au lieu d'ouvrir la boîte de dialogue globale de l'employé IA.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
chatBoxUid: 'AI_CHAT_BOX_BLOCK_UID',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Le uid doit appartenir au bloc AI Chat Box externe actuellement monté sur la page. Ne placez pas cette valeur de routage dans `tasks`. Si le bloc cible est introuvable, NocoBase affiche une erreur et ne revient pas à la boîte de dialogue globale. Lorsque `chatBoxUid` est omis, la tâche utilise la boîte de dialogue globale de l'employé IA.
|
||||
|
||||
### Charger et envoyer des pièces jointes dans JSBlock
|
||||
|
||||
L'exemple suivant affiche dans JSBlock un chargement de fichiers, les instructions de la tâche et un bouton d'envoi. Les fichiers chargés sont transmis à l'employé IA via `message.attachments`:
|
||||
|
||||
```tsx
|
||||
if (!ctx.ai?.uploadFile || !ctx.ai?.triggerTask) {
|
||||
ctx.message.error(ctx.t('AI employee task API is not available.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { React } = ctx.libs;
|
||||
const { useState } = React;
|
||||
const { Button, Card, Input, Space, Upload } = ctx.libs.antd;
|
||||
const { InboxOutlined, SendOutlined } = ctx.libs.antdIcons;
|
||||
|
||||
const AttachmentTask = () => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [fileList, setFileList] = useState([]);
|
||||
|
||||
const uploadAttachment = async ({ file, onError, onProgress, onSuccess }) => {
|
||||
try {
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
onProgress?.({ percent });
|
||||
},
|
||||
});
|
||||
onSuccess?.(attachment);
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error : new Error(ctx.t('File upload failed')));
|
||||
}
|
||||
};
|
||||
|
||||
const sendTask = () => {
|
||||
const attachments = fileList
|
||||
.filter((file) => file.status === 'done' && file.response)
|
||||
.map((file) => file.response);
|
||||
|
||||
if (!prompt.trim()) {
|
||||
ctx.message.warning(ctx.t('Enter task instructions'));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'viz',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Analyze uploaded files'),
|
||||
message: {
|
||||
user: prompt.trim(),
|
||||
attachments,
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
setPrompt('');
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const uploading = fileList.some((file) => file.status === 'uploading');
|
||||
|
||||
return (
|
||||
<Card title={ctx.t('AI file analysis')}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
fileList={fileList}
|
||||
customRequest={uploadAttachment}
|
||||
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p>{ctx.t('Click or drag files here to upload')}</p>
|
||||
</Upload.Dragger>
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={ctx.t('Describe the task for the AI employee')}
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
disabled={uploading || !prompt.trim()}
|
||||
onClick={sendTask}
|
||||
>
|
||||
{ctx.t('Send to AI')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
ctx.render(<AttachmentTask />);
|
||||
```
|
||||
|
||||
Avec `autoSend: false`, les pièces jointes et les instructions sont placées dans le brouillon du chat IA et ne sont pas envoyées immédiatement.
|
||||
|
||||
### Ajouter le contexte de blocs de page
|
||||
|
||||
`message.workContext` sert actuellement à transmettre des blocs de page. Placez-y le uid FlowModel du bloc cible:
|
||||
@@ -151,6 +329,8 @@ Si `aiEmployee` est une chaîne, NocoBase recherche une correspondance exacte su
|
||||
|
||||
Lit une tâche depuis un modèle d'action d'employé IA sur la page et la déclenche.
|
||||
|
||||
Les options publiques de `triggerModelTask()` n'acceptent pas `chatBoxUid`. Pour cibler une AI Chat Box, configurez `chatBoxUid` sur la tâche prédéfinie de l'action d'employé IA. `triggerModelTask()` continue de réutiliser cette valeur prédéfinie.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTaskOptions): void
|
||||
```
|
||||
@@ -161,6 +341,10 @@ ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTa
|
||||
| `taskIndex` | `number` | Index de la tâche, à partir de `0`. |
|
||||
| `options.open` | `boolean` | Indique s'il faut ouvrir le panneau de conversation de l'employé IA. |
|
||||
| `options.auto` | `boolean` | Indique s'il faut utiliser la sémantique de déclenchement automatique d'une action d'employé IA. |
|
||||
| `options.attachments` | `Attachment[]` | Pièces jointes ajoutées dynamiquement à la tâche configurée. |
|
||||
| `options.onResponseLoadingChange` | `(loading: boolean) => void` | Callback de l'état de chargement de la réponse. Il s'exécute uniquement lorsque la tâche configurée est envoyée automatiquement. |
|
||||
|
||||
`options.onResponseLoadingChange` fonctionne comme dans `triggerTask()`. Son exécution dépend de la valeur `autoSend` de la tâche configurée. Il ne s'exécute pas lorsque la tâche utilise `autoSend: false`.
|
||||
|
||||
```ts
|
||||
if (!ctx.ai?.triggerModelTask) {
|
||||
@@ -172,6 +356,7 @@ const weeklyReviewActionUid = 'AI_EMPLOYEE_ACTION_MODEL_UID';
|
||||
|
||||
ctx.ai.triggerModelTask(weeklyReviewActionUid, 0, {
|
||||
open: true,
|
||||
attachments,
|
||||
});
|
||||
|
||||
ctx.message.success(ctx.t('Configured AI employee task triggered.'));
|
||||
@@ -182,9 +367,14 @@ Si le modèle cible n'existe pas, si aucun employé IA n'est configuré, ou si l
|
||||
## Notes
|
||||
|
||||
- `triggerTask()` et `triggerModelTask()` sont fire-and-forget. Ils ne renvoient pas le résultat d'exécution de la tâche.
|
||||
- `uploadFile()` renvoie un Promise. Attendez la fin du chargement avant de déclencher une tâche qui utilise la pièce jointe.
|
||||
- Les chaînes `aiEmployee` correspondent uniquement à `AIEmployee.username` de façon exacte.
|
||||
- `triggerModelTask()` utilise un `taskIndex` basé sur `0`.
|
||||
- `message.workContext` décrit actuellement uniquement le contexte de blocs de page.
|
||||
- La valeur de premier niveau `triggerTask().chatBoxUid` doit référencer un bloc AI Chat Box actuellement monté sur la page.
|
||||
- `triggerModelTask()` continue d'utiliser le `chatBoxUid` configuré sur sa tâche prédéfinie.
|
||||
- Les pièces jointes dynamiques de `triggerModelTask()` sont ajoutées aux `message.attachments` existants de la tâche prédéfinie sans modifier la configuration enregistrée.
|
||||
- `onResponseLoadingChange` suit uniquement une réponse envoyée automatiquement par l'appel actuel. Il ne suit pas un message envoyé manuellement plus tard par l'utilisateur.
|
||||
|
||||
## Liens associés
|
||||
|
||||
|
||||
@@ -80,6 +80,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Block Halaman",
|
||||
"items": [
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Karyawan AI Bawaan",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
pkg: '@nocobase/plugin-ai'
|
||||
title: 'Block AI Chat box'
|
||||
description: 'Panduan untuk administrator dan pembuat halaman NocoBase tentang cara menambahkan block AI Chat box, mengatur kemampuan percakapan, Work context, riwayat percakapan, dan Actions.'
|
||||
keywords: 'AI Chat box,Karyawan AI,block halaman,Work context,Scope,Actions,NocoBase'
|
||||
---
|
||||
|
||||
# Block AI Chat box
|
||||
|
||||
Di NocoBase, **AI Chat box** adalah block percakapan AI yang dapat ditambahkan langsung ke halaman. Block ini dapat ditempatkan pada halaman bisnis sebagai akses tetap ke asisten AI khusus untuk halaman tersebut.
|
||||
|
||||
Setiap block AI Chat box memiliki percakapan aktif dan status input sendiri. Pembuat halaman juga dapat membatasi Karyawan AI, model, upload file, pencarian web, dan konteks kerja yang tersedia agar sesuai dengan skenario bisnis.
|
||||
|
||||
:::tip Sebelum memulai
|
||||
|
||||
Pertama [konfigurasikan LLM Service](../features/llm-service.md) dan [aktifkan setidaknya satu Karyawan AI](../features/enable-ai-employee.md).
|
||||
|
||||
:::
|
||||
|
||||
## Menambahkan block AI Chat box
|
||||
|
||||
1. Buka halaman yang ingin dikonfigurasi.
|
||||
2. Klik `UI Editor` di sudut kanan atas untuk masuk ke mode pengeditan halaman.
|
||||
3. Klik `Add block`.
|
||||
4. Pada `Other blocks`, pilih `AI chat box`.
|
||||
|
||||

|
||||
|
||||
## Struktur block
|
||||
|
||||

|
||||
|
||||
AI Chat box terdiri dari tiga area dari atas ke bawah:
|
||||
|
||||
- **Area tindakan atas** — akses daftar percakapan, Actions, tindakan khusus, dan percakapan baru; tombol pesan juga muncul saat area pesan disembunyikan
|
||||
- **Area pesan** — menampilkan pesan dalam draf atau percakapan aktif
|
||||
- **Area pengiriman** — kotak input, pemilihan konteks, upload file, pencarian web, pemilihan Karyawan AI, pemilihan model, tombol kirim, dan disclaimer
|
||||
|
||||
### Menambahkan konten ke body block
|
||||
|
||||
Dalam mode pengeditan halaman, klik `Add block` di dalam AI Chat box untuk menambahkan salah satu block berikut di atas area chat:
|
||||
|
||||
- JS block
|
||||
- Iframe
|
||||
- Markdown
|
||||
|
||||
Block tersebut dapat digunakan untuk menampilkan petunjuk, halaman eksternal, atau informasi pendukung. Menu internal hanya menyediakan tiga jenis block ini dan tidak mengizinkan AI Chat box lain ditambahkan di dalamnya.
|
||||
|
||||
## Mengonfigurasi AI Chat box
|
||||
|
||||
Arahkan pointer ke block dan buka menu pengaturannya. Klik `Edit chat box` untuk mengatur cakupan percakapan, pesan default, Work context, Karyawan AI, dan model.
|
||||
|
||||

|
||||
|
||||
### Pengaturan Edit chat box
|
||||
|
||||
| Pengaturan | Deskripsi |
|
||||
| --- | --- |
|
||||
| `Scope` | Mengatur AI Chat box mana yang berbagi daftar percakapan. Block baru menggunakan UID block sendiri secara default agar percakapan tetap terpisah. |
|
||||
| `Background` | Menambahkan system prompt setelah definisi Karyawan AI untuk memberikan peran, tujuan, atau persyaratan jawaban pada halaman aktif. |
|
||||
| `Default user message` | Mengisi area pengiriman dengan pesan pengguna default saat percakapan baru dimulai. |
|
||||
| `Work context` | Memilih block halaman yang dimasukkan secara default ke draf baru. |
|
||||
| `AI employees` | Membatasi Karyawan AI bisnis yang dapat dipilih pada block ini. Biarkan kosong untuk mengizinkan semua Karyawan AI bisnis yang tersedia. |
|
||||
| `Models` | Membatasi model yang dapat dipilih pada block ini. Biarkan kosong untuk mengizinkan semua model yang tersedia. |
|
||||
|
||||
### Pengaturan block lainnya
|
||||
|
||||
| Pengaturan | Deskripsi |
|
||||
| --- | --- |
|
||||
| `Show messages` | Mengatur apakah area pesan ditampilkan langsung di dalam block. Saat dinonaktifkan, gunakan tombol pesan di bagian atas untuk membuka panel kanan. |
|
||||
| `Sender placeholder` | Mengubah teks placeholder pada area pengiriman. |
|
||||
| `Enable add context` | Menampilkan atau menyembunyikan akses pemilihan konteks pada area pengiriman. |
|
||||
| `Enable upload files` | Menampilkan atau menyembunyikan akses upload file. Saat dinonaktifkan, menempelkan file juga tidak memulai upload. |
|
||||
| `Enable web search` | Menampilkan atau menyembunyikan switch pencarian web. Menonaktifkannya juga mematikan pencarian web untuk draf aktif. |
|
||||
| `Enable employee select` | Menampilkan atau menyembunyikan pemilih Karyawan AI. |
|
||||
| `Enable model select` | Menampilkan atau menyembunyikan pemilih model. |
|
||||
| `Show disclaimer` | Menampilkan atau menyembunyikan disclaimer AI di bawah area pengiriman. |
|
||||
|
||||
## Mengonfigurasi Work context
|
||||
|
||||
Pada `Work context` di dalam `Edit chat box`, klik tombol tambah konteks, pilih `Pick block`, lalu pilih block halaman yang ingin diberikan kepada AI. Setelah disimpan, block yang dipilih menjadi konteks kerja default untuk percakapan baru dan dapat dihapus dari area pengiriman sebelum pesan dikirim.
|
||||
|
||||
## Menyembunyikan pesan dan menggunakan panel kanan
|
||||
|
||||
Setelah `Show messages` dinonaktifkan, body block hanya menampilkan area pengiriman. Tombol pesan muncul di bagian atas; klik tombol tersebut untuk membuka panel pesan dari kanan.
|
||||
|
||||

|
||||
|
||||
Saat panel terbuka, bagian block lainnya tertutup overlay. Klik overlay atau klik lagi tombol pesan untuk menutup panel.
|
||||
|
||||
Layout ini cocok saat AI Chat box digunakan sebagai akses input ringan pada halaman: biasanya hanya area pengiriman yang ditampilkan, dan panel dibuka saat perlu melihat pesan.
|
||||
|
||||
## Mengelola riwayat percakapan
|
||||
|
||||
Klik tombol daftar percakapan di sudut kiri atas block untuk melihat riwayat pada Scope aktif.
|
||||
|
||||
Perhatikan aturan berikut:
|
||||
|
||||
- Beberapa AI Chat box dengan Scope yang sama dapat melihat daftar percakapan yang sama
|
||||
- Setiap block tetap memiliki percakapan aktif, draf area pengiriman, Karyawan AI, model, lampiran, dan status konteks sendiri
|
||||
- Chatbox floating global tidak memfilter berdasarkan Scope block, sehingga percakapan yang memiliki Scope tetap ditampilkan
|
||||
- Setelah Scope dikosongkan, block tidak lagi memfilter daftar berdasarkan Scope dan menampilkan percakapan tanpa Scope serta percakapan yang menggunakan Scope lain
|
||||
|
||||
Biasanya, mempertahankan Scope yang dibuat untuk block baru sudah cukup untuk memisahkan riwayat setiap asisten halaman. Gunakan Scope yang sama hanya jika beberapa block perlu berbagi daftar percakapan.
|
||||
|
||||
## Menambahkan Actions
|
||||
|
||||
Dalam mode pengeditan halaman, klik `Actions` di bagian atas block untuk menambahkan salah satu tindakan berikut:
|
||||
|
||||
- JS Action
|
||||
- AI employee
|
||||
|
||||
Setelah menambahkan AI employee, Anda dapat mengatur shortcut task untuk karyawan tersebut.
|
||||
|
||||
Pengaturan `Chat box uid` pada shortcut task menentukan AI Chat box tempat task dijalankan. AI employee yang ditambahkan langsung di dalam AI Chat box secara default menunjuk ke UID block aktif.
|
||||
|
||||
Jika AI Chat box yang ditentukan tidak terpasang, NocoBase memberi tahu bahwa block target tidak ditemukan dan tidak beralih ke chatbox floating global. Lihat [Shortcut task Karyawan AI](../features/task.md) untuk konfigurasi lebih lanjut.
|
||||
|
||||
## Mengonfigurasi asisten khusus halaman
|
||||
|
||||
Langkah berikut membuat asisten AI ringan untuk sebuah halaman:
|
||||
|
||||
1. Tambahkan block AI Chat box dan pindahkan ke posisi yang sesuai pada halaman.
|
||||
2. Masukkan Background khusus halaman di `Edit chat box`.
|
||||
3. Pilih satu atau beberapa Work contexts.
|
||||
4. Batasi karyawan dan model yang tersedia di `AI employees` dan `Models`.
|
||||
5. Keluar dari mode pengeditan, masukkan pertanyaan, lalu kirim.
|
||||
|
||||
## Catatan
|
||||
|
||||
- Block AI Chat box dan chatbox floating global di sudut kanan bawah merupakan akses yang terpisah; percakapan aktif dan status input tidak disinkronkan secara otomatis
|
||||
- Di dalam AI Chat box, `Add block` hanya dapat menambahkan JS block, Iframe, dan Markdown
|
||||
- Mengubah Scope memengaruhi cakupan kueri daftar percakapan dan tidak menyalin percakapan atau draf yang sedang terbuka pada block lain
|
||||
@@ -123,6 +123,11 @@
|
||||
"label": "Action Panel",
|
||||
"link": "/interface-builder/blocks/other-blocks/action-panel"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Iframe",
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
---
|
||||
title: "ctx.ai"
|
||||
description: "Gunakan ctx.ai di RunJS untuk memicu tugas karyawan AI, baik dengan isi tugas langsung maupun dengan tugas yang dikonfigurasi pada aksi karyawan AI."
|
||||
keywords: "ctx.ai,AI employee,triggerTask,triggerModelTask,RunJS,NocoBase"
|
||||
description: "Gunakan ctx.ai di RunJS untuk memicu tugas karyawan AI di percakapan global atau AI Chat Box tertentu, baik dengan isi tugas langsung maupun dengan tugas yang dikonfigurasi pada aksi karyawan AI."
|
||||
keywords: "ctx.ai,AI employee,uploadFile,attachments,triggerTask,triggerModelTask,onResponseLoadingChange,chatBoxUid,AI Chat Box,RunJS,NocoBase"
|
||||
---
|
||||
|
||||
# ctx.ai
|
||||
|
||||
Di RunJS, `ctx.ai` digunakan untuk memicu **tugas karyawan AI**. API ini cocok untuk JSBlock, JSAction, dan interaksi lain ketika tombol, formulir, atau alur bisnis perlu menyerahkan pekerjaan kepada karyawan AI tertentu.
|
||||
|
||||
`ctx.ai` hanya memicu tugas. API ini tidak mengembalikan hasil eksekusi tugas. Setelah dipanggil, tugas akan masuk ke alur percakapan karyawan AI.
|
||||
`ctx.ai` mengunggah lampiran untuk tugas AI dan memicu tugas. Unggahan file dapat ditunggu, tetapi pemicu tugas tidak mengembalikan hasil eksekusinya. Setelah dipanggil, tugas akan masuk ke alur percakapan karyawan AI.
|
||||
|
||||
:::warning Catatan
|
||||
|
||||
`ctx.ai` disediakan oleh plugin AI. Jika plugin AI belum diaktifkan, atau lingkungan RunJS saat ini belum memuat kemampuan client yang sesuai, `ctx.ai` mungkin tidak ada. Anda dapat memeriksa `ctx.ai?.triggerTask` atau `ctx.ai?.triggerModelTask` sebelum memanggilnya.
|
||||
`ctx.ai` disediakan oleh plugin AI. Jika plugin AI belum diaktifkan, atau lingkungan RunJS saat ini belum memuat kemampuan client yang sesuai, `ctx.ai` mungkin tidak ada. Anda dapat memeriksa `ctx.ai?.uploadFile`, `ctx.ai?.triggerTask`, atau `ctx.ai?.triggerModelTask` sebelum memanggilnya.
|
||||
|
||||
:::
|
||||
|
||||
## Metode
|
||||
|
||||
### ctx.ai.uploadFile()
|
||||
|
||||
Mengunggah satu file dan mengembalikan objek lampiran yang dapat langsung diteruskan ke tugas karyawan AI.
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, options);
|
||||
```
|
||||
|
||||
| Parameter | Tipe | Deskripsi |
|
||||
|------|------|------|
|
||||
| `file` | `File` | Objek file browser yang akan diunggah. |
|
||||
| `options.onProgress` | `(percent: number) => void` | Callback progres unggahan. `percent` berada dalam rentang `0` hingga `100`. |
|
||||
| `options.signal` | `AbortSignal` | Sinyal untuk membatalkan unggahan. |
|
||||
|
||||
Unggahan menggunakan penyimpanan file yang dikonfigurasi oleh plugin AI dan membuat record di `aiFiles`. Objek yang dikembalikan mencakup field seperti `id`, `filename`, `url`, dan `source`:
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
console.log('upload progress', percent);
|
||||
},
|
||||
});
|
||||
|
||||
// attachment dapat langsung ditempatkan di message.attachments
|
||||
```
|
||||
|
||||
Promise akan ditolak jika unggahan gagal. Menghapus lampiran dari daftar lokal tidak menghapus record yang sudah dibuat di `aiFiles`, sama seperti perilaku jendela chat AI default.
|
||||
|
||||
### ctx.ai.triggerTask()
|
||||
|
||||
Memicu tugas karyawan AI secara langsung.
|
||||
@@ -30,8 +58,10 @@ ctx.ai.triggerTask(options: TriggerTaskOptions): void
|
||||
|------|------|------|
|
||||
| `aiEmployee` | `string \| AIEmployee` | Karyawan AI. Jika string diberikan, NocoBase mencocokkan secara persis dengan `AIEmployee.username`, dan karyawan AI tersebut harus dapat diakses oleh pengguna saat ini. |
|
||||
| `tasks` | `Task[]` | Daftar tugas yang akan dipicu. |
|
||||
| `chatBoxUid` | `string` | uid FlowModel dari blok AI Chat Box yang akan menerima tugas. |
|
||||
| `open` | `boolean` | Apakah panel percakapan karyawan AI dibuka. |
|
||||
| `auto` | `boolean` | Apakah menggunakan semantik pemicu otomatis dari aksi karyawan AI. |
|
||||
| `onResponseLoadingChange` | `(loading: boolean) => void` | Callback status pemuatan respons model. Hanya dijalankan ketika tugas ini dikirim otomatis. |
|
||||
|
||||
Field umum pada `Task`:
|
||||
|
||||
@@ -40,12 +70,160 @@ Field umum pada `Task`:
|
||||
| `title` | `string` | Judul tugas. |
|
||||
| `message.system` | `string` | Pesan sistem untuk membatasi peran dan persyaratan keluaran karyawan AI. |
|
||||
| `message.user` | `string` | Pesan pengguna, yaitu instruksi utama tugas ini. |
|
||||
| `message.attachments` | `Attachment[]` | Lampiran yang digunakan oleh tugas, biasanya dikembalikan oleh `ctx.ai.uploadFile()`. |
|
||||
| `message.workContext` | `ContextItem[]` | Konteks blok halaman yang digunakan oleh tugas. |
|
||||
| `autoSend` | `boolean` | Apakah pesan tugas dikirim otomatis. |
|
||||
| `webSearch` | `boolean` | Apakah tugas ini boleh menggunakan Web search. |
|
||||
| `model` | `{ llmService: string; model: string } \| null` | Model yang digunakan oleh tugas ini. |
|
||||
| `skillSettings` | `SkillSettings` | Konfigurasi skills / tools yang digunakan tugas ini. |
|
||||
|
||||
### Melacak status pemuatan respons
|
||||
|
||||
Berikan `onResponseLoadingChange` pada opsi tingkat atas untuk melacak status pemuatan respons model. Callback menerima `true` saat NocoBase mulai menunggu respons, lalu `false` saat respons selesai, dibatalkan, atau gagal. Jika komponen React sudah mendeklarasikan `setResponseLoading` dengan `useState`, Anda dapat menulis:
|
||||
|
||||
```tsx
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
onResponseLoadingChange(loading) {
|
||||
setResponseLoading(loading);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`onResponseLoadingChange` hanya melacak respons yang dimulai langsung oleh pemanggilan `triggerTask()` ini. Dengan `autoSend: false`, tugas hanya masuk ke draft chat dan callback tidak dijalankan. Jika pengguna mengirim draft tersebut secara manual nanti, pengiriman itu tidak menggunakan kembali callback ini.
|
||||
|
||||
Dalam komponen React pada blok JS, perubahan status ini akan memicu render ulang selama komponen masih dimuat.
|
||||
|
||||
### Menargetkan AI Chat Box
|
||||
|
||||
Atur `chatBoxUid` pada opsi tingkat atas `triggerTask()` untuk memicu tugas di blok AI Chat Box yang sudah dimuat, bukan membuka dialog global karyawan AI.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
chatBoxUid: 'AI_CHAT_BOX_BLOCK_UID',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
uid harus berasal dari blok luar AI Chat Box yang saat ini dimuat pada halaman. Jangan tempatkan nilai routing ini di dalam `tasks`. Jika blok target tidak ditemukan, NocoBase menampilkan error dan tidak kembali ke dialog global. Jika `chatBoxUid` tidak diberikan, tugas menggunakan dialog global karyawan AI.
|
||||
|
||||
### Mengunggah dan mengirim lampiran di JSBlock
|
||||
|
||||
Contoh berikut merender unggahan file, instruksi tugas, dan tombol kirim di JSBlock. File yang sudah diunggah diteruskan ke karyawan AI melalui `message.attachments`:
|
||||
|
||||
```tsx
|
||||
if (!ctx.ai?.uploadFile || !ctx.ai?.triggerTask) {
|
||||
ctx.message.error(ctx.t('AI employee task API is not available.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { React } = ctx.libs;
|
||||
const { useState } = React;
|
||||
const { Button, Card, Input, Space, Upload } = ctx.libs.antd;
|
||||
const { InboxOutlined, SendOutlined } = ctx.libs.antdIcons;
|
||||
|
||||
const AttachmentTask = () => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [fileList, setFileList] = useState([]);
|
||||
|
||||
const uploadAttachment = async ({ file, onError, onProgress, onSuccess }) => {
|
||||
try {
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
onProgress?.({ percent });
|
||||
},
|
||||
});
|
||||
onSuccess?.(attachment);
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error : new Error(ctx.t('File upload failed')));
|
||||
}
|
||||
};
|
||||
|
||||
const sendTask = () => {
|
||||
const attachments = fileList
|
||||
.filter((file) => file.status === 'done' && file.response)
|
||||
.map((file) => file.response);
|
||||
|
||||
if (!prompt.trim()) {
|
||||
ctx.message.warning(ctx.t('Enter task instructions'));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'viz',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Analyze uploaded files'),
|
||||
message: {
|
||||
user: prompt.trim(),
|
||||
attachments,
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
setPrompt('');
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const uploading = fileList.some((file) => file.status === 'uploading');
|
||||
|
||||
return (
|
||||
<Card title={ctx.t('AI file analysis')}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
fileList={fileList}
|
||||
customRequest={uploadAttachment}
|
||||
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p>{ctx.t('Click or drag files here to upload')}</p>
|
||||
</Upload.Dragger>
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={ctx.t('Describe the task for the AI employee')}
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
disabled={uploading || !prompt.trim()}
|
||||
onClick={sendTask}
|
||||
>
|
||||
{ctx.t('Send to AI')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
ctx.render(<AttachmentTask />);
|
||||
```
|
||||
|
||||
Dengan `autoSend: false`, lampiran dan instruksi tugas ditempatkan di draft chat AI dan tidak langsung dikirim.
|
||||
|
||||
### Menambahkan konteks blok halaman
|
||||
|
||||
`message.workContext` saat ini digunakan untuk meneruskan blok halaman. Masukkan uid FlowModel dari blok target:
|
||||
@@ -151,6 +329,8 @@ Jika `aiEmployee` berupa string, NocoBase mencocokkan secara persis berdasarkan
|
||||
|
||||
Membaca tugas dari model aksi karyawan AI pada halaman dan memicunya.
|
||||
|
||||
Opsi publik `triggerModelTask()` tidak menerima `chatBoxUid`. Untuk menargetkan AI Chat Box, konfigurasikan `chatBoxUid` pada tugas preset dari aksi karyawan AI. `triggerModelTask()` akan tetap menggunakan nilai preset tersebut.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTaskOptions): void
|
||||
```
|
||||
@@ -161,6 +341,10 @@ ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTa
|
||||
| `taskIndex` | `number` | Indeks tugas, dimulai dari `0`. |
|
||||
| `options.open` | `boolean` | Apakah panel percakapan karyawan AI dibuka. |
|
||||
| `options.auto` | `boolean` | Apakah menggunakan semantik pemicu otomatis dari aksi karyawan AI. |
|
||||
| `options.attachments` | `Attachment[]` | Lampiran yang ditambahkan secara dinamis ke tugas yang sudah dikonfigurasi. |
|
||||
| `options.onResponseLoadingChange` | `(loading: boolean) => void` | Callback status pemuatan respons model. Hanya dijalankan ketika tugas yang dikonfigurasi dikirim otomatis. |
|
||||
|
||||
`options.onResponseLoadingChange` berperilaku sama seperti pada `triggerTask()`. Callback dijalankan sesuai nilai `autoSend` pada tugas yang dikonfigurasi. Callback tidak dijalankan ketika tugas menggunakan `autoSend: false`.
|
||||
|
||||
```ts
|
||||
if (!ctx.ai?.triggerModelTask) {
|
||||
@@ -172,6 +356,7 @@ const weeklyReviewActionUid = 'AI_EMPLOYEE_ACTION_MODEL_UID';
|
||||
|
||||
ctx.ai.triggerModelTask(weeklyReviewActionUid, 0, {
|
||||
open: true,
|
||||
attachments,
|
||||
});
|
||||
|
||||
ctx.message.success(ctx.t('Configured AI employee task triggered.'));
|
||||
@@ -182,9 +367,14 @@ Jika model target tidak ada, belum mengonfigurasi karyawan AI, atau indeks yang
|
||||
## Catatan
|
||||
|
||||
- `triggerTask()` dan `triggerModelTask()` bersifat fire-and-forget. Keduanya tidak mengembalikan hasil eksekusi tugas.
|
||||
- `uploadFile()` mengembalikan Promise. Tunggu unggahan selesai sebelum memicu tugas yang menggunakan lampiran tersebut.
|
||||
- String `aiEmployee` hanya cocok secara persis dengan `AIEmployee.username`.
|
||||
- `triggerModelTask()` menggunakan `taskIndex` berbasis `0`.
|
||||
- `message.workContext` saat ini hanya menjelaskan konteks blok halaman.
|
||||
- `triggerTask().chatBoxUid` tingkat atas harus merujuk ke blok AI Chat Box yang sedang dimuat pada halaman.
|
||||
- `triggerModelTask()` tetap menggunakan `chatBoxUid` yang dikonfigurasi pada tugas preset.
|
||||
- Lampiran dinamis dari `triggerModelTask()` ditambahkan ke `message.attachments` yang sudah ada pada tugas preset tanpa mengubah konfigurasi tersimpan.
|
||||
- `onResponseLoadingChange` hanya melacak respons model yang dikirim otomatis oleh pemanggilan saat ini. Callback tidak melacak pesan yang dikirim manual oleh pengguna setelahnya.
|
||||
|
||||
## Terkait
|
||||
|
||||
|
||||
@@ -80,6 +80,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "ページブロック",
|
||||
"items": [
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "組み込みAI従業員",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
pkg: '@nocobase/plugin-ai'
|
||||
title: 'AI Chat box ブロック'
|
||||
description: 'NocoBase 管理者とページ作成者向けの AI Chat box ブロック操作ガイドです。ブロックの追加、会話機能、Work context、会話履歴、Actions の設定方法を説明します。'
|
||||
keywords: 'AI Chat box,AI 従業員,ページブロック,Work context,Scope,Actions,NocoBase'
|
||||
---
|
||||
|
||||
# AI Chat box ブロック
|
||||
|
||||
NocoBase の **AI Chat box** は、ページに直接追加できる AI 会話ブロックです。業務ページに配置して、そのページ専用の AI アシスタント入口として利用できます。
|
||||
|
||||
各 AI Chat box ブロックは、現在の会話と入力状態を個別に保持します。ページ作成者は、利用できる AI 従業員、モデル、ファイルアップロード、Web 検索、作業コンテキストを制限し、業務シーンに合わせて設定できます。
|
||||
|
||||
:::tip 使用前の準備
|
||||
|
||||
最初に [LLM サービスを設定](../features/llm-service.md)し、[少なくとも 1 人の AI 従業員を有効化](../features/enable-ai-employee.md)してください。
|
||||
|
||||
:::
|
||||
|
||||
## AI Chat box ブロックを追加する
|
||||
|
||||
1. 設定するページを開きます。
|
||||
2. 右上の `UI Editor` をクリックして、ページ編集モードに入ります。
|
||||
3. `Add block` をクリックします。
|
||||
4. `Other blocks` から `AI chat box` を選択します。
|
||||
|
||||

|
||||
|
||||
## ブロックの構成
|
||||
|
||||

|
||||
|
||||
AI Chat box は上から順に 3 つの領域で構成されます。
|
||||
|
||||
- **上部操作領域** — 会話一覧、Actions、カスタムアクション、新しい会話ボタン。メッセージ領域を非表示にするとメッセージボタンも表示されます
|
||||
- **メッセージ領域** — 現在の下書きまたは会話内のメッセージを表示します
|
||||
- **送信領域** — 入力欄、コンテキスト選択、ファイルアップロード、Web 検索、AI 従業員選択、モデル選択、送信ボタン、免責事項を表示します
|
||||
|
||||
### ブロック body に表示コンテンツを追加する
|
||||
|
||||
ページ編集モードで AI Chat box 内の `Add block` をクリックすると、チャット領域の上に次のブロックを追加できます。
|
||||
|
||||
- JS block
|
||||
- Iframe
|
||||
- Markdown
|
||||
|
||||
これらのブロックは、説明、外部ページ、補助情報の表示に適しています。AI Chat box 内の追加メニューではこの 3 種類のみ利用でき、AI Chat box をさらにネストすることはできません。
|
||||
|
||||
## AI Chat box を設定する
|
||||
|
||||
ブロックにポインターを合わせて、ブロック設定メニューを開きます。`Edit chat box` をクリックすると、会話範囲、デフォルトメッセージ、Work context、AI 従業員、モデルを設定できます。
|
||||
|
||||

|
||||
|
||||
### Edit chat box の設定
|
||||
|
||||
| 設定項目 | 説明 |
|
||||
| --- | --- |
|
||||
| `Scope` | 会話一覧を共有する AI Chat box を制御します。新しいブロックでは、会話を分離するためにデフォルトで自身のブロック UID が使用されます。 |
|
||||
| `Background` | AI 従業員の定義の後にシステムプロンプトを追加し、現在のページの役割、目的、回答要件を補足します。 |
|
||||
| `Default user message` | 新しい会話を開始したときに、送信欄へデフォルトのユーザーメッセージを入力します。 |
|
||||
| `Work context` | 新しい下書きにデフォルトで追加するページブロックを選択します。 |
|
||||
| `AI employees` | このブロックで選択できる業務用 AI 従業員を制限します。空欄の場合は、利用可能なすべての業務用 AI 従業員を使用できます。 |
|
||||
| `Models` | このブロックで選択できるモデルを制限します。空欄の場合は、利用可能なすべてのモデルを使用できます。 |
|
||||
|
||||
### その他のブロック設定
|
||||
|
||||
| 設定項目 | 説明 |
|
||||
| --- | --- |
|
||||
| `Show messages` | メッセージ領域をブロック内に直接表示するかどうかを設定します。無効にすると、上部のメッセージボタンから右側パネルを開きます。 |
|
||||
| `Sender placeholder` | 送信欄に表示するプレースホルダーを変更します。 |
|
||||
| `Enable add context` | 送信欄のコンテキスト選択入口を表示または非表示にします。 |
|
||||
| `Enable upload files` | ファイルアップロード入口を表示または非表示にします。無効にすると、ファイルを貼り付けてもアップロードされません。 |
|
||||
| `Enable web search` | Web 検索スイッチを表示または非表示にします。無効にすると、現在の下書きの Web 検索もオフになります。 |
|
||||
| `Enable employee select` | AI 従業員セレクターを表示または非表示にします。 |
|
||||
| `Enable model select` | モデルセレクターを表示または非表示にします。 |
|
||||
| `Show disclaimer` | 送信欄の下にある AI 免責事項を表示または非表示にします。 |
|
||||
|
||||
## Work context を設定する
|
||||
|
||||
`Edit chat box` の `Work context` でコンテキスト追加ボタンをクリックし、`Pick block` を選択してから、AI に提供するページブロックを選びます。保存すると、選択したブロックが新しい会話のデフォルト作業コンテキストになり、送信前に送信領域から削除することもできます。
|
||||
|
||||
## メッセージ領域を非表示にして右側パネルを使用する
|
||||
|
||||
`Show messages` を無効にすると、ブロック本体には送信領域だけが残ります。上部に表示されるメッセージボタンをクリックすると、右側からメッセージパネルが開きます。
|
||||
|
||||

|
||||
|
||||
パネルを開くと、ブロックの残りの領域にオーバーレイが表示されます。オーバーレイまたはメッセージボタンをもう一度クリックすると、パネルを閉じられます。
|
||||
|
||||
このレイアウトは、AI Chat box をページ上の軽量な入力入口として使用する場合に適しています。通常は送信欄だけを表示し、メッセージを確認するときにパネルを開きます。
|
||||
|
||||
## 会話履歴を管理する
|
||||
|
||||
ブロック左上の会話一覧ボタンをクリックすると、現在の Scope に属する会話履歴を確認できます。
|
||||
|
||||
次のルールがあります。
|
||||
|
||||
- 同じ Scope を使用する複数の AI Chat box は、同じ会話一覧を表示できます
|
||||
- 各ブロックの現在の会話、送信欄の下書き、AI 従業員、モデル、添付ファイル、コンテキスト状態は個別に保持されます
|
||||
- グローバルフローティング chatbox はブロックの Scope でフィルタリングしないため、Scope を持つ会話も表示します
|
||||
- Scope を空にすると、会話一覧は Scope でフィルタリングされず、Scope がない会話と別の Scope を使用する会話の両方を表示します
|
||||
|
||||
通常は、新しいブロックに自動生成された Scope をそのまま使用すれば、ページアシスタントごとの会話履歴を分離できます。複数のブロックで同じ会話一覧を共有する場合のみ、同じ Scope を設定してください。
|
||||
|
||||
## Actions を追加する
|
||||
|
||||
ページ編集モードで、ブロック上部の `Actions` をクリックすると、次の操作を追加できます。
|
||||
|
||||
- JS Action
|
||||
- AI employee
|
||||
|
||||
AI employee を追加した後、その従業員のショートカットタスクを設定できます。
|
||||
|
||||
ショートカットタスクの `Chat box uid` では、タスクを実行する AI Chat box を指定できます。AI Chat box 内に直接追加した AI employee は、デフォルトで現在のブロック UID を参照します。
|
||||
|
||||
指定した AI Chat box がマウントされていない場合、NocoBase は対象ブロックが見つからないことを通知し、グローバルフローティング chatbox にはフォールバックしません。詳しい設定は [AI 従業員のショートカットタスク](../features/task.md)を参照してください。
|
||||
|
||||
## ページ専用アシスタントを設定する
|
||||
|
||||
次の手順で、ページ専用の軽量 AI アシスタントを作成できます。
|
||||
|
||||
1. AI Chat box ブロックを追加し、ページ内の適切な位置へ移動します。
|
||||
2. `Edit chat box` でページ専用の Background を入力します。
|
||||
3. 1 つ以上の Work context を選択します。
|
||||
4. `AI employees` と `Models` で利用できる従業員とモデルを制限します。
|
||||
5. 編集モードを終了し、質問を入力して送信します。
|
||||
|
||||
## 注意事項
|
||||
|
||||
- AI Chat box ブロックと右下のグローバルフローティング chatbox は別の入口です。現在の会話と入力状態は自動的に同期されません
|
||||
- AI Chat box 内の `Add block` では、JS block、Iframe、Markdown のみ追加できます
|
||||
- Scope を変更すると会話一覧の検索範囲が変わりますが、別のブロックで開いている会話や下書きはコピーされません
|
||||
@@ -123,6 +123,11 @@
|
||||
"label": "操作パネル",
|
||||
"link": "/interface-builder/blocks/other-blocks/action-panel"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Iframe",
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
---
|
||||
title: "ctx.ai"
|
||||
description: "RunJS で ctx.ai を使い、タスク内容を直接渡すか、AI 従業員アクションに設定されたタスクを再利用して AI 従業員タスクを実行します。"
|
||||
keywords: "ctx.ai,AI employee,triggerTask,triggerModelTask,RunJS,NocoBase"
|
||||
description: "RunJS で ctx.ai を使い、グローバル会話または指定した AI Chat Box で AI 従業員タスクを実行します。タスク内容の直接指定と、AI 従業員アクションに設定されたタスクの再利用に対応しています。"
|
||||
keywords: "ctx.ai,AI employee,uploadFile,attachments,triggerTask,triggerModelTask,onResponseLoadingChange,chatBoxUid,AI Chat Box,RunJS,NocoBase"
|
||||
---
|
||||
|
||||
# ctx.ai
|
||||
|
||||
RunJS では `ctx.ai` を使って **AI 従業員タスク** を実行できます。JSBlock、JSAction、フォーム、ボタンなどから、指定した AI 従業員へ作業を渡す場面に向いています。
|
||||
|
||||
`ctx.ai` はタスクを実行するための API です。AI 従業員タスクの実行結果は返しません。呼び出し後、タスクは AI 従業員の会話フローに入り、結果はそのセッションで処理されます。
|
||||
`ctx.ai` は AI タスクの添付ファイルをアップロードし、タスクを実行します。ファイルのアップロードは完了を待てますが、タスクの実行結果は返しません。呼び出し後、タスクは AI 従業員の会話フローに入り、結果はそのセッションで処理されます。
|
||||
|
||||
:::warning 注意
|
||||
|
||||
`ctx.ai` は AI プラグインによって提供されます。AI プラグインが有効でない場合、または現在の RunJS 環境に対応するクライアント機能が読み込まれていない場合、`ctx.ai` は存在しないことがあります。呼び出す前に `ctx.ai?.triggerTask` または `ctx.ai?.triggerModelTask` を確認できます。
|
||||
`ctx.ai` は AI プラグインによって提供されます。AI プラグインが有効でない場合、または現在の RunJS 環境に対応するクライアント機能が読み込まれていない場合、`ctx.ai` は存在しないことがあります。呼び出す前に `ctx.ai?.uploadFile`、`ctx.ai?.triggerTask`、または `ctx.ai?.triggerModelTask` を確認できます。
|
||||
|
||||
:::
|
||||
|
||||
## メソッド
|
||||
|
||||
### ctx.ai.uploadFile()
|
||||
|
||||
ファイルをアップロードし、AI 従業員タスクへ直接渡せる添付ファイルオブジェクトを返します。
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, options);
|
||||
```
|
||||
|
||||
| パラメーター | 型 | 説明 |
|
||||
|------|------|------|
|
||||
| `file` | `File` | アップロードするブラウザーのファイルオブジェクト。 |
|
||||
| `options.onProgress` | `(percent: number) => void` | アップロード進捗のコールバック。`percent` は `0` から `100` の範囲です。 |
|
||||
| `options.signal` | `AbortSignal` | アップロードをキャンセルするためのシグナル。 |
|
||||
|
||||
アップロードでは AI プラグインに設定されたファイルストレージを使用し、`aiFiles` にレコードを作成します。返されるオブジェクトには `id`、`filename`、`url`、`source` などが含まれます。
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
console.log('upload progress', percent);
|
||||
},
|
||||
});
|
||||
|
||||
// attachment は message.attachments に直接設定できます
|
||||
```
|
||||
|
||||
アップロードに失敗すると Promise は reject されます。ローカルのファイル一覧から添付ファイルを削除しても、`aiFiles` に作成済みのレコードは削除されません。デフォルトの AI チャットウィンドウと同じ動作です。
|
||||
|
||||
### ctx.ai.triggerTask()
|
||||
|
||||
AI 従業員タスクを直接実行します。
|
||||
@@ -30,8 +58,10 @@ ctx.ai.triggerTask(options: TriggerTaskOptions): void
|
||||
|------|------|------|
|
||||
| `aiEmployee` | `string \| AIEmployee` | AI 従業員。文字列を渡す場合、`AIEmployee.username` と完全一致で検索され、現在のユーザーがアクセスできる必要があります。 |
|
||||
| `tasks` | `Task[]` | 実行するタスク一覧。 |
|
||||
| `chatBoxUid` | `string` | タスクを受け取る AI Chat Box ブロックの FlowModel uid。 |
|
||||
| `open` | `boolean` | AI 従業員の会話パネルを開くかどうか。 |
|
||||
| `auto` | `boolean` | AI 従業員アクションの自動実行セマンティクスを使うかどうか。 |
|
||||
| `onResponseLoadingChange` | `(loading: boolean) => void` | モデル応答の読み込み状態コールバック。このタスクが自動送信される場合にのみ呼び出されます。 |
|
||||
|
||||
よく使う `Task` フィールド:
|
||||
|
||||
@@ -40,12 +70,160 @@ ctx.ai.triggerTask(options: TriggerTaskOptions): void
|
||||
| `title` | `string` | タスクタイトル。 |
|
||||
| `message.system` | `string` | AI 従業員の役割や出力要件を制約するシステムメッセージ。 |
|
||||
| `message.user` | `string` | このタスクの主要な指示。 |
|
||||
| `message.attachments` | `Attachment[]` | タスクで使う添付ファイル。通常は `ctx.ai.uploadFile()` の戻り値です。 |
|
||||
| `message.workContext` | `ContextItem[]` | タスクで使うページブロックのコンテキスト。 |
|
||||
| `autoSend` | `boolean` | タスクメッセージを自動送信するかどうか。 |
|
||||
| `webSearch` | `boolean` | このタスクで Web search を許可するかどうか。 |
|
||||
| `model` | `{ llmService: string; model: string } \| null` | このタスクで使うモデル。 |
|
||||
| `skillSettings` | `SkillSettings` | このタスクで使う skills / tools 設定。 |
|
||||
|
||||
### レスポンスの読み込み状態を監視する
|
||||
|
||||
トップレベルオプションに `onResponseLoadingChange` を渡すと、このタスクのモデル応答の読み込み状態を監視できます。モデル応答の待機開始時は `true`、完了、キャンセル、失敗時は `false` が渡されます。React コンポーネントで `useState` により `setResponseLoading` を宣言済みの場合は、次のように記述できます。
|
||||
|
||||
```tsx
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
onResponseLoadingChange(loading) {
|
||||
setResponseLoading(loading);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`onResponseLoadingChange` は、この `triggerTask()` 呼び出しが直接開始したモデル応答だけを監視します。`autoSend: false` の場合、タスクはチャットの下書きに入るだけで、コールバックは呼び出されません。ユーザーが後で手動送信しても、このコールバックは再利用されません。
|
||||
|
||||
JS ブロック内の React コンポーネントでは、コンポーネントがマウントされている間、この状態更新によって再レンダリングされます。
|
||||
|
||||
### AI Chat Box を指定する
|
||||
|
||||
`triggerTask()` のトップレベルオプションに `chatBoxUid` を設定すると、グローバルな AI 従業員ダイアログではなく、ページにマウントされている AI Chat Box ブロックでタスクを実行できます。
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
chatBoxUid: 'AI_CHAT_BOX_BLOCK_UID',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
uid には、現在のページにマウントされている外側の AI Chat Box ブロックを指定します。このルーティング値を `tasks` 内に設定しないでください。対象ブロックが見つからない場合、NocoBase はエラーを表示し、グローバルダイアログにはフォールバックしません。`chatBoxUid` を省略すると、タスクはグローバルな AI 従業員ダイアログで実行されます。
|
||||
|
||||
### JSBlock で添付ファイルをアップロードして送信する
|
||||
|
||||
次の例では、JSBlock にファイルアップロード、タスクの指示、送信ボタンを表示します。アップロード済みのファイルは `message.attachments` を通して AI 従業員へ渡されます。
|
||||
|
||||
```tsx
|
||||
if (!ctx.ai?.uploadFile || !ctx.ai?.triggerTask) {
|
||||
ctx.message.error(ctx.t('AI employee task API is not available.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { React } = ctx.libs;
|
||||
const { useState } = React;
|
||||
const { Button, Card, Input, Space, Upload } = ctx.libs.antd;
|
||||
const { InboxOutlined, SendOutlined } = ctx.libs.antdIcons;
|
||||
|
||||
const AttachmentTask = () => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [fileList, setFileList] = useState([]);
|
||||
|
||||
const uploadAttachment = async ({ file, onError, onProgress, onSuccess }) => {
|
||||
try {
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
onProgress?.({ percent });
|
||||
},
|
||||
});
|
||||
onSuccess?.(attachment);
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error : new Error(ctx.t('File upload failed')));
|
||||
}
|
||||
};
|
||||
|
||||
const sendTask = () => {
|
||||
const attachments = fileList
|
||||
.filter((file) => file.status === 'done' && file.response)
|
||||
.map((file) => file.response);
|
||||
|
||||
if (!prompt.trim()) {
|
||||
ctx.message.warning(ctx.t('Enter task instructions'));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'viz',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Analyze uploaded files'),
|
||||
message: {
|
||||
user: prompt.trim(),
|
||||
attachments,
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
setPrompt('');
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const uploading = fileList.some((file) => file.status === 'uploading');
|
||||
|
||||
return (
|
||||
<Card title={ctx.t('AI file analysis')}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
fileList={fileList}
|
||||
customRequest={uploadAttachment}
|
||||
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p>{ctx.t('Click or drag files here to upload')}</p>
|
||||
</Upload.Dragger>
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={ctx.t('Describe the task for the AI employee')}
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
disabled={uploading || !prompt.trim()}
|
||||
onClick={sendTask}
|
||||
>
|
||||
{ctx.t('Send to AI')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
ctx.render(<AttachmentTask />);
|
||||
```
|
||||
|
||||
`autoSend: false` を設定すると、添付ファイルとタスクの指示は AI チャットの下書きに入り、すぐには送信されません。
|
||||
|
||||
### ページブロックコンテキストを追加する
|
||||
|
||||
`message.workContext` は現在、ページブロックを渡すために使います。対象ページブロックの FlowModel uid を入れます。
|
||||
@@ -151,6 +329,8 @@ ctx.message.success(ctx.t('AI employee task triggered.'));
|
||||
|
||||
ページ上の AI 従業員アクションモデルからタスクを読み取り、実行します。
|
||||
|
||||
`triggerModelTask()` の公開オプションは `chatBoxUid` を受け取りません。AI Chat Box を指定するには、AI 従業員アクションのプリセットタスクに `chatBoxUid` を設定します。`triggerModelTask()` はそのプリセット値を引き続き使用します。
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTaskOptions): void
|
||||
```
|
||||
@@ -161,6 +341,10 @@ ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTa
|
||||
| `taskIndex` | `number` | タスクのインデックス。`0` から始まります。 |
|
||||
| `options.open` | `boolean` | AI 従業員の会話パネルを開くかどうか。 |
|
||||
| `options.auto` | `boolean` | AI 従業員アクションの自動実行セマンティクスを使うかどうか。 |
|
||||
| `options.attachments` | `Attachment[]` | 設定済みタスクへ動的に追加する添付ファイル。 |
|
||||
| `options.onResponseLoadingChange` | `(loading: boolean) => void` | モデル応答の読み込み状態コールバック。設定済みタスクが自動送信される場合にのみ呼び出されます。 |
|
||||
|
||||
`options.onResponseLoadingChange` の動作は `triggerTask()` と同じです。呼び出されるかどうかは、設定済みタスクの `autoSend` によって決まります。`autoSend: false` のタスクでは呼び出されません。
|
||||
|
||||
```ts
|
||||
if (!ctx.ai?.triggerModelTask) {
|
||||
@@ -172,6 +356,7 @@ const weeklyReviewActionUid = 'AI_EMPLOYEE_ACTION_MODEL_UID';
|
||||
|
||||
ctx.ai.triggerModelTask(weeklyReviewActionUid, 0, {
|
||||
open: true,
|
||||
attachments,
|
||||
});
|
||||
|
||||
ctx.message.success(ctx.t('Configured AI employee task triggered.'));
|
||||
@@ -182,9 +367,14 @@ ctx.message.success(ctx.t('Configured AI employee task triggered.'));
|
||||
## 注意事項
|
||||
|
||||
- `triggerTask()` と `triggerModelTask()` は fire-and-forget です。AI 従業員タスクの実行結果は返しません。
|
||||
- `uploadFile()` は Promise を返します。添付ファイルを使うタスクは、アップロード完了後に実行してください。
|
||||
- `aiEmployee` の文字列は `AIEmployee.username` と完全一致でのみ検索されます。
|
||||
- `triggerModelTask()` の `taskIndex` は `0` から始まります。
|
||||
- `message.workContext` は現在、ページブロックコンテキストのみを表します。
|
||||
- トップレベルの `triggerTask().chatBoxUid` には、現在のページにマウントされている AI Chat Box ブロックを指定する必要があります。
|
||||
- `triggerModelTask()` はプリセットタスクに設定された `chatBoxUid` を引き続き使用します。
|
||||
- `triggerModelTask()` の動的な添付ファイルは、保存済みタスク設定を変更せず、プリセットタスクの既存の `message.attachments` に追加されます。
|
||||
- `onResponseLoadingChange` は現在の呼び出しが自動送信したモデル応答だけを監視し、後でユーザーが手動送信したメッセージは監視しません。
|
||||
|
||||
## 関連
|
||||
|
||||
|
||||
@@ -80,6 +80,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Blocos de página",
|
||||
"items": [
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Funcionários de IA integrados",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
pkg: '@nocobase/plugin-ai'
|
||||
title: 'Bloco AI Chat box'
|
||||
description: 'Guia para administradores e criadores de páginas do NocoBase sobre como adicionar um bloco AI Chat box, configurar recursos de conversa, definir Work context, gerenciar conversas e adicionar Actions.'
|
||||
keywords: 'AI Chat box,Funcionário de IA,bloco de página,Work context,Scope,Actions,NocoBase'
|
||||
---
|
||||
|
||||
# Bloco AI Chat box
|
||||
|
||||
No NocoBase, o **AI Chat box** é um bloco de conversa com IA que pode ser adicionado diretamente a uma página. Você pode colocá-lo em uma página de negócio para oferecer um ponto de acesso fixo a um assistente de IA dedicado àquela página.
|
||||
|
||||
Cada bloco AI Chat box mantém seu próprio estado de conversa atual e de entrada. Os criadores de páginas também podem limitar os funcionários de IA, modelos, upload de arquivos, pesquisa na web e contexto de trabalho disponíveis para adequar o bloco ao cenário de negócio.
|
||||
|
||||
:::tip Antes de começar
|
||||
|
||||
Primeiro [configure um serviço de LLM](../features/llm-service.md) e [ative pelo menos um funcionário de IA](../features/enable-ai-employee.md).
|
||||
|
||||
:::
|
||||
|
||||
## Adicionar um bloco AI Chat box
|
||||
|
||||
1. Abra a página que deseja configurar.
|
||||
2. Clique em `UI Editor` no canto superior direito para entrar no modo de edição da página.
|
||||
3. Clique em `Add block`.
|
||||
4. Em `Other blocks`, selecione `AI chat box`.
|
||||
|
||||

|
||||
|
||||
## Estrutura do bloco
|
||||
|
||||

|
||||
|
||||
O AI Chat box é dividido em três áreas, de cima para baixo:
|
||||
|
||||
- **Área de ações superior** — acesso à lista de conversas, Actions, ações personalizadas e nova conversa; quando a área de mensagens é ocultada, também aparece o botão de mensagens
|
||||
- **Área de mensagens** — exibe as mensagens do rascunho ou da conversa atual
|
||||
- **Área de envio** — caixa de entrada, seleção de contexto, upload de arquivos, pesquisa na web, seleção de funcionário de IA, seleção de modelo, botão de envio e aviso legal
|
||||
|
||||
### Adicionar conteúdo ao body do bloco
|
||||
|
||||
No modo de edição da página, clique em `Add block` dentro do AI Chat box para adicionar um dos blocos abaixo acima da área de chat:
|
||||
|
||||
- JS block
|
||||
- Iframe
|
||||
- Markdown
|
||||
|
||||
Esses blocos são úteis para exibir instruções, páginas externas ou informações complementares. O menu interno oferece apenas esses três tipos de bloco e não permite aninhar outro AI Chat box.
|
||||
|
||||
## Configurar o AI Chat box
|
||||
|
||||
Mova o ponteiro sobre o bloco e abra o menu de configurações. Clique em `Edit chat box` para configurar o escopo das conversas, a mensagem padrão, Work context, funcionários de IA e modelos.
|
||||
|
||||

|
||||
|
||||
### Configurações de Edit chat box
|
||||
|
||||
| Configuração | Descrição |
|
||||
| --- | --- |
|
||||
| `Scope` | Controla quais AI Chat boxes compartilham uma lista de conversas. Um novo bloco usa seu próprio UID por padrão para manter as conversas separadas. |
|
||||
| `Background` | Adiciona um prompt de sistema após a definição do funcionário de IA para informar a função, o objetivo ou os requisitos de resposta da página atual. |
|
||||
| `Default user message` | Preenche a área de envio com uma mensagem de usuário padrão ao iniciar uma nova conversa. |
|
||||
| `Work context` | Seleciona os blocos de página que serão adicionados por padrão a um novo rascunho. |
|
||||
| `AI employees` | Limita os funcionários de IA de negócio que podem ser selecionados neste bloco. Deixe vazio para permitir todos os funcionários de IA de negócio disponíveis. |
|
||||
| `Models` | Limita os modelos que podem ser selecionados neste bloco. Deixe vazio para permitir todos os modelos disponíveis. |
|
||||
|
||||
### Outras configurações do bloco
|
||||
|
||||
| Configuração | Descrição |
|
||||
| --- | --- |
|
||||
| `Show messages` | Controla se a área de mensagens é exibida diretamente no bloco. Quando desativado, use o botão de mensagens na parte superior para abrir o painel direito. |
|
||||
| `Sender placeholder` | Altera o texto de placeholder da área de envio. |
|
||||
| `Enable add context` | Exibe ou oculta a entrada de seleção de contexto na área de envio. |
|
||||
| `Enable upload files` | Exibe ou oculta a entrada de upload de arquivos. Quando desativado, colar um arquivo também não inicia o upload. |
|
||||
| `Enable web search` | Exibe ou oculta o seletor de pesquisa na web. Desativá-lo também desliga a pesquisa na web do rascunho atual. |
|
||||
| `Enable employee select` | Exibe ou oculta o seletor de funcionários de IA. |
|
||||
| `Enable model select` | Exibe ou oculta o seletor de modelos. |
|
||||
| `Show disclaimer` | Exibe ou oculta o aviso de IA abaixo da área de envio. |
|
||||
|
||||
## Configurar Work context
|
||||
|
||||
Em `Work context`, dentro de `Edit chat box`, clique no botão de adicionar contexto, selecione `Pick block` e escolha o bloco da página que deseja fornecer à IA. Depois de salvar, o bloco selecionado será usado como contexto de trabalho padrão para novas conversas e poderá ser removido da área de envio antes do envio.
|
||||
|
||||
## Ocultar as mensagens e usar o painel direito
|
||||
|
||||
Depois de desativar `Show messages`, o corpo do bloco mantém apenas a área de envio. Um botão de mensagens aparece na parte superior; clique nele para abrir o painel de mensagens pela direita.
|
||||
|
||||

|
||||
|
||||
Quando o painel está aberto, o restante do bloco fica coberto por uma sobreposição. Clique na sobreposição ou novamente no botão de mensagens para fechar o painel.
|
||||
|
||||
Esse layout funciona bem quando o AI Chat box é usado como uma entrada leve na página: normalmente apenas a área de envio fica visível, e o painel é aberto quando for necessário consultar as mensagens.
|
||||
|
||||
## Gerenciar o histórico de conversas
|
||||
|
||||
Clique no botão da lista de conversas no canto superior esquerdo do bloco para ver o histórico no Scope atual.
|
||||
|
||||
Observe estas regras:
|
||||
|
||||
- Vários AI Chat boxes com o mesmo Scope podem ver a mesma lista de conversas
|
||||
- Cada bloco mantém de forma independente a conversa atual, o rascunho da área de envio, o funcionário de IA, o modelo, os anexos e o estado do contexto
|
||||
- O chatbox flutuante global não filtra pelo Scope do bloco, portanto não oculta conversas que possuem Scope
|
||||
- Ao limpar o Scope, o bloco deixa de filtrar a lista por Scope e exibe conversas sem Scope e conversas que usam outros Scopes
|
||||
|
||||
Normalmente, manter o Scope gerado para um novo bloco é suficiente para separar o histórico de cada assistente de página. Configure o mesmo Scope somente quando vários blocos precisarem compartilhar a mesma lista de conversas.
|
||||
|
||||
## Adicionar Actions
|
||||
|
||||
No modo de edição da página, clique em `Actions` na parte superior do bloco para adicionar uma das seguintes ações:
|
||||
|
||||
- JS Action
|
||||
- AI employee
|
||||
|
||||
Depois de adicionar um AI employee, você pode configurar tarefas de atalho para esse funcionário.
|
||||
|
||||
A configuração `Chat box uid` em uma tarefa de atalho especifica em qual AI Chat box a tarefa será executada. Um AI employee adicionado diretamente dentro de um AI Chat box aponta por padrão para o UID do bloco atual.
|
||||
|
||||
Se o AI Chat box especificado não estiver montado, o NocoBase informa que o bloco de destino não foi encontrado e não usa o chatbox flutuante global como alternativa. Consulte [Tarefas de atalho dos funcionários de IA](../features/task.md) para obter a configuração detalhada.
|
||||
|
||||
## Configurar um assistente específico para uma página
|
||||
|
||||
As etapas abaixo criam um assistente de IA leve para uma página:
|
||||
|
||||
1. Adicione um bloco AI Chat box e mova-o para a posição adequada na página.
|
||||
2. Insira um Background específico para a página em `Edit chat box`.
|
||||
3. Selecione um ou mais Work contexts.
|
||||
4. Limite os funcionários e modelos disponíveis em `AI employees` e `Models`.
|
||||
5. Saia do modo de edição, insira uma pergunta e envie.
|
||||
|
||||
## Observações
|
||||
|
||||
- O bloco AI Chat box e o chatbox flutuante global no canto inferior direito são pontos de entrada separados; a conversa atual e o estado de entrada não são sincronizados automaticamente
|
||||
- Dentro de um AI Chat box, `Add block` permite adicionar apenas JS block, Iframe e Markdown
|
||||
- Alterar o Scope afeta o alcance da consulta da lista de conversas e não copia a conversa ou o rascunho aberto atualmente em outro bloco
|
||||
@@ -123,6 +123,11 @@
|
||||
"label": "Painel de operação",
|
||||
"link": "/interface-builder/blocks/other-blocks/action-panel"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Iframe",
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
---
|
||||
title: "ctx.ai"
|
||||
description: "Use ctx.ai no RunJS para acionar tarefas de funcionário de IA, com conteúdo de tarefa direto ou com tarefas configuradas em uma ação de funcionário de IA."
|
||||
keywords: "ctx.ai,AI employee,triggerTask,triggerModelTask,RunJS,NocoBase"
|
||||
description: "Use ctx.ai no RunJS para acionar tarefas de funcionário de IA na conversa global ou em um AI Chat Box específico, com conteúdo direto ou com tarefas configuradas em uma ação de funcionário de IA."
|
||||
keywords: "ctx.ai,AI employee,uploadFile,attachments,triggerTask,triggerModelTask,onResponseLoadingChange,chatBoxUid,AI Chat Box,RunJS,NocoBase"
|
||||
---
|
||||
|
||||
# ctx.ai
|
||||
|
||||
No RunJS, `ctx.ai` aciona **tarefas de funcionário de IA**. Ele é útil em JSBlock, JSAction e outras interações nas quais um botão, formulário ou fluxo de negócio precisa entregar trabalho a um funcionário de IA específico.
|
||||
|
||||
`ctx.ai` apenas aciona tarefas. Ele não retorna o resultado de execução da tarefa. Depois da chamada, a tarefa entra no fluxo de conversa do funcionário de IA.
|
||||
`ctx.ai` faz upload de anexos para tarefas de IA e aciona tarefas. É possível aguardar o upload dos arquivos, mas o acionamento da tarefa não retorna seu resultado de execução. Depois da chamada, a tarefa entra no fluxo de conversa do funcionário de IA.
|
||||
|
||||
:::warning Observação
|
||||
|
||||
`ctx.ai` é fornecido pelo plugin de IA. Se o plugin de IA não estiver habilitado, ou se o ambiente RunJS atual não tiver carregado a capacidade de cliente correspondente, `ctx.ai` pode não existir. Você pode verificar `ctx.ai?.triggerTask` ou `ctx.ai?.triggerModelTask` antes de chamar.
|
||||
`ctx.ai` é fornecido pelo plugin de IA. Se o plugin de IA não estiver habilitado, ou se o ambiente RunJS atual não tiver carregado a capacidade de cliente correspondente, `ctx.ai` pode não existir. Você pode verificar `ctx.ai?.uploadFile`, `ctx.ai?.triggerTask` ou `ctx.ai?.triggerModelTask` antes de chamar.
|
||||
|
||||
:::
|
||||
|
||||
## Métodos
|
||||
|
||||
### ctx.ai.uploadFile()
|
||||
|
||||
Faz upload de um arquivo e retorna um objeto de anexo que pode ser passado diretamente para uma tarefa de funcionário de IA.
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, options);
|
||||
```
|
||||
|
||||
| Parâmetro | Tipo | Descrição |
|
||||
|------|------|------|
|
||||
| `file` | `File` | Objeto de arquivo do navegador que será enviado. |
|
||||
| `options.onProgress` | `(percent: number) => void` | Callback de progresso do upload. `percent` varia de `0` a `100`. |
|
||||
| `options.signal` | `AbortSignal` | Sinal usado para cancelar o upload. |
|
||||
|
||||
O upload usa o armazenamento de arquivos configurado pelo plugin de IA e cria um registro em `aiFiles`. O objeto retornado inclui campos como `id`, `filename`, `url` e `source`:
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
console.log('upload progress', percent);
|
||||
},
|
||||
});
|
||||
|
||||
// attachment pode ser colocado diretamente em message.attachments
|
||||
```
|
||||
|
||||
O Promise é rejeitado quando o upload falha. Remover um anexo da lista local não exclui o registro já criado em `aiFiles`, assim como na janela de chat de IA padrão.
|
||||
|
||||
### ctx.ai.triggerTask()
|
||||
|
||||
Aciona diretamente uma tarefa de funcionário de IA.
|
||||
@@ -30,8 +58,10 @@ ctx.ai.triggerTask(options: TriggerTaskOptions): void
|
||||
|------|------|------|
|
||||
| `aiEmployee` | `string \| AIEmployee` | Funcionário de IA. Ao passar uma string, NocoBase compara exatamente com `AIEmployee.username`, e o funcionário de IA deve estar acessível ao usuário atual. |
|
||||
| `tasks` | `Task[]` | Lista de tarefas a acionar. |
|
||||
| `chatBoxUid` | `string` | uid FlowModel do bloco AI Chat Box que deve receber a tarefa. |
|
||||
| `open` | `boolean` | Se o painel de conversa do funcionário de IA deve ser aberto. |
|
||||
| `auto` | `boolean` | Se deve usar a semântica de acionamento automático de uma ação de funcionário de IA. |
|
||||
| `onResponseLoadingChange` | `(loading: boolean) => void` | Callback do estado de carregamento da resposta. Só é executado quando esta tarefa é enviada automaticamente. |
|
||||
|
||||
Campos comuns de `Task`:
|
||||
|
||||
@@ -40,12 +70,160 @@ Campos comuns de `Task`:
|
||||
| `title` | `string` | Título da tarefa. |
|
||||
| `message.system` | `string` | Mensagem do sistema para limitar o papel e os requisitos de saída do funcionário de IA. |
|
||||
| `message.user` | `string` | Mensagem do usuário, ou seja, a instrução principal da tarefa. |
|
||||
| `message.attachments` | `Attachment[]` | Anexos usados pela tarefa, geralmente retornados por `ctx.ai.uploadFile()`. |
|
||||
| `message.workContext` | `ContextItem[]` | Contexto de blocos da página usado pela tarefa. |
|
||||
| `autoSend` | `boolean` | Se a mensagem da tarefa deve ser enviada automaticamente. |
|
||||
| `webSearch` | `boolean` | Se esta tarefa pode usar Web search. |
|
||||
| `model` | `{ llmService: string; model: string } \| null` | Modelo usado por esta tarefa. |
|
||||
| `skillSettings` | `SkillSettings` | Configuração de skills / tools usada por esta tarefa. |
|
||||
|
||||
### Acompanhar o estado de carregamento da resposta
|
||||
|
||||
Passe `onResponseLoadingChange` nas opções de nível superior para acompanhar o estado de carregamento da resposta do modelo. O callback recebe `true` quando o NocoBase começa a aguardar a resposta e `false` quando ela termina, é cancelada ou falha. Se o componente React já declarou `setResponseLoading` com `useState`, você pode escrever:
|
||||
|
||||
```tsx
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
onResponseLoadingChange(loading) {
|
||||
setResponseLoading(loading);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`onResponseLoadingChange` acompanha apenas a resposta iniciada diretamente por esta chamada de `triggerTask()`. Com `autoSend: false`, a tarefa fica no rascunho do chat e o callback não é executado. Se o usuário enviar o rascunho depois, esse envio manual não reutiliza o callback.
|
||||
|
||||
Em um componente React de um bloco JS, essa atualização renderiza novamente o componente enquanto ele permanecer montado.
|
||||
|
||||
### Direcionar para um AI Chat Box
|
||||
|
||||
Defina `chatBoxUid` nas opções de nível superior de `triggerTask()` para acionar a tarefa em um bloco AI Chat Box montado, em vez de abrir o diálogo global do funcionário de IA.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
chatBoxUid: 'AI_CHAT_BOX_BLOCK_UID',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
O uid deve pertencer ao bloco AI Chat Box externo atualmente montado na página. Não coloque esse valor de roteamento dentro de `tasks`. Se o bloco de destino não for encontrado, NocoBase exibirá um erro e não retornará ao diálogo global. Quando `chatBoxUid` é omitido, a tarefa usa o diálogo global do funcionário de IA.
|
||||
|
||||
### Fazer upload e enviar anexos no JSBlock
|
||||
|
||||
O exemplo a seguir renderiza upload de arquivos, instruções da tarefa e um botão de envio no JSBlock. Os arquivos enviados são passados ao funcionário de IA por `message.attachments`:
|
||||
|
||||
```tsx
|
||||
if (!ctx.ai?.uploadFile || !ctx.ai?.triggerTask) {
|
||||
ctx.message.error(ctx.t('AI employee task API is not available.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { React } = ctx.libs;
|
||||
const { useState } = React;
|
||||
const { Button, Card, Input, Space, Upload } = ctx.libs.antd;
|
||||
const { InboxOutlined, SendOutlined } = ctx.libs.antdIcons;
|
||||
|
||||
const AttachmentTask = () => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [fileList, setFileList] = useState([]);
|
||||
|
||||
const uploadAttachment = async ({ file, onError, onProgress, onSuccess }) => {
|
||||
try {
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
onProgress?.({ percent });
|
||||
},
|
||||
});
|
||||
onSuccess?.(attachment);
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error : new Error(ctx.t('File upload failed')));
|
||||
}
|
||||
};
|
||||
|
||||
const sendTask = () => {
|
||||
const attachments = fileList
|
||||
.filter((file) => file.status === 'done' && file.response)
|
||||
.map((file) => file.response);
|
||||
|
||||
if (!prompt.trim()) {
|
||||
ctx.message.warning(ctx.t('Enter task instructions'));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'viz',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Analyze uploaded files'),
|
||||
message: {
|
||||
user: prompt.trim(),
|
||||
attachments,
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
setPrompt('');
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const uploading = fileList.some((file) => file.status === 'uploading');
|
||||
|
||||
return (
|
||||
<Card title={ctx.t('AI file analysis')}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
fileList={fileList}
|
||||
customRequest={uploadAttachment}
|
||||
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p>{ctx.t('Click or drag files here to upload')}</p>
|
||||
</Upload.Dragger>
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={ctx.t('Describe the task for the AI employee')}
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
disabled={uploading || !prompt.trim()}
|
||||
onClick={sendTask}
|
||||
>
|
||||
{ctx.t('Send to AI')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
ctx.render(<AttachmentTask />);
|
||||
```
|
||||
|
||||
Com `autoSend: false`, os anexos e as instruções da tarefa são colocados no rascunho do chat de IA e não são enviados imediatamente.
|
||||
|
||||
### Adicionar contexto de blocos da página
|
||||
|
||||
`message.workContext` atualmente passa blocos da página. Coloque nele o uid FlowModel do bloco de destino:
|
||||
@@ -151,6 +329,8 @@ Se `aiEmployee` for uma string, NocoBase procura correspondência exata por `use
|
||||
|
||||
Lê uma tarefa de um modelo de ação de funcionário de IA na página e a aciona.
|
||||
|
||||
As opções públicas de `triggerModelTask()` não aceitam `chatBoxUid`. Para direcionar a um AI Chat Box, configure `chatBoxUid` na tarefa predefinida da ação de funcionário de IA. `triggerModelTask()` continuará reutilizando esse valor predefinido.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTaskOptions): void
|
||||
```
|
||||
@@ -161,6 +341,10 @@ ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTa
|
||||
| `taskIndex` | `number` | Índice da tarefa, começando em `0`. |
|
||||
| `options.open` | `boolean` | Se o painel de conversa do funcionário de IA deve ser aberto. |
|
||||
| `options.auto` | `boolean` | Se deve usar a semântica de acionamento automático de uma ação de funcionário de IA. |
|
||||
| `options.attachments` | `Attachment[]` | Anexos adicionados dinamicamente à tarefa configurada. |
|
||||
| `options.onResponseLoadingChange` | `(loading: boolean) => void` | Callback do estado de carregamento da resposta. Só é executado quando a tarefa configurada é enviada automaticamente. |
|
||||
|
||||
`options.onResponseLoadingChange` funciona da mesma forma que em `triggerTask()`. Sua execução depende do valor `autoSend` da tarefa configurada. Ele não é executado quando a tarefa usa `autoSend: false`.
|
||||
|
||||
```ts
|
||||
if (!ctx.ai?.triggerModelTask) {
|
||||
@@ -172,6 +356,7 @@ const weeklyReviewActionUid = 'AI_EMPLOYEE_ACTION_MODEL_UID';
|
||||
|
||||
ctx.ai.triggerModelTask(weeklyReviewActionUid, 0, {
|
||||
open: true,
|
||||
attachments,
|
||||
});
|
||||
|
||||
ctx.message.success(ctx.t('Configured AI employee task triggered.'));
|
||||
@@ -182,9 +367,14 @@ Se o modelo de destino não existir, não tiver funcionário de IA configurado,
|
||||
## Observações
|
||||
|
||||
- `triggerTask()` e `triggerModelTask()` são fire-and-forget. Eles não retornam o resultado de execução da tarefa.
|
||||
- `uploadFile()` retorna um Promise. Aguarde o fim do upload antes de acionar uma tarefa que use o anexo.
|
||||
- Strings em `aiEmployee` correspondem apenas exatamente a `AIEmployee.username`.
|
||||
- `triggerModelTask()` usa `taskIndex` começando em `0`.
|
||||
- `message.workContext` atualmente descreve apenas contexto de blocos da página.
|
||||
- O valor de nível superior `triggerTask().chatBoxUid` deve referenciar um bloco AI Chat Box atualmente montado na página.
|
||||
- `triggerModelTask()` continua usando o `chatBoxUid` configurado na tarefa predefinida.
|
||||
- Os anexos dinâmicos de `triggerModelTask()` são adicionados aos `message.attachments` existentes da tarefa predefinida sem alterar a configuração salva.
|
||||
- `onResponseLoadingChange` acompanha apenas uma resposta enviada automaticamente pela chamada atual. Ele não acompanha uma mensagem enviada manualmente pelo usuário depois.
|
||||
|
||||
## Relacionado
|
||||
|
||||
|
||||
@@ -80,6 +80,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Блоки страниц",
|
||||
"items": [
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Встроенные ИИ-сотрудники",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
pkg: '@nocobase/plugin-ai'
|
||||
title: 'Блок AI Chat box'
|
||||
description: 'Руководство для администраторов и конструкторов страниц NocoBase: добавление блока AI Chat box, настройка возможностей чата, Work context, истории разговоров и Actions.'
|
||||
keywords: 'AI Chat box,ИИ-сотрудник,блок страницы,Work context,Scope,Actions,NocoBase'
|
||||
---
|
||||
|
||||
# Блок AI Chat box
|
||||
|
||||
В NocoBase **AI Chat box** — это блок чата с ИИ, который можно добавить непосредственно на страницу. Его можно разместить на бизнес-странице как постоянную точку доступа к ИИ-ассистенту для этой страницы.
|
||||
|
||||
Каждый блок AI Chat box независимо хранит текущий разговор и состояние ввода. Конструктор страницы также может ограничить доступных ИИ-сотрудников, модели, загрузку файлов, веб-поиск и рабочий контекст в соответствии с бизнес-сценарием.
|
||||
|
||||
:::tip Перед началом
|
||||
|
||||
Сначала [настройте службу языковой модели](../features/llm-service.md) и [включите хотя бы одного ИИ-сотрудника](../features/enable-ai-employee.md).
|
||||
|
||||
:::
|
||||
|
||||
## Добавление блока AI Chat box
|
||||
|
||||
1. Откройте страницу, которую нужно настроить.
|
||||
2. Нажмите `UI Editor` в правом верхнем углу, чтобы перейти в режим редактирования страницы.
|
||||
3. Нажмите `Add block`.
|
||||
4. В разделе `Other blocks` выберите `AI chat box`.
|
||||
|
||||

|
||||
|
||||
## Структура блока
|
||||
|
||||

|
||||
|
||||
AI Chat box состоит из трех областей сверху вниз:
|
||||
|
||||
- **Верхняя область действий** — список разговоров, Actions, пользовательские действия и кнопка нового разговора; при скрытой области сообщений также отображается кнопка сообщений
|
||||
- **Область сообщений** — показывает сообщения текущего черновика или разговора
|
||||
- **Область отправки** — поле ввода, выбор контекста, загрузка файлов, веб-поиск, выбор ИИ-сотрудника, выбор модели, кнопка отправки и предупреждение
|
||||
|
||||
### Добавление содержимого в body блока
|
||||
|
||||
В режиме редактирования страницы нажмите `Add block` внутри AI Chat box, чтобы добавить над областью чата один из следующих блоков:
|
||||
|
||||
- JS block
|
||||
- Iframe
|
||||
- Markdown
|
||||
|
||||
Эти блоки подходят для инструкций, внешних страниц и вспомогательной информации. Внутреннее меню предлагает только эти три типа блоков и не позволяет вложить еще один AI Chat box.
|
||||
|
||||
## Настройка AI Chat box
|
||||
|
||||
Наведите указатель на блок и откройте меню его настроек. Нажмите `Edit chat box`, чтобы настроить область разговоров, сообщение по умолчанию, Work context, ИИ-сотрудников и модели.
|
||||
|
||||

|
||||
|
||||
### Настройки Edit chat box
|
||||
|
||||
| Настройка | Описание |
|
||||
| --- | --- |
|
||||
| `Scope` | Определяет, какие AI Chat box используют общий список разговоров. Новый блок по умолчанию использует собственный UID, чтобы разделить разговоры. |
|
||||
| `Background` | Добавляет системный промпт после определения ИИ-сотрудника, чтобы указать роль, цель или требования к ответам для текущей страницы. |
|
||||
| `Default user message` | Предварительно заполняет область отправки сообщением пользователя по умолчанию при начале нового разговора. |
|
||||
| `Work context` | Выбирает блоки страницы, которые по умолчанию добавляются в новый черновик. |
|
||||
| `AI employees` | Ограничивает бизнес-ориентированных ИИ-сотрудников, которых можно выбрать в этом блоке. Оставьте поле пустым, чтобы разрешить всех доступных бизнес-ориентированных ИИ-сотрудников. |
|
||||
| `Models` | Ограничивает модели, которые можно выбрать в этом блоке. Оставьте поле пустым, чтобы разрешить все доступные модели. |
|
||||
|
||||
### Другие настройки блока
|
||||
|
||||
| Настройка | Описание |
|
||||
| --- | --- |
|
||||
| `Show messages` | Определяет, отображается ли область сообщений непосредственно в блоке. Если параметр отключен, используйте кнопку сообщений в верхней части, чтобы открыть правую панель. |
|
||||
| `Sender placeholder` | Изменяет текст-заполнитель в области отправки. |
|
||||
| `Enable add context` | Показывает или скрывает элемент выбора контекста в области отправки. |
|
||||
| `Enable upload files` | Показывает или скрывает элемент загрузки файлов. Если параметр отключен, вставка файла также не запускает загрузку. |
|
||||
| `Enable web search` | Показывает или скрывает переключатель веб-поиска. Отключение также выключает веб-поиск для текущего черновика. |
|
||||
| `Enable employee select` | Показывает или скрывает выбор ИИ-сотрудника. |
|
||||
| `Enable model select` | Показывает или скрывает выбор модели. |
|
||||
| `Show disclaimer` | Показывает или скрывает предупреждение об ИИ под областью отправки. |
|
||||
|
||||
## Настройка Work context
|
||||
|
||||
В разделе `Work context` окна `Edit chat box` нажмите кнопку добавления контекста, выберите `Pick block`, а затем выберите блок страницы, который нужно предоставить ИИ. После сохранения выбранный блок становится рабочим контекстом по умолчанию для новых разговоров; перед отправкой его можно удалить из области отправки.
|
||||
|
||||
## Скрытие сообщений и использование правой панели
|
||||
|
||||
После отключения `Show messages` в теле блока остается только область отправки. В верхней части появляется кнопка сообщений; нажмите ее, чтобы открыть панель сообщений справа.
|
||||
|
||||

|
||||
|
||||
Когда панель открыта, остальная часть блока закрывается наложением. Нажмите на наложение или повторно нажмите кнопку сообщений, чтобы закрыть панель.
|
||||
|
||||
Такой макет подходит, если AI Chat box используется как компактная область ввода на странице: обычно видна только область отправки, а панель открывается для просмотра сообщений.
|
||||
|
||||
## Управление историей разговоров
|
||||
|
||||
Нажмите кнопку списка разговоров в левом верхнем углу блока, чтобы просмотреть историю для текущего Scope.
|
||||
|
||||
Учитывайте следующие правила:
|
||||
|
||||
- Несколько AI Chat box с одинаковым Scope могут видеть один и тот же список разговоров
|
||||
- Каждый блок по-прежнему независимо хранит текущий разговор, черновик области отправки, ИИ-сотрудника, модель, вложения и состояние контекста
|
||||
- Глобальный плавающий chatbox не фильтрует по Scope блока, поэтому не скрывает разговоры со Scope
|
||||
- После очистки Scope блок перестает фильтровать список по Scope и показывает разговоры без Scope, а также разговоры с другими Scopes
|
||||
|
||||
Обычно Scope, автоматически созданного для нового блока, достаточно, чтобы разделить историю разных ассистентов страницы. Используйте одинаковый Scope только тогда, когда несколько блоков должны совместно использовать один список разговоров.
|
||||
|
||||
## Добавление Actions
|
||||
|
||||
В режиме редактирования страницы нажмите `Actions` в верхней части блока, чтобы добавить одно из следующих действий:
|
||||
|
||||
- JS Action
|
||||
- AI employee
|
||||
|
||||
После добавления AI employee можно настроить быстрые задачи для этого сотрудника.
|
||||
|
||||
Параметр `Chat box uid` быстрой задачи определяет AI Chat box, в котором будет выполняться задача. AI employee, добавленный непосредственно внутри AI Chat box, по умолчанию ссылается на UID текущего блока.
|
||||
|
||||
Если указанный AI Chat box не подключен, NocoBase сообщает, что целевой блок не найден, и не переключается на глобальный плавающий chatbox. Подробная настройка описана в разделе [Быстрые задачи ИИ-сотрудников](../features/task.md).
|
||||
|
||||
## Настройка ассистента для отдельной страницы
|
||||
|
||||
Следующие шаги создают компактного ИИ-ассистента для страницы:
|
||||
|
||||
1. Добавьте блок AI Chat box и переместите его в подходящее место на странице.
|
||||
2. Введите Background для этой страницы в `Edit chat box`.
|
||||
3. Выберите один или несколько Work contexts.
|
||||
4. Ограничьте доступных сотрудников и модели в `AI employees` и `Models`.
|
||||
5. Выйдите из режима редактирования, введите вопрос и отправьте его.
|
||||
|
||||
## Примечания
|
||||
|
||||
- Блок AI Chat box и глобальный плавающий chatbox в правом нижнем углу — отдельные точки доступа; текущий разговор и состояние ввода не синхронизируются автоматически
|
||||
- Внутри AI Chat box через `Add block` можно добавить только JS block, Iframe и Markdown
|
||||
- Изменение Scope влияет на область запроса списка разговоров и не копирует разговор или черновик, открытый в другом блоке
|
||||
@@ -123,6 +123,11 @@
|
||||
"label": "Панель действий",
|
||||
"link": "/interface-builder/blocks/other-blocks/action-panel"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Блок Iframe",
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
---
|
||||
title: "ctx.ai"
|
||||
description: "Используйте ctx.ai в RunJS, чтобы запускать задачи AI-сотрудника: с содержимым задачи напрямую или с задачами, настроенными в действии AI-сотрудника."
|
||||
keywords: "ctx.ai,AI employee,triggerTask,triggerModelTask,RunJS,NocoBase"
|
||||
description: "Используйте ctx.ai в RunJS, чтобы запускать задачи AI-сотрудника в глобальном диалоге или в указанном AI Chat Box: с содержимым задачи напрямую или с задачами, настроенными в действии AI-сотрудника."
|
||||
keywords: "ctx.ai,AI employee,uploadFile,attachments,triggerTask,triggerModelTask,onResponseLoadingChange,chatBoxUid,AI Chat Box,RunJS,NocoBase"
|
||||
---
|
||||
|
||||
# ctx.ai
|
||||
|
||||
В RunJS `ctx.ai` используется для запуска **задач AI-сотрудника**. Это удобно в JSBlock, JSAction и других взаимодействиях, где кнопка, форма или бизнес-процесс должны передать работу конкретному AI-сотруднику.
|
||||
|
||||
`ctx.ai` только запускает задачи. Он не возвращает результат выполнения задачи. После вызова задача попадает в поток диалога AI-сотрудника.
|
||||
`ctx.ai` загружает вложения для AI-задач и запускает задачи. Завершения загрузки файла можно дождаться, но запуск задачи не возвращает результат ее выполнения. После вызова задача попадает в поток диалога AI-сотрудника.
|
||||
|
||||
:::warning Примечание
|
||||
|
||||
`ctx.ai` предоставляет AI-плагин. Если AI-плагин не включен или текущая среда RunJS не загрузила соответствующую клиентскую возможность, `ctx.ai` может отсутствовать. Перед вызовом можно проверить `ctx.ai?.triggerTask` или `ctx.ai?.triggerModelTask`.
|
||||
`ctx.ai` предоставляет AI-плагин. Если AI-плагин не включен или текущая среда RunJS не загрузила соответствующую клиентскую возможность, `ctx.ai` может отсутствовать. Перед вызовом можно проверить `ctx.ai?.uploadFile`, `ctx.ai?.triggerTask` или `ctx.ai?.triggerModelTask`.
|
||||
|
||||
:::
|
||||
|
||||
## Методы
|
||||
|
||||
### ctx.ai.uploadFile()
|
||||
|
||||
Загружает файл и возвращает объект вложения, который можно напрямую передать в задачу AI-сотрудника.
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, options);
|
||||
```
|
||||
|
||||
| Параметр | Тип | Описание |
|
||||
|------|------|------|
|
||||
| `file` | `File` | Объект файла браузера для загрузки. |
|
||||
| `options.onProgress` | `(percent: number) => void` | Callback прогресса загрузки. `percent` находится в диапазоне от `0` до `100`. |
|
||||
| `options.signal` | `AbortSignal` | Сигнал для отмены загрузки. |
|
||||
|
||||
При загрузке используется файловое хранилище, настроенное в AI-плагине, и создается запись в `aiFiles`. Возвращаемый объект содержит поля `id`, `filename`, `url`, `source` и другие:
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
console.log('upload progress', percent);
|
||||
},
|
||||
});
|
||||
|
||||
// attachment можно напрямую добавить в message.attachments
|
||||
```
|
||||
|
||||
При ошибке загрузки Promise отклоняется. Удаление вложения только из локального списка не удаляет уже созданную запись в `aiFiles`, как и в стандартном окне AI-чата.
|
||||
|
||||
### ctx.ai.triggerTask()
|
||||
|
||||
Запускает задачу AI-сотрудника напрямую.
|
||||
@@ -30,8 +58,10 @@ ctx.ai.triggerTask(options: TriggerTaskOptions): void
|
||||
|------|------|------|
|
||||
| `aiEmployee` | `string \| AIEmployee` | AI-сотрудник. Если передана строка, NocoBase ищет точное совпадение с `AIEmployee.username`, и AI-сотрудник должен быть доступен текущему пользователю. |
|
||||
| `tasks` | `Task[]` | Список задач для запуска. |
|
||||
| `chatBoxUid` | `string` | FlowModel uid блока AI Chat Box, который должен получить задачу. |
|
||||
| `open` | `boolean` | Открывать ли панель диалога AI-сотрудника. |
|
||||
| `auto` | `boolean` | Использовать ли семантику автоматического запуска действия AI-сотрудника. |
|
||||
| `onResponseLoadingChange` | `(loading: boolean) => void` | Callback состояния загрузки ответа модели. Он вызывается только при автоматической отправке этой задачи. |
|
||||
|
||||
Часто используемые поля `Task`:
|
||||
|
||||
@@ -40,12 +70,160 @@ ctx.ai.triggerTask(options: TriggerTaskOptions): void
|
||||
| `title` | `string` | Заголовок задачи. |
|
||||
| `message.system` | `string` | Системное сообщение, ограничивающее роль и требования к ответу AI-сотрудника. |
|
||||
| `message.user` | `string` | Пользовательское сообщение, то есть основная инструкция задачи. |
|
||||
| `message.attachments` | `Attachment[]` | Вложения задачи, обычно полученные из `ctx.ai.uploadFile()`. |
|
||||
| `message.workContext` | `ContextItem[]` | Контекст блоков страницы, используемый задачей. |
|
||||
| `autoSend` | `boolean` | Отправлять ли сообщение задачи автоматически. |
|
||||
| `webSearch` | `boolean` | Разрешен ли Web search для этой задачи. |
|
||||
| `model` | `{ llmService: string; model: string } \| null` | Модель, используемая этой задачей. |
|
||||
| `skillSettings` | `SkillSettings` | Настройки skills / tools для этой задачи. |
|
||||
|
||||
### Отслеживать состояние загрузки ответа
|
||||
|
||||
Передайте `onResponseLoadingChange` в параметрах верхнего уровня, чтобы отслеживать состояние загрузки ответа модели. Callback получает `true`, когда NocoBase начинает ожидать ответ, и `false`, когда ответ завершен, отменен или завершился ошибкой. Если React-компонент уже объявил `setResponseLoading` через `useState`, можно написать:
|
||||
|
||||
```tsx
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
onResponseLoadingChange(loading) {
|
||||
setResponseLoading(loading);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`onResponseLoadingChange` отслеживает только ответ модели, запущенный непосредственно этим вызовом `triggerTask()`. При `autoSend: false` задача остается в черновике чата, а callback не вызывается. Если пользователь позже отправит черновик вручную, этот callback не будет использован повторно.
|
||||
|
||||
В React-компоненте JS-блока такое изменение состояния вызывает повторный рендеринг, пока компонент остается смонтированным.
|
||||
|
||||
### Выбрать AI Chat Box
|
||||
|
||||
Укажите `chatBoxUid` в параметрах верхнего уровня `triggerTask()`, чтобы запустить задачу в смонтированном блоке AI Chat Box вместо глобального диалога AI-сотрудника.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
chatBoxUid: 'AI_CHAT_BOX_BLOCK_UID',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
uid должен принадлежать внешнему блоку AI Chat Box, смонтированному на текущей странице. Не помещайте это значение маршрутизации в `tasks`. Если целевой блок не найден, NocoBase сообщает об ошибке и не переключается на глобальный диалог. Если `chatBoxUid` не указан, задача запускается в глобальном диалоге AI-сотрудника.
|
||||
|
||||
### Загрузить и отправить вложения в JSBlock
|
||||
|
||||
В следующем примере JSBlock отображает загрузку файлов, инструкции к задаче и кнопку отправки. Загруженные файлы передаются AI-сотруднику через `message.attachments`:
|
||||
|
||||
```tsx
|
||||
if (!ctx.ai?.uploadFile || !ctx.ai?.triggerTask) {
|
||||
ctx.message.error(ctx.t('AI employee task API is not available.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { React } = ctx.libs;
|
||||
const { useState } = React;
|
||||
const { Button, Card, Input, Space, Upload } = ctx.libs.antd;
|
||||
const { InboxOutlined, SendOutlined } = ctx.libs.antdIcons;
|
||||
|
||||
const AttachmentTask = () => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [fileList, setFileList] = useState([]);
|
||||
|
||||
const uploadAttachment = async ({ file, onError, onProgress, onSuccess }) => {
|
||||
try {
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
onProgress?.({ percent });
|
||||
},
|
||||
});
|
||||
onSuccess?.(attachment);
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error : new Error(ctx.t('File upload failed')));
|
||||
}
|
||||
};
|
||||
|
||||
const sendTask = () => {
|
||||
const attachments = fileList
|
||||
.filter((file) => file.status === 'done' && file.response)
|
||||
.map((file) => file.response);
|
||||
|
||||
if (!prompt.trim()) {
|
||||
ctx.message.warning(ctx.t('Enter task instructions'));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'viz',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Analyze uploaded files'),
|
||||
message: {
|
||||
user: prompt.trim(),
|
||||
attachments,
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
setPrompt('');
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const uploading = fileList.some((file) => file.status === 'uploading');
|
||||
|
||||
return (
|
||||
<Card title={ctx.t('AI file analysis')}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
fileList={fileList}
|
||||
customRequest={uploadAttachment}
|
||||
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p>{ctx.t('Click or drag files here to upload')}</p>
|
||||
</Upload.Dragger>
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={ctx.t('Describe the task for the AI employee')}
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
disabled={uploading || !prompt.trim()}
|
||||
onClick={sendTask}
|
||||
>
|
||||
{ctx.t('Send to AI')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
ctx.render(<AttachmentTask />);
|
||||
```
|
||||
|
||||
При `autoSend: false` вложения и инструкции к задаче помещаются в черновик AI-чата и не отправляются сразу.
|
||||
|
||||
### Добавить контекст блока страницы
|
||||
|
||||
`message.workContext` сейчас используется для передачи блоков страницы. Укажите в нем FlowModel uid целевого блока:
|
||||
@@ -151,6 +329,8 @@ ctx.message.success(ctx.t('AI employee task triggered.'));
|
||||
|
||||
Считывает задачу из модели действия AI-сотрудника на странице и запускает ее.
|
||||
|
||||
Публичные параметры `triggerModelTask()` не принимают `chatBoxUid`. Чтобы выбрать AI Chat Box, настройте `chatBoxUid` в предустановленной задаче действия AI-сотрудника. `triggerModelTask()` продолжит использовать это предустановленное значение.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTaskOptions): void
|
||||
```
|
||||
@@ -161,6 +341,10 @@ ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTa
|
||||
| `taskIndex` | `number` | Индекс задачи, начиная с `0`. |
|
||||
| `options.open` | `boolean` | Открывать ли панель диалога AI-сотрудника. |
|
||||
| `options.auto` | `boolean` | Использовать ли семантику автоматического запуска действия AI-сотрудника. |
|
||||
| `options.attachments` | `Attachment[]` | Вложения, динамически добавляемые к настроенной задаче. |
|
||||
| `options.onResponseLoadingChange` | `(loading: boolean) => void` | Callback состояния загрузки ответа модели. Он вызывается только при автоматической отправке настроенной задачи. |
|
||||
|
||||
`options.onResponseLoadingChange` работает так же, как в `triggerTask()`. Его вызов зависит от значения `autoSend` настроенной задачи. При `autoSend: false` callback не вызывается.
|
||||
|
||||
```ts
|
||||
if (!ctx.ai?.triggerModelTask) {
|
||||
@@ -172,6 +356,7 @@ const weeklyReviewActionUid = 'AI_EMPLOYEE_ACTION_MODEL_UID';
|
||||
|
||||
ctx.ai.triggerModelTask(weeklyReviewActionUid, 0, {
|
||||
open: true,
|
||||
attachments,
|
||||
});
|
||||
|
||||
ctx.message.success(ctx.t('Configured AI employee task triggered.'));
|
||||
@@ -182,9 +367,14 @@ ctx.message.success(ctx.t('Configured AI employee task triggered.'));
|
||||
## Примечания
|
||||
|
||||
- `triggerTask()` и `triggerModelTask()` работают как fire-and-forget. Они не возвращают результат выполнения задачи.
|
||||
- `uploadFile()` возвращает Promise. Дождитесь завершения загрузки, прежде чем запускать задачу с этим вложением.
|
||||
- Строка `aiEmployee` сопоставляется только с точным значением `AIEmployee.username`.
|
||||
- `triggerModelTask()` использует `taskIndex`, начинающийся с `0`.
|
||||
- `message.workContext` сейчас описывает только контекст блоков страницы.
|
||||
- Значение верхнего уровня `triggerTask().chatBoxUid` должно ссылаться на блок AI Chat Box, смонтированный на текущей странице.
|
||||
- `triggerModelTask()` продолжает использовать `chatBoxUid`, настроенный в предустановленной задаче.
|
||||
- Динамические вложения `triggerModelTask()` добавляются к существующим `message.attachments` предустановленной задачи без изменения сохраненной конфигурации.
|
||||
- `onResponseLoadingChange` отслеживает только ответ модели, автоматически отправленный текущим вызовом. Он не отслеживает сообщение, которое пользователь отправит вручную позже.
|
||||
|
||||
## Связанные разделы
|
||||
|
||||
|
||||
@@ -80,6 +80,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Block trang",
|
||||
"items": [
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Nhân viên AI tích hợp sẵn",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
pkg: '@nocobase/plugin-ai'
|
||||
title: 'Block AI Chat box'
|
||||
description: 'Hướng dẫn dành cho quản trị viên và người xây dựng trang NocoBase về cách thêm block AI Chat box, cấu hình khả năng hội thoại, Work context, lịch sử hội thoại và Actions.'
|
||||
keywords: 'AI Chat box,Nhân viên AI,block trang,Work context,Scope,Actions,NocoBase'
|
||||
---
|
||||
|
||||
# Block AI Chat box
|
||||
|
||||
Trong NocoBase, **AI Chat box** là block hội thoại AI có thể được thêm trực tiếp vào trang. Bạn có thể đặt block này trên trang nghiệp vụ để cung cấp một lối vào cố định cho trợ lý AI dành riêng cho trang đó.
|
||||
|
||||
Mỗi block AI Chat box có hội thoại hiện tại và trạng thái nhập liệu riêng. Người xây dựng trang cũng có thể giới hạn Nhân viên AI, mô hình, tải tệp lên, tìm kiếm web và ngữ cảnh làm việc khả dụng để phù hợp với từng tình huống nghiệp vụ.
|
||||
|
||||
:::tip Chuẩn bị trước khi sử dụng
|
||||
|
||||
Trước tiên, hãy [cấu hình dịch vụ LLM](../features/llm-service.md) và [kích hoạt ít nhất một Nhân viên AI](../features/enable-ai-employee.md).
|
||||
|
||||
:::
|
||||
|
||||
## Thêm block AI Chat box
|
||||
|
||||
1. Mở trang cần cấu hình.
|
||||
2. Nhấp `UI Editor` ở góc trên bên phải để vào chế độ chỉnh sửa trang.
|
||||
3. Nhấp `Add block`.
|
||||
4. Trong `Other blocks`, chọn `AI chat box`.
|
||||
|
||||

|
||||
|
||||
## Cấu trúc block
|
||||
|
||||

|
||||
|
||||
AI Chat box gồm ba khu vực từ trên xuống dưới:
|
||||
|
||||
- **Khu vực thao tác phía trên** — lối vào danh sách hội thoại, Actions, thao tác tùy chỉnh và nút tạo hội thoại mới; khi khu vực tin nhắn bị ẩn, nút tin nhắn cũng được hiển thị
|
||||
- **Khu vực tin nhắn** — hiển thị tin nhắn trong bản nháp hoặc hội thoại hiện tại
|
||||
- **Khu vực gửi** — ô nhập, chọn ngữ cảnh, tải tệp lên, tìm kiếm web, chọn Nhân viên AI, chọn mô hình, nút gửi và tuyên bố miễn trừ trách nhiệm
|
||||
|
||||
### Thêm nội dung vào body của block
|
||||
|
||||
Trong chế độ chỉnh sửa trang, nhấp `Add block` bên trong AI Chat box để thêm một trong các block sau phía trên khu vực chat:
|
||||
|
||||
- JS block
|
||||
- Iframe
|
||||
- Markdown
|
||||
|
||||
Các block này phù hợp để hiển thị hướng dẫn, trang bên ngoài hoặc thông tin bổ trợ. Menu bên trong chỉ cung cấp ba loại block này và không cho phép lồng thêm một AI Chat box khác.
|
||||
|
||||
## Cấu hình AI Chat box
|
||||
|
||||
Di chuyển con trỏ lên block và mở menu cài đặt. Nhấp `Edit chat box` để cấu hình phạm vi hội thoại, tin nhắn mặc định, Work context, Nhân viên AI và mô hình.
|
||||
|
||||

|
||||
|
||||
### Cài đặt Edit chat box
|
||||
|
||||
| Cài đặt | Mô tả |
|
||||
| --- | --- |
|
||||
| `Scope` | Kiểm soát những AI Chat box dùng chung danh sách hội thoại. Block mới mặc định sử dụng UID của chính nó để tách biệt các hội thoại. |
|
||||
| `Background` | Thêm system prompt sau định nghĩa Nhân viên AI để cung cấp vai trò, mục tiêu hoặc yêu cầu trả lời cho trang hiện tại. |
|
||||
| `Default user message` | Điền sẵn tin nhắn người dùng mặc định vào khu vực gửi khi bắt đầu hội thoại mới. |
|
||||
| `Work context` | Chọn các block trang được đưa vào bản nháp mới theo mặc định. |
|
||||
| `AI employees` | Giới hạn các Nhân viên AI nghiệp vụ có thể được chọn trong block này. Để trống để cho phép tất cả Nhân viên AI nghiệp vụ khả dụng. |
|
||||
| `Models` | Giới hạn các mô hình có thể được chọn trong block này. Để trống để cho phép tất cả mô hình khả dụng. |
|
||||
|
||||
### Các cài đặt block khác
|
||||
|
||||
| Cài đặt | Mô tả |
|
||||
| --- | --- |
|
||||
| `Show messages` | Kiểm soát việc khu vực tin nhắn có hiển thị trực tiếp trong block hay không. Khi tắt, sử dụng nút tin nhắn ở phía trên để mở panel bên phải. |
|
||||
| `Sender placeholder` | Thay đổi văn bản placeholder trong khu vực gửi. |
|
||||
| `Enable add context` | Hiển thị hoặc ẩn lối vào chọn ngữ cảnh trong khu vực gửi. |
|
||||
| `Enable upload files` | Hiển thị hoặc ẩn lối vào tải tệp lên. Khi tắt, việc dán tệp cũng không bắt đầu tải lên. |
|
||||
| `Enable web search` | Hiển thị hoặc ẩn công tắc tìm kiếm web. Việc tắt cũng sẽ tắt tìm kiếm web cho bản nháp hiện tại. |
|
||||
| `Enable employee select` | Hiển thị hoặc ẩn bộ chọn Nhân viên AI. |
|
||||
| `Enable model select` | Hiển thị hoặc ẩn bộ chọn mô hình. |
|
||||
| `Show disclaimer` | Hiển thị hoặc ẩn tuyên bố AI bên dưới khu vực gửi. |
|
||||
|
||||
## Cấu hình Work context
|
||||
|
||||
Trong `Work context` của `Edit chat box`, nhấp nút thêm ngữ cảnh, chọn `Pick block`, sau đó chọn block trang muốn cung cấp cho AI. Sau khi lưu, block đã chọn trở thành ngữ cảnh làm việc mặc định cho các hội thoại mới và có thể được xóa khỏi khu vực gửi trước khi gửi tin nhắn.
|
||||
|
||||
## Ẩn tin nhắn và sử dụng panel bên phải
|
||||
|
||||
Sau khi tắt `Show messages`, body của block chỉ giữ lại khu vực gửi. Một nút tin nhắn xuất hiện ở phía trên; nhấp vào nút đó để mở panel tin nhắn từ bên phải.
|
||||
|
||||

|
||||
|
||||
Khi panel mở, phần còn lại của block được phủ bởi một lớp overlay. Nhấp vào overlay hoặc nhấp lại nút tin nhắn để đóng panel.
|
||||
|
||||
Bố cục này phù hợp khi AI Chat box được sử dụng như một lối nhập liệu gọn nhẹ trên trang: thông thường chỉ hiển thị khu vực gửi và mở panel khi cần xem lại tin nhắn.
|
||||
|
||||
## Quản lý lịch sử hội thoại
|
||||
|
||||
Nhấp nút danh sách hội thoại ở góc trên bên trái của block để xem lịch sử trong Scope hiện tại.
|
||||
|
||||
Lưu ý các quy tắc sau:
|
||||
|
||||
- Nhiều AI Chat box có cùng Scope có thể xem cùng một danh sách hội thoại
|
||||
- Mỗi block vẫn có hội thoại hiện tại, bản nháp khu vực gửi, Nhân viên AI, mô hình, tệp đính kèm và trạng thái ngữ cảnh riêng
|
||||
- Chatbox nổi toàn cục không lọc theo Scope của block, do đó không ẩn các hội thoại có Scope
|
||||
- Sau khi xóa Scope, block không còn lọc danh sách theo Scope và sẽ hiển thị cả hội thoại không có Scope lẫn hội thoại sử dụng các Scope khác
|
||||
|
||||
Thông thường, giữ nguyên Scope được tạo cho block mới là đủ để tách lịch sử của từng trợ lý trang. Chỉ cấu hình cùng một Scope khi nhiều block cần dùng chung danh sách hội thoại.
|
||||
|
||||
## Thêm Actions
|
||||
|
||||
Trong chế độ chỉnh sửa trang, nhấp `Actions` ở phía trên block để thêm một trong các thao tác sau:
|
||||
|
||||
- JS Action
|
||||
- AI employee
|
||||
|
||||
Sau khi thêm AI employee, bạn có thể cấu hình tác vụ nhanh cho nhân viên đó.
|
||||
|
||||
Cài đặt `Chat box uid` trong tác vụ nhanh xác định AI Chat box sẽ chạy tác vụ. AI employee được thêm trực tiếp bên trong AI Chat box mặc định trỏ đến UID của block hiện tại.
|
||||
|
||||
Nếu AI Chat box đã chỉ định chưa được gắn, NocoBase sẽ thông báo không tìm thấy block đích và không chuyển sang chatbox nổi toàn cục. Xem [Tác vụ nhanh của Nhân viên AI](../features/task.md) để biết cấu hình chi tiết.
|
||||
|
||||
## Cấu hình trợ lý riêng cho trang
|
||||
|
||||
Các bước sau tạo một trợ lý AI gọn nhẹ cho trang:
|
||||
|
||||
1. Thêm block AI Chat box và di chuyển đến vị trí phù hợp trên trang.
|
||||
2. Nhập Background riêng cho trang trong `Edit chat box`.
|
||||
3. Chọn một hoặc nhiều Work contexts.
|
||||
4. Giới hạn nhân viên và mô hình khả dụng trong `AI employees` và `Models`.
|
||||
5. Thoát chế độ chỉnh sửa, nhập câu hỏi và gửi.
|
||||
|
||||
## Lưu ý
|
||||
|
||||
- Block AI Chat box và chatbox nổi toàn cục ở góc dưới bên phải là hai lối vào riêng biệt; hội thoại hiện tại và trạng thái nhập liệu không tự động đồng bộ
|
||||
- Bên trong AI Chat box, `Add block` chỉ cho phép thêm JS block, Iframe và Markdown
|
||||
- Thay đổi Scope ảnh hưởng đến phạm vi truy vấn danh sách hội thoại và không sao chép hội thoại hoặc bản nháp đang mở trong block khác
|
||||
@@ -123,6 +123,11 @@
|
||||
"label": "Action panel",
|
||||
"link": "/interface-builder/blocks/other-blocks/action-panel"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "AI Chat box",
|
||||
"link": "/ai-employees/block/ai-chat-box"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Iframe",
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
---
|
||||
title: "ctx.ai"
|
||||
description: "Dùng ctx.ai trong RunJS để kích hoạt tác vụ nhân viên AI, bằng nội dung tác vụ trực tiếp hoặc bằng tác vụ đã cấu hình trên hành động nhân viên AI."
|
||||
keywords: "ctx.ai,AI employee,triggerTask,triggerModelTask,RunJS,NocoBase"
|
||||
description: "Dùng ctx.ai trong RunJS để kích hoạt tác vụ nhân viên AI trong hội thoại toàn cục hoặc một AI Chat Box được chỉ định, bằng nội dung trực tiếp hoặc tác vụ đã cấu hình trên hành động nhân viên AI."
|
||||
keywords: "ctx.ai,AI employee,uploadFile,attachments,triggerTask,triggerModelTask,onResponseLoadingChange,chatBoxUid,AI Chat Box,RunJS,NocoBase"
|
||||
---
|
||||
|
||||
# ctx.ai
|
||||
|
||||
Trong RunJS, `ctx.ai` dùng để kích hoạt **tác vụ nhân viên AI**. API này phù hợp với JSBlock, JSAction và các tương tác nơi nút bấm, biểu mẫu hoặc quy trình nghiệp vụ cần giao việc cho một nhân viên AI cụ thể.
|
||||
|
||||
`ctx.ai` chỉ kích hoạt tác vụ. Nó không trả về kết quả thực thi của tác vụ. Sau khi gọi, tác vụ sẽ đi vào luồng hội thoại của nhân viên AI.
|
||||
`ctx.ai` tải tệp đính kèm cho tác vụ AI và kích hoạt tác vụ. Có thể chờ quá trình tải tệp hoàn tất, nhưng việc kích hoạt tác vụ không trả về kết quả thực thi. Sau khi gọi, tác vụ sẽ đi vào luồng hội thoại của nhân viên AI.
|
||||
|
||||
:::warning Lưu ý
|
||||
|
||||
`ctx.ai` được cung cấp bởi plugin AI. Nếu plugin AI chưa được bật, hoặc môi trường RunJS hiện tại chưa tải năng lực client tương ứng, `ctx.ai` có thể không tồn tại. Bạn có thể kiểm tra `ctx.ai?.triggerTask` hoặc `ctx.ai?.triggerModelTask` trước khi gọi.
|
||||
`ctx.ai` được cung cấp bởi plugin AI. Nếu plugin AI chưa được bật, hoặc môi trường RunJS hiện tại chưa tải năng lực client tương ứng, `ctx.ai` có thể không tồn tại. Bạn có thể kiểm tra `ctx.ai?.uploadFile`, `ctx.ai?.triggerTask` hoặc `ctx.ai?.triggerModelTask` trước khi gọi.
|
||||
|
||||
:::
|
||||
|
||||
## Phương thức
|
||||
|
||||
### ctx.ai.uploadFile()
|
||||
|
||||
Tải một tệp lên và trả về đối tượng tệp đính kèm có thể truyền trực tiếp cho tác vụ nhân viên AI.
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, options);
|
||||
```
|
||||
|
||||
| Tham số | Kiểu | Mô tả |
|
||||
|------|------|------|
|
||||
| `file` | `File` | Đối tượng tệp của trình duyệt cần tải lên. |
|
||||
| `options.onProgress` | `(percent: number) => void` | Callback tiến trình tải lên. `percent` nằm trong khoảng từ `0` đến `100`. |
|
||||
| `options.signal` | `AbortSignal` | Signal dùng để hủy tải lên. |
|
||||
|
||||
Quá trình tải lên dùng kho lưu trữ tệp được cấu hình bởi plugin AI và tạo một bản ghi trong `aiFiles`. Đối tượng trả về gồm các trường như `id`, `filename`, `url` và `source`:
|
||||
|
||||
```ts
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
console.log('upload progress', percent);
|
||||
},
|
||||
});
|
||||
|
||||
// attachment có thể được đặt trực tiếp trong message.attachments
|
||||
```
|
||||
|
||||
Promise sẽ bị reject khi tải lên thất bại. Việc xóa tệp đính kèm khỏi danh sách cục bộ không xóa bản ghi đã tạo trong `aiFiles`, giống với cửa sổ chat AI mặc định.
|
||||
|
||||
### ctx.ai.triggerTask()
|
||||
|
||||
Kích hoạt trực tiếp một tác vụ nhân viên AI.
|
||||
@@ -30,8 +58,10 @@ ctx.ai.triggerTask(options: TriggerTaskOptions): void
|
||||
|------|------|------|
|
||||
| `aiEmployee` | `string \| AIEmployee` | Nhân viên AI. Khi truyền chuỗi, NocoBase khớp chính xác với `AIEmployee.username`, và nhân viên AI đó phải khả dụng với người dùng hiện tại. |
|
||||
| `tasks` | `Task[]` | Danh sách tác vụ cần kích hoạt. |
|
||||
| `chatBoxUid` | `string` | uid FlowModel của khối AI Chat Box sẽ nhận tác vụ. |
|
||||
| `open` | `boolean` | Có mở bảng hội thoại nhân viên AI hay không. |
|
||||
| `auto` | `boolean` | Có dùng ngữ nghĩa tự động kích hoạt của hành động nhân viên AI hay không. |
|
||||
| `onResponseLoadingChange` | `(loading: boolean) => void` | Callback trạng thái tải phản hồi của mô hình. Chỉ được gọi khi tác vụ này được gửi tự động. |
|
||||
|
||||
Các trường thường dùng của `Task`:
|
||||
|
||||
@@ -40,12 +70,160 @@ Các trường thường dùng của `Task`:
|
||||
| `title` | `string` | Tiêu đề tác vụ. |
|
||||
| `message.system` | `string` | Tin nhắn hệ thống dùng để ràng buộc vai trò và yêu cầu đầu ra của nhân viên AI. |
|
||||
| `message.user` | `string` | Tin nhắn người dùng, tức chỉ dẫn chính của tác vụ. |
|
||||
| `message.attachments` | `Attachment[]` | Tệp đính kèm mà tác vụ sử dụng, thường được trả về từ `ctx.ai.uploadFile()`. |
|
||||
| `message.workContext` | `ContextItem[]` | Ngữ cảnh khối trang được tác vụ sử dụng. |
|
||||
| `autoSend` | `boolean` | Có tự động gửi tin nhắn tác vụ hay không. |
|
||||
| `webSearch` | `boolean` | Có cho phép tác vụ dùng Web search hay không. |
|
||||
| `model` | `{ llmService: string; model: string } \| null` | Mô hình dùng cho tác vụ này. |
|
||||
| `skillSettings` | `SkillSettings` | Cấu hình skills / tools của tác vụ này. |
|
||||
|
||||
### Theo dõi trạng thái tải phản hồi
|
||||
|
||||
Truyền `onResponseLoadingChange` trong tùy chọn cấp cao nhất để theo dõi trạng thái tải phản hồi của mô hình. Callback nhận `true` khi NocoBase bắt đầu chờ phản hồi, sau đó nhận `false` khi phản hồi hoàn tất, bị hủy hoặc thất bại. Nếu component React đã khai báo `setResponseLoading` bằng `useState`, bạn có thể viết:
|
||||
|
||||
```tsx
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
onResponseLoadingChange(loading) {
|
||||
setResponseLoading(loading);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`onResponseLoadingChange` chỉ theo dõi phản hồi được bắt đầu trực tiếp bởi lần gọi `triggerTask()` này. Với `autoSend: false`, tác vụ chỉ được đưa vào bản nháp chat và callback không được gọi. Nếu người dùng gửi bản nháp thủ công sau đó, lần gửi này không sử dụng lại callback.
|
||||
|
||||
Trong component React của khối JS, thay đổi trạng thái này sẽ kích hoạt render lại khi component vẫn được gắn.
|
||||
|
||||
### Chỉ định AI Chat Box
|
||||
|
||||
Đặt `chatBoxUid` trong tùy chọn cấp cao nhất của `triggerTask()` để kích hoạt tác vụ trong một khối AI Chat Box đã được gắn trên trang, thay vì mở hộp thoại nhân viên AI toàn cục.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'nathan',
|
||||
chatBoxUid: 'AI_CHAT_BOX_BLOCK_UID',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Review current page'),
|
||||
message: {
|
||||
user: 'Review the current page and summarize the main risks.',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
uid phải thuộc về khối AI Chat Box bên ngoài hiện đang được gắn trên trang. Không đặt giá trị định tuyến này bên trong `tasks`. Nếu không tìm thấy khối đích, NocoBase sẽ báo lỗi và không quay về hộp thoại toàn cục. Khi bỏ qua `chatBoxUid`, tác vụ sẽ dùng hộp thoại nhân viên AI toàn cục.
|
||||
|
||||
### Tải lên và gửi tệp đính kèm trong JSBlock
|
||||
|
||||
Ví dụ sau render phần tải tệp, chỉ dẫn tác vụ và nút gửi trong JSBlock. Các tệp đã tải lên được truyền cho nhân viên AI qua `message.attachments`:
|
||||
|
||||
```tsx
|
||||
if (!ctx.ai?.uploadFile || !ctx.ai?.triggerTask) {
|
||||
ctx.message.error(ctx.t('AI employee task API is not available.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { React } = ctx.libs;
|
||||
const { useState } = React;
|
||||
const { Button, Card, Input, Space, Upload } = ctx.libs.antd;
|
||||
const { InboxOutlined, SendOutlined } = ctx.libs.antdIcons;
|
||||
|
||||
const AttachmentTask = () => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [fileList, setFileList] = useState([]);
|
||||
|
||||
const uploadAttachment = async ({ file, onError, onProgress, onSuccess }) => {
|
||||
try {
|
||||
const attachment = await ctx.ai.uploadFile(file, {
|
||||
onProgress(percent) {
|
||||
onProgress?.({ percent });
|
||||
},
|
||||
});
|
||||
onSuccess?.(attachment);
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error : new Error(ctx.t('File upload failed')));
|
||||
}
|
||||
};
|
||||
|
||||
const sendTask = () => {
|
||||
const attachments = fileList
|
||||
.filter((file) => file.status === 'done' && file.response)
|
||||
.map((file) => file.response);
|
||||
|
||||
if (!prompt.trim()) {
|
||||
ctx.message.warning(ctx.t('Enter task instructions'));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ai.triggerTask({
|
||||
aiEmployee: 'viz',
|
||||
open: true,
|
||||
tasks: [
|
||||
{
|
||||
title: ctx.t('Analyze uploaded files'),
|
||||
message: {
|
||||
user: prompt.trim(),
|
||||
attachments,
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
setPrompt('');
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const uploading = fileList.some((file) => file.status === 'uploading');
|
||||
|
||||
return (
|
||||
<Card title={ctx.t('AI file analysis')}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
fileList={fileList}
|
||||
customRequest={uploadAttachment}
|
||||
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p>{ctx.t('Click or drag files here to upload')}</p>
|
||||
</Upload.Dragger>
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={ctx.t('Describe the task for the AI employee')}
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
disabled={uploading || !prompt.trim()}
|
||||
onClick={sendTask}
|
||||
>
|
||||
{ctx.t('Send to AI')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
ctx.render(<AttachmentTask />);
|
||||
```
|
||||
|
||||
Với `autoSend: false`, tệp đính kèm và chỉ dẫn tác vụ được đưa vào bản nháp chat AI và không được gửi ngay.
|
||||
|
||||
### Thêm ngữ cảnh khối trang
|
||||
|
||||
`message.workContext` hiện dùng để truyền khối trang. Hãy đặt uid FlowModel của khối đích vào đây:
|
||||
@@ -151,6 +329,8 @@ Nếu `aiEmployee` là chuỗi, NocoBase sẽ khớp chính xác theo `username`
|
||||
|
||||
Đọc một tác vụ từ model hành động nhân viên AI trên trang và kích hoạt nó.
|
||||
|
||||
Tùy chọn công khai của `triggerModelTask()` không nhận `chatBoxUid`. Để chỉ định AI Chat Box, hãy cấu hình `chatBoxUid` trên tác vụ đặt trước của hành động nhân viên AI. `triggerModelTask()` tiếp tục sử dụng giá trị đặt trước này.
|
||||
|
||||
```ts
|
||||
ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTaskOptions): void
|
||||
```
|
||||
@@ -161,6 +341,10 @@ ctx.ai.triggerModelTask(uid: string, taskIndex: number, options?: TriggerModelTa
|
||||
| `taskIndex` | `number` | Chỉ số tác vụ, bắt đầu từ `0`. |
|
||||
| `options.open` | `boolean` | Có mở bảng hội thoại nhân viên AI hay không. |
|
||||
| `options.auto` | `boolean` | Có dùng ngữ nghĩa tự động kích hoạt của hành động nhân viên AI hay không. |
|
||||
| `options.attachments` | `Attachment[]` | Tệp đính kèm được thêm động vào tác vụ đã cấu hình. |
|
||||
| `options.onResponseLoadingChange` | `(loading: boolean) => void` | Callback trạng thái tải phản hồi của mô hình. Chỉ được gọi khi tác vụ đã cấu hình được gửi tự động. |
|
||||
|
||||
`options.onResponseLoadingChange` hoạt động giống tùy chọn của `triggerTask()`. Việc callback có được gọi hay không phụ thuộc vào giá trị `autoSend` của tác vụ đã cấu hình. Callback không được gọi khi tác vụ dùng `autoSend: false`.
|
||||
|
||||
```ts
|
||||
if (!ctx.ai?.triggerModelTask) {
|
||||
@@ -172,6 +356,7 @@ const weeklyReviewActionUid = 'AI_EMPLOYEE_ACTION_MODEL_UID';
|
||||
|
||||
ctx.ai.triggerModelTask(weeklyReviewActionUid, 0, {
|
||||
open: true,
|
||||
attachments,
|
||||
});
|
||||
|
||||
ctx.message.success(ctx.t('Configured AI employee task triggered.'));
|
||||
@@ -182,9 +367,14 @@ Nếu model đích không tồn tại, chưa cấu hình nhân viên AI, hoặc
|
||||
## Lưu ý
|
||||
|
||||
- `triggerTask()` và `triggerModelTask()` là fire-and-forget. Chúng không trả về kết quả thực thi tác vụ.
|
||||
- `uploadFile()` trả về Promise. Hãy chờ tải lên hoàn tất trước khi kích hoạt tác vụ sử dụng tệp đính kèm.
|
||||
- Chuỗi `aiEmployee` chỉ khớp chính xác với `AIEmployee.username`.
|
||||
- `triggerModelTask()` dùng `taskIndex` bắt đầu từ `0`.
|
||||
- `message.workContext` hiện chỉ mô tả ngữ cảnh khối trang.
|
||||
- `triggerTask().chatBoxUid` ở cấp cao nhất phải tham chiếu đến một khối AI Chat Box đang được gắn trên trang.
|
||||
- `triggerModelTask()` tiếp tục dùng `chatBoxUid` được cấu hình trên tác vụ đặt trước.
|
||||
- Tệp đính kèm động của `triggerModelTask()` được nối vào `message.attachments` hiện có của tác vụ đặt trước mà không thay đổi cấu hình đã lưu.
|
||||
- `onResponseLoadingChange` chỉ theo dõi phản hồi mô hình được gửi tự động bởi lần gọi hiện tại. Callback không theo dõi tin nhắn được người dùng gửi thủ công sau đó.
|
||||
|
||||
## Liên quan
|
||||
|
||||
|
||||
+7
-7
@@ -10,7 +10,7 @@
|
||||
import { FormItem } from '@formily/antd-v5';
|
||||
import { createForm } from '@formily/core';
|
||||
import { createSchemaField, FormProvider, useField, useFieldSchema } from '@formily/react';
|
||||
import { render, screen, userEvent, waitFor } from '@nocobase/test/client';
|
||||
import { fireEvent, render, screen, waitFor } from '@nocobase/test/client';
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { InternalCascadeSelect } from '../InternalCascadeSelect';
|
||||
@@ -151,8 +151,8 @@ describe('InternalCascadeSelect', () => {
|
||||
</FormProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(container.querySelector('.ant-select-selector'));
|
||||
await userEvent.click(await screen.findByText('2', { selector: '.ant-select-item-option-content' }));
|
||||
fireEvent.mouseDown(container.querySelector('.ant-select-selector'));
|
||||
fireEvent.click(await screen.findByText('2', { selector: '.ant-select-item-option-content' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Array.isArray(form.values.org_m2o_tree)).toBe(false);
|
||||
@@ -184,8 +184,8 @@ describe('InternalCascadeSelect', () => {
|
||||
</FormProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(container.querySelector('.ant-select-selector'));
|
||||
await userEvent.click(await screen.findByText('1', { selector: '.ant-select-item-option-content' }));
|
||||
fireEvent.mouseDown(container.querySelector('.ant-select-selector'));
|
||||
fireEvent.click(await screen.findByText('1', { selector: '.ant-select-item-option-content' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Array.isArray(form.values.org_m2o_tree)).toBe(false);
|
||||
@@ -196,8 +196,8 @@ describe('InternalCascadeSelect', () => {
|
||||
expect(container.querySelectorAll('.ant-select-selector')).toHaveLength(2);
|
||||
});
|
||||
|
||||
await userEvent.click(container.querySelectorAll('.ant-select-selector')[1]);
|
||||
await userEvent.click(await screen.findByText('2', { selector: '.ant-select-item-option-content' }));
|
||||
fireEvent.mouseDown(container.querySelectorAll('.ant-select-selector')[1]);
|
||||
fireEvent.click(await screen.findByText('2', { selector: '.ant-select-item-option-content' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Array.isArray(form.values.org_m2o_tree)).toBe(false);
|
||||
|
||||
+179
-26
@@ -12,13 +12,22 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AIEmployee, Task } from '../ai-employees/types';
|
||||
import { AIEmployeeShortcut } from '../ai-employees/AIEmployeeShortcut';
|
||||
import {
|
||||
ChatBoxRuntimeProvider,
|
||||
createChatBoxRuntime,
|
||||
getGlobalChatBoxRuntime,
|
||||
type ChatBoxRuntime,
|
||||
} from '../ai-employees/chatbox/stores/runtime';
|
||||
import { clearMountedChatBoxes, registerMountedChatBox } from '../ai-employees/chatbox/stores/mounted-chat-boxes';
|
||||
import { AIEmployeeButtonModel } from '../models/ai-employees';
|
||||
|
||||
const triggerTask = vi.fn().mockResolvedValue(undefined);
|
||||
const clear = vi.fn();
|
||||
const addContextItems = vi.fn();
|
||||
const addExistingConversationContextItems = vi.fn();
|
||||
const syncContextAttachments = vi.fn();
|
||||
let currentConversation: string | undefined;
|
||||
const messageError = vi.fn();
|
||||
const actionRuntimes: ChatBoxRuntime[] = [];
|
||||
const chatFor = vi.fn((sessionId?: string) => ({
|
||||
addContextItems: sessionId ? addExistingConversationContextItems : addContextItems,
|
||||
}));
|
||||
@@ -42,12 +51,16 @@ vi.mock('antd', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useRequest: () => ({
|
||||
data: [employee],
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock('ahooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('ahooks')>('ahooks');
|
||||
return {
|
||||
...actual,
|
||||
useRequest: () => ({
|
||||
data: [employee],
|
||||
loading: false,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@nocobase/flow-engine', async () => {
|
||||
const actual = await vi.importActual<typeof import('@nocobase/flow-engine')>('@nocobase/flow-engine');
|
||||
@@ -67,15 +80,26 @@ vi.mock('@nocobase/flow-engine', async () => {
|
||||
model: {
|
||||
parent: undefined,
|
||||
},
|
||||
message: {
|
||||
error: messageError,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../locale', () => ({
|
||||
tExpr: (text: string) => text,
|
||||
useT: () => (text: string, params?: { uid?: string }) => (params?.uid ? `${text}:${params.uid}` : text),
|
||||
}));
|
||||
|
||||
vi.mock('../ai-employees/chatbox/hooks/useChatBoxActions', () => ({
|
||||
useChatBoxActions: () => ({
|
||||
clear,
|
||||
triggerTask,
|
||||
}),
|
||||
useChatBoxActions: (runtime: ChatBoxRuntime) => {
|
||||
actionRuntimes.push(runtime);
|
||||
return {
|
||||
clear,
|
||||
triggerTask,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../ai-employees/chatbox/hooks/useChatMessageActions', () => ({
|
||||
@@ -91,15 +115,6 @@ vi.mock('../ai-employees/chatbox/hooks/useChat', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../ai-employees/chatbox/stores/chat-conversations', () => ({
|
||||
useChatConversationsStore: {
|
||||
use: {
|
||||
currentConversation: () => currentConversation,
|
||||
},
|
||||
getState: () => ({ currentConversation }),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AIEmployeeShortcut', () => {
|
||||
beforeEach(() => {
|
||||
triggerTask.mockReset().mockResolvedValue(undefined);
|
||||
@@ -107,15 +122,26 @@ describe('AIEmployeeShortcut', () => {
|
||||
addContextItems.mockClear();
|
||||
addExistingConversationContextItems.mockClear();
|
||||
syncContextAttachments.mockClear();
|
||||
messageError.mockClear();
|
||||
actionRuntimes.length = 0;
|
||||
chatFor.mockClear();
|
||||
currentConversation = undefined;
|
||||
clearMountedChatBoxes();
|
||||
getGlobalChatBoxRuntime().chatConversationModel.setCurrentConversation(undefined);
|
||||
});
|
||||
|
||||
it('triggers a popover task like v1 without inheriting shortcut auto=false', async () => {
|
||||
const task: Task = { title: 'Analyze record' };
|
||||
const workContext = [{ type: 'flow-model' as const, uid: 'block-1' }];
|
||||
|
||||
render(<AIEmployeeShortcut aiEmployee={employee} tasks={[task]} context={{ workContext }} auto={false} />);
|
||||
render(
|
||||
<AIEmployeeShortcut
|
||||
aiEmployee={employee}
|
||||
tasks={[task]}
|
||||
context={{ workContext }}
|
||||
auto={false}
|
||||
runtime={getGlobalChatBoxRuntime()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Analyze record'));
|
||||
|
||||
@@ -135,7 +161,14 @@ describe('AIEmployeeShortcut', () => {
|
||||
const task: Task = { title: 'Analyze record', autoSend: true };
|
||||
const workContext = [{ type: 'flow-model' as const, uid: 'block-1' }];
|
||||
|
||||
const { container } = render(<AIEmployeeShortcut aiEmployee={employee} tasks={[task]} context={{ workContext }} />);
|
||||
const { container } = render(
|
||||
<AIEmployeeShortcut
|
||||
aiEmployee={employee}
|
||||
tasks={[task]}
|
||||
context={{ workContext }}
|
||||
runtime={getGlobalChatBoxRuntime()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const shortcut = container.querySelector('.ant-avatar');
|
||||
expect(shortcut).toBeTruthy();
|
||||
@@ -154,15 +187,135 @@ describe('AIEmployeeShortcut', () => {
|
||||
expect(addContextItems.mock.invocationCallOrder[0]).toBeLessThan(clear.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('routes a task to the mounted AI chat box selected by chatBoxUid', async () => {
|
||||
const task: Task = { title: 'Analyze in block', chatBoxUid: 'chat-box-1' };
|
||||
const workContext = [{ type: 'flow-model' as const, uid: 'block-1' }];
|
||||
const targetTriggerTask = vi.fn().mockResolvedValue(undefined);
|
||||
const targetClear = vi.fn();
|
||||
const targetSyncContextItems = vi.fn();
|
||||
registerMountedChatBox({
|
||||
uid: 'chat-box-1',
|
||||
runtime: getGlobalChatBoxRuntime(),
|
||||
triggerTask: targetTriggerTask,
|
||||
clear: targetClear,
|
||||
syncContextItems: targetSyncContextItems,
|
||||
});
|
||||
|
||||
render(
|
||||
<AIEmployeeShortcut
|
||||
aiEmployee={employee}
|
||||
tasks={[task]}
|
||||
context={{ workContext }}
|
||||
runtime={getGlobalChatBoxRuntime()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Analyze in block'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(targetTriggerTask).toHaveBeenCalledWith({
|
||||
aiEmployee: employee,
|
||||
tasks: [task],
|
||||
});
|
||||
});
|
||||
expect(triggerTask).not.toHaveBeenCalled();
|
||||
expect(targetSyncContextItems).toHaveBeenCalledWith(workContext);
|
||||
expect(addContextItems).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes direct avatar clicks to the mounted AI chat box when its default task has chatBoxUid', async () => {
|
||||
const task: Task = { chatBoxUid: 'chat-box-1' };
|
||||
const targetTriggerTask = vi.fn().mockResolvedValue(undefined);
|
||||
const targetClear = vi.fn();
|
||||
const targetSyncContextItems = vi.fn();
|
||||
registerMountedChatBox({
|
||||
uid: 'chat-box-1',
|
||||
runtime: getGlobalChatBoxRuntime(),
|
||||
triggerTask: targetTriggerTask,
|
||||
clear: targetClear,
|
||||
syncContextItems: targetSyncContextItems,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<AIEmployeeShortcut aiEmployee={employee} tasks={[task]} runtime={getGlobalChatBoxRuntime()} />,
|
||||
);
|
||||
|
||||
const shortcut = container.querySelector('.ant-avatar');
|
||||
expect(shortcut).toBeTruthy();
|
||||
fireEvent.click(shortcut);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(targetTriggerTask).toHaveBeenCalledWith({
|
||||
aiEmployee: employee,
|
||||
tasks: [task],
|
||||
auto: undefined,
|
||||
});
|
||||
});
|
||||
expect(triggerTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the surrounding block runtime for AI employee buttons inside an AI chat box', async () => {
|
||||
const blockRuntime = createChatBoxRuntime({ mode: 'block', getScope: async () => 'chat-box-1' });
|
||||
const model = Object.create(AIEmployeeButtonModel.prototype) as AIEmployeeButtonModel;
|
||||
model.props = {
|
||||
aiEmployee: {
|
||||
username: 'atlas',
|
||||
},
|
||||
context: {
|
||||
workContext: [{ type: 'flow-model', uid: 'chat-box-1' }],
|
||||
},
|
||||
};
|
||||
model.parent = {
|
||||
uid: 'chat-box-1',
|
||||
use: 'AIChatBoxBlockModel',
|
||||
} as AIEmployeeButtonModel['parent'];
|
||||
|
||||
const { container } = render(
|
||||
<ChatBoxRuntimeProvider runtime={blockRuntime}>{model.render()}</ChatBoxRuntimeProvider>,
|
||||
);
|
||||
|
||||
const shortcut = container.querySelector('.ant-avatar');
|
||||
expect(shortcut).toBeTruthy();
|
||||
fireEvent.click(shortcut);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(triggerTask).toHaveBeenCalledWith({
|
||||
aiEmployee: employee,
|
||||
tasks: undefined,
|
||||
auto: undefined,
|
||||
});
|
||||
});
|
||||
expect(actionRuntimes).toContain(blockRuntime);
|
||||
expect(actionRuntimes).not.toContain(getGlobalChatBoxRuntime());
|
||||
expect(addContextItems).not.toHaveBeenCalled();
|
||||
expect(syncContextAttachments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a missing target AI chat box without falling back to the global chatbox', async () => {
|
||||
const task: Task = { title: 'Analyze in missing block', chatBoxUid: 'missing-chat-box' };
|
||||
|
||||
render(<AIEmployeeShortcut aiEmployee={employee} tasks={[task]} runtime={getGlobalChatBoxRuntime()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Analyze in missing block'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(messageError).toHaveBeenCalledWith('AI chat box not found:missing-chat-box');
|
||||
});
|
||||
expect(triggerTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('syncs shortcut context to the new draft when replacing an existing conversation', async () => {
|
||||
const task: Task = { title: 'Analyze record' };
|
||||
const workContext = [{ type: 'flow-model' as const, uid: 'block-1' }];
|
||||
currentConversation = 'session-1';
|
||||
const runtime = getGlobalChatBoxRuntime();
|
||||
runtime.chatConversationModel.setCurrentConversation('session-1');
|
||||
triggerTask.mockImplementation(async () => {
|
||||
currentConversation = undefined;
|
||||
runtime.chatConversationModel.setCurrentConversation(undefined);
|
||||
});
|
||||
|
||||
const { container } = render(<AIEmployeeShortcut aiEmployee={employee} tasks={[task]} context={{ workContext }} />);
|
||||
const { container } = render(
|
||||
<AIEmployeeShortcut aiEmployee={employee} tasks={[task]} context={{ workContext }} runtime={runtime} />,
|
||||
);
|
||||
|
||||
const shortcut = container.querySelector('.ant-avatar');
|
||||
expect(shortcut).toBeTruthy();
|
||||
|
||||
+79
@@ -53,6 +53,47 @@ describe('AI employee v2 action models', () => {
|
||||
expect(flow?.getStep('editTasks')).toBeDefined();
|
||||
});
|
||||
|
||||
it('adds chat box uid to shortcut task settings', async () => {
|
||||
const step = AIEmployeeShortcutModel.globalFlowRegistry.getFlow('shortcutSettings')?.getStep('editTasks');
|
||||
const uiSchema = step?.serialize().uiSchema as (ctx: FlowModelContext) => Promise<{
|
||||
tasks?: {
|
||||
items?: {
|
||||
properties?: Record<
|
||||
string,
|
||||
{
|
||||
default?: unknown;
|
||||
title?: unknown;
|
||||
['x-component']?: string;
|
||||
['x-decorator-props']?: { tooltip?: unknown };
|
||||
}
|
||||
>;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
const ctx = {
|
||||
model: {
|
||||
context: {},
|
||||
props: {
|
||||
defaultTaskChatBoxUid: 'chat-box-1',
|
||||
},
|
||||
},
|
||||
aiConfigRepository: {
|
||||
getAIEmployees: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
} as unknown as FlowModelContext;
|
||||
|
||||
const schema = await uiSchema(ctx);
|
||||
|
||||
expect(schema.tasks?.items?.properties?.chatBoxUid).toMatchObject({
|
||||
title: expect.anything(),
|
||||
'x-component': 'Input',
|
||||
'x-decorator-props': {
|
||||
tooltip: expect.anything(),
|
||||
},
|
||||
default: 'chat-box-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the task settings dialog while selecting work context', () => {
|
||||
const step = AIEmployeeShortcutModel.globalFlowRegistry.getFlow('shortcutSettings')?.getStep('editTasks');
|
||||
const uiMode = step?.serialize().uiMode as () => {
|
||||
@@ -156,4 +197,42 @@ describe('AI employee v2 action models', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults AI chat box shortcut tasks to the owning chat box uid', async () => {
|
||||
const engine = new FlowEngine();
|
||||
const ctx = {
|
||||
engine,
|
||||
model: {
|
||||
uid: 'actions-1',
|
||||
parent: {
|
||||
uid: 'chat-box-1',
|
||||
use: 'AIChatBoxBlockModel',
|
||||
},
|
||||
},
|
||||
aiConfigRepository: {
|
||||
getAIEmployees: vi.fn().mockResolvedValue([{ username: 'business', nickname: 'Business' }]),
|
||||
},
|
||||
} as unknown as FlowModelContext & {
|
||||
aiConfigRepository: {
|
||||
getAIEmployees: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
const children = await AIEmployeeActionModel.defineChildren(ctx);
|
||||
|
||||
expect(children).toHaveLength(1);
|
||||
expect(children[0]).toMatchObject({
|
||||
createModelOptions: {
|
||||
props: {
|
||||
aiEmployee: { username: 'business' },
|
||||
context: {
|
||||
workContext: [{ type: 'flow-model', uid: 'actions-1' }],
|
||||
},
|
||||
defaultTaskChatBoxUid: 'chat-box-1',
|
||||
tasks: [{ chatBoxUid: 'chat-box-1' }],
|
||||
auto: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AI_EMPLOYEE_TRIGGER_TASK_EVENT, AIManager } from '../manager/ai-manager';
|
||||
import type { RunJSAIEmployeeTriggerTaskOptions } from '../ai-employees/chatbox/utils/normalizeTriggerTaskOptions';
|
||||
import type { RunJSAIEmployeeTriggerTaskOptions } from '../ai-employees/chatbox/utils';
|
||||
import type { ContextItem, Task } from '../ai-employees/types';
|
||||
import type { FrontendToolRegistration } from '../manager/frontend-tool-registry';
|
||||
import type { LLMProviderOptions, ToolModalProps, ToolOptions } from '../manager/ai-manager';
|
||||
@@ -21,7 +21,11 @@ const ChatSettingsForm = () => <div />;
|
||||
|
||||
function createManager(getModel?: (uid: string, searchInPreviousEngines?: boolean) => unknown) {
|
||||
const eventBus = new EventTarget();
|
||||
const request = vi.fn();
|
||||
const app = {
|
||||
apiClient: {
|
||||
request,
|
||||
},
|
||||
eventBus,
|
||||
flowEngine: {
|
||||
getModel: getModel ?? vi.fn(),
|
||||
@@ -31,6 +35,7 @@ function createManager(getModel?: (uid: string, searchInPreviousEngines?: boolea
|
||||
return {
|
||||
manager: new AIManager(app),
|
||||
eventBus,
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,6 +191,33 @@ describe('AIManager v2', () => {
|
||||
expect(details).toEqual([{ aiEmployee: 'nathan', tasks: [{ title: 'Now' }] }]);
|
||||
});
|
||||
|
||||
it('uploads files through the application API client', async () => {
|
||||
const source = { dataSourceKey: 'main', collectionName: 'aiFiles' };
|
||||
const { manager, request } = createManager();
|
||||
request.mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
id: 1,
|
||||
filename: 'report.txt',
|
||||
meta: { source },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(manager.uploadFile(new File(['report'], 'report.txt'))).resolves.toMatchObject({
|
||||
id: 1,
|
||||
filename: 'report.txt',
|
||||
source,
|
||||
status: 'done',
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'aiFiles:create',
|
||||
method: 'post',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the oldest queued task when the pending AI employee task queue exceeds 20 tasks', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { manager, eventBus } = createManager();
|
||||
@@ -207,7 +239,8 @@ describe('AIManager v2', () => {
|
||||
});
|
||||
|
||||
it('triggers a model task by reading model AI employee and 0-based task index', () => {
|
||||
const task: Task = { title: 'Summarize' };
|
||||
const task: Task = { title: 'Summarize', chatBoxUid: 'chat-box-1' };
|
||||
const onResponseLoadingChange = vi.fn();
|
||||
const getModel = vi.fn(() => ({
|
||||
props: {
|
||||
aiEmployee: { username: 'nathan' },
|
||||
@@ -218,10 +251,38 @@ describe('AIManager v2', () => {
|
||||
const details = listenTasks(eventBus);
|
||||
|
||||
manager.onChatBoxMounted();
|
||||
manager.triggerModelTask('flow-model-uid', 1, { open: true });
|
||||
manager.triggerModelTask('flow-model-uid', 1, { open: true, onResponseLoadingChange });
|
||||
|
||||
expect(getModel).toHaveBeenCalledWith('flow-model-uid', true);
|
||||
expect(details).toEqual([{ aiEmployee: 'nathan', tasks: [task], open: true }]);
|
||||
expect(details).toEqual([
|
||||
{ aiEmployee: 'nathan', tasks: [task], chatBoxUid: 'chat-box-1', open: true, onResponseLoadingChange },
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends runtime attachments to the configured model task without mutating it', () => {
|
||||
const configuredAttachment = { id: 1, filename: 'configured.txt' };
|
||||
const runtimeAttachment = { id: 2, filename: 'runtime.txt' };
|
||||
const task: Task = {
|
||||
title: 'Summarize',
|
||||
message: {
|
||||
user: 'Summarize the attachments',
|
||||
attachments: [configuredAttachment],
|
||||
},
|
||||
};
|
||||
const getModel = vi.fn(() => ({
|
||||
props: {
|
||||
aiEmployee: { username: 'nathan' },
|
||||
tasks: [task],
|
||||
},
|
||||
}));
|
||||
const { manager, eventBus } = createManager(getModel);
|
||||
const details = listenTasks(eventBus);
|
||||
|
||||
manager.onChatBoxMounted();
|
||||
manager.triggerModelTask('flow-model-uid', 0, { attachments: [runtimeAttachment] });
|
||||
|
||||
expect(details[0].tasks?.[0].message?.attachments).toEqual([configuredAttachment, runtimeAttachment]);
|
||||
expect(task.message?.attachments).toEqual([configuredAttachment]);
|
||||
});
|
||||
|
||||
it('warns when model props have no task at the requested index', () => {
|
||||
|
||||
+9
-14
@@ -33,22 +33,17 @@ vi.mock('../ai-employees/chatbox/hooks/useChat', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../ai-employees/chatbox/stores/chat-conversations', () => ({
|
||||
useChatConversationsStore: {
|
||||
use: {
|
||||
currentConversation: () => 'conversation-1',
|
||||
vi.mock('../ai-employees/chatbox/stores/runtime', () => ({
|
||||
useChatBoxRuntime: () => ({
|
||||
chatConversationModel: {
|
||||
currentConversation: 'conversation-1',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../ai-employees/chatbox/stores/chat-tools', () => ({
|
||||
useChatToolsStore: {
|
||||
use: {
|
||||
setOpenToolModal: () => mocks.setOpenToolModal,
|
||||
setActiveTool: () => mocks.setActiveTool,
|
||||
setActiveMessageId: () => mocks.setActiveMessageId,
|
||||
chatToolModel: {
|
||||
setOpenToolModal: mocks.setOpenToolModal,
|
||||
setActiveTool: mocks.setActiveTool,
|
||||
setActiveMessageId: mocks.setActiveMessageId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const createProps = (toolCall: Partial<React.ComponentProps<typeof BusinessReportCard>['toolCall']> = {}) => ({
|
||||
|
||||
+52
-1
@@ -7,8 +7,9 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { normalizeAIFileUploadAttachment } from '../../ai-employees/chatbox/utils';
|
||||
import { uploadAIFile } from '../../ai-employees/chatbox/upload';
|
||||
|
||||
describe('normalizeAIFileUploadAttachment', () => {
|
||||
it('promotes upload response meta source to attachment source', () => {
|
||||
@@ -44,3 +45,53 @@ describe('normalizeAIFileUploadAttachment', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAIFile', () => {
|
||||
it('uploads multipart data, reports progress and returns a normalized attachment', async () => {
|
||||
const source = {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'aiFiles',
|
||||
};
|
||||
const request = vi.fn().mockImplementation(async (options) => {
|
||||
options.onUploadProgress?.({ loaded: 25, total: 100 });
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
id: 1,
|
||||
filename: 'report.txt',
|
||||
meta: { source },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
const onProgress = vi.fn();
|
||||
const file = new File(['report'], 'report.txt', { type: 'text/plain' });
|
||||
|
||||
await expect(uploadAIFile({ request }, file, { onProgress })).resolves.toEqual({
|
||||
id: 1,
|
||||
filename: 'report.txt',
|
||||
meta: { source },
|
||||
source,
|
||||
status: 'done',
|
||||
});
|
||||
|
||||
expect(onProgress).toHaveBeenCalledWith(25);
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'aiFiles:create',
|
||||
method: 'post',
|
||||
data: expect.any(FormData),
|
||||
}),
|
||||
);
|
||||
const requestOptions = request.mock.calls[0][0];
|
||||
expect(requestOptions.data.get('file')).toBe(file);
|
||||
});
|
||||
|
||||
it('rejects an upload response without attachment data', async () => {
|
||||
const request = vi.fn().mockResolvedValue({ data: {} });
|
||||
|
||||
await expect(uploadAIFile({ request }, new File([''], 'empty.txt'))).rejects.toThrow(
|
||||
'AI file upload response did not contain attachment data.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+17
-7
@@ -15,6 +15,7 @@ import PluginAIClientV2, {
|
||||
AIEmployeeShortcut,
|
||||
ChatBox,
|
||||
ChatBoxLayout,
|
||||
ChatBoxRuntimeProvider,
|
||||
ChatButton,
|
||||
AIPluginFeatureManagerImpl,
|
||||
ModelSwitcher,
|
||||
@@ -23,13 +24,11 @@ import PluginAIClientV2, {
|
||||
createModelSettingsForm,
|
||||
defaultVectorStorePropForm,
|
||||
formatModelLabel,
|
||||
getGlobalChatBoxRuntime,
|
||||
ModelSelect,
|
||||
OptionsFields,
|
||||
useAIConfigRepository,
|
||||
useChatBoxActions,
|
||||
useChatBoxStore,
|
||||
useChatConversationsStore,
|
||||
useChatMessagesStore,
|
||||
} from '@nocobase/plugin-ai/client-v2';
|
||||
import type {
|
||||
AIEmployee,
|
||||
@@ -42,7 +41,9 @@ import type {
|
||||
OptionField,
|
||||
SkillSettings,
|
||||
Task,
|
||||
TriggerModelTaskOptions,
|
||||
TriggerTaskOptions,
|
||||
UploadAIFileOptions,
|
||||
WebSearching,
|
||||
} from '@nocobase/plugin-ai/client-v2';
|
||||
|
||||
@@ -53,6 +54,7 @@ describe('plugin-ai client-v2 public API contract', () => {
|
||||
expect(AIEmployeeProfileCard).toBeDefined();
|
||||
expect(ChatBox).toBeDefined();
|
||||
expect(ChatBoxLayout).toBeDefined();
|
||||
expect(ChatBoxRuntimeProvider).toBeDefined();
|
||||
expect(ChatButton).toBeDefined();
|
||||
expect(AIEmployeeSwitcher).toBeDefined();
|
||||
expect(ModelSwitcher).toBeDefined();
|
||||
@@ -66,10 +68,8 @@ describe('plugin-ai client-v2 public API contract', () => {
|
||||
expect(AIPluginFeatureManagerImpl).toBeDefined();
|
||||
expect(defaultVectorStorePropForm).toBeDefined();
|
||||
expect(useAIConfigRepository).toBeDefined();
|
||||
expect(useChatMessagesStore).toBeDefined();
|
||||
expect(useChatBoxStore).toBeDefined();
|
||||
expect(useChatConversationsStore).toBeDefined();
|
||||
expect(useChatBoxActions).toBeDefined();
|
||||
expect(getGlobalChatBoxRuntime).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps the exported type surface available', () => {
|
||||
@@ -98,7 +98,15 @@ describe('plugin-ai client-v2 public API contract', () => {
|
||||
const optionField: OptionField = { name: 'temperature', title: 'Temperature' };
|
||||
const skillSettings: SkillSettings = { tools: [], skills: [] };
|
||||
const task: Task = { title: 'Translate' };
|
||||
const triggerTaskOptions: TriggerTaskOptions = { aiEmployee, tasks: [task] };
|
||||
const onResponseLoadingChange = (_loading: boolean) => {};
|
||||
const triggerModelTaskOptions: TriggerModelTaskOptions = { attachments: [attachment], onResponseLoadingChange };
|
||||
const triggerTaskOptions: TriggerTaskOptions = {
|
||||
aiEmployee,
|
||||
tasks: [task],
|
||||
chatBoxUid: 'chat-box-1',
|
||||
onResponseLoadingChange,
|
||||
};
|
||||
const uploadOptions: UploadAIFileOptions = { onProgress: () => {} };
|
||||
const webSearching: WebSearching = { type: 'search', query: 'NocoBase' };
|
||||
|
||||
expect({
|
||||
@@ -111,7 +119,9 @@ describe('plugin-ai client-v2 public API contract', () => {
|
||||
message,
|
||||
optionField,
|
||||
skillSettings,
|
||||
triggerModelTaskOptions,
|
||||
triggerTaskOptions,
|
||||
uploadOptions,
|
||||
webSearching,
|
||||
}).toBeDefined();
|
||||
});
|
||||
|
||||
+24
-7
@@ -10,6 +10,7 @@
|
||||
import { createMockClient } from '@nocobase/client-v2';
|
||||
import { createJSRunnerWithVersion, FlowContext, getRunJSDocFor, setupRunJSContexts } from '@nocobase/flow-engine';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AIChatBoxBlockModel, AIChatBoxCoreModel } from '../block/ai-chat-box';
|
||||
import PluginAIClientV2, {
|
||||
registerPluginAIPermissionsTab,
|
||||
registerPluginAIRunJSContextContribution,
|
||||
@@ -87,7 +88,7 @@ describe('plugin-ai v2 settings registration', () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not register the deprecated datasource work context in v2', async () => {
|
||||
it('registers v2 work contexts and AI chat box models', async () => {
|
||||
const app = createMockClient({ publicPath: '/v/' });
|
||||
|
||||
await app.pm.add(PluginAIClientV2);
|
||||
@@ -99,18 +100,22 @@ describe('plugin-ai v2 settings registration', () => {
|
||||
expect(plugin.aiManager.getWorkContext('datasource')).toBeUndefined();
|
||||
const context = app.flowEngine.context as unknown as {
|
||||
ai?: {
|
||||
uploadFile?: unknown;
|
||||
triggerTask?: unknown;
|
||||
triggerModelTask?: unknown;
|
||||
onChatBoxMounted?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
expect(context.ai?.uploadFile).toEqual(expect.any(Function));
|
||||
expect(context.ai?.triggerTask).toEqual(expect.any(Function));
|
||||
expect(context.ai?.triggerModelTask).toEqual(expect.any(Function));
|
||||
expect(context.ai?.onChatBoxMounted).toBeUndefined();
|
||||
expect(app.flowEngine.getModelClass('AIChatBoxBlockModel')).toBe(AIChatBoxBlockModel);
|
||||
expect(app.flowEngine.getModelClass('AIChatBoxCoreModel')).toBe(AIChatBoxCoreModel);
|
||||
});
|
||||
|
||||
it('registers RunJS docs for ctx.ai.triggerTask and ctx.ai.triggerModelTask', async () => {
|
||||
it('registers RunJS docs for ctx.ai upload and task methods', async () => {
|
||||
registerPluginAIRunJSContextContribution();
|
||||
await setupRunJSContexts();
|
||||
|
||||
@@ -120,6 +125,7 @@ describe('plugin-ai v2 settings registration', () => {
|
||||
];
|
||||
|
||||
for (const doc of docs) {
|
||||
expect(doc?.properties?.ai?.properties?.uploadFile).toBeDefined();
|
||||
expect(doc?.properties?.ai?.properties?.triggerTask).toBeDefined();
|
||||
expect(doc?.properties?.ai?.properties?.triggerModelTask).toBeDefined();
|
||||
const registerTool = doc?.properties?.ai?.properties?.tools?.properties?.register;
|
||||
@@ -134,6 +140,7 @@ describe('plugin-ai v2 settings registration', () => {
|
||||
await setupRunJSContexts();
|
||||
|
||||
for (const version of ['v1', 'v2'] as const) {
|
||||
const uploadFile = vi.fn().mockResolvedValue({ id: 1, filename: 'report.txt' });
|
||||
const triggerTask = vi.fn();
|
||||
const triggerModelTask = vi.fn();
|
||||
const clear = vi.fn();
|
||||
@@ -148,6 +155,7 @@ describe('plugin-ai v2 settings registration', () => {
|
||||
});
|
||||
engineContext.defineProperty('ai', {
|
||||
value: {
|
||||
uploadFile,
|
||||
triggerTask,
|
||||
triggerModelTask,
|
||||
},
|
||||
@@ -159,10 +167,11 @@ describe('plugin-ai v2 settings registration', () => {
|
||||
for (let run = 0; run < 2; run++) {
|
||||
const runner = createJSRunnerWithVersion.call(ctx, { version });
|
||||
const result = await runner.run(`
|
||||
const attachment = await ctx.ai.uploadFile({ name: 'report.txt' });
|
||||
ctx.ai.tools.register({ name: 'read_dashboard', description: 'Read dashboard', execute() {} });
|
||||
ctx.ai.triggerTask({ aiEmployee: 'nathan', tasks: [], open: true });
|
||||
ctx.ai.triggerModelTask('flow-model-uid', 0);
|
||||
return typeof ctx.ai.tools.register === 'function';
|
||||
ctx.ai.triggerTask({ aiEmployee: 'nathan', tasks: [], chatBoxUid: 'chat-box-1', open: true });
|
||||
ctx.ai.triggerModelTask('flow-model-uid', 0, { attachments: [attachment] });
|
||||
return typeof ctx.ai.uploadFile === 'function' && typeof ctx.ai.tools.register === 'function' && typeof ctx.ai.triggerTask === 'function' && typeof ctx.ai.triggerModelTask === 'function';
|
||||
`);
|
||||
|
||||
expect(result?.success).toBe(true);
|
||||
@@ -176,8 +185,16 @@ describe('plugin-ai v2 settings registration', () => {
|
||||
'block-1',
|
||||
expect.objectContaining({ name: 'read_dashboard', description: 'Read dashboard' }),
|
||||
);
|
||||
expect(triggerTask).toHaveBeenCalledWith({ aiEmployee: 'nathan', tasks: [], open: true });
|
||||
expect(triggerModelTask).toHaveBeenCalledWith('flow-model-uid', 0);
|
||||
expect(uploadFile).toHaveBeenCalledWith({ name: 'report.txt' });
|
||||
expect(triggerTask).toHaveBeenCalledWith({
|
||||
aiEmployee: 'nathan',
|
||||
tasks: [],
|
||||
chatBoxUid: 'chat-box-1',
|
||||
open: true,
|
||||
});
|
||||
expect(triggerModelTask).toHaveBeenCalledWith('flow-model-uid', 0, {
|
||||
attachments: [{ id: 1, filename: 'report.txt' }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+7
-9
@@ -11,6 +11,7 @@ import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SubAgentDispatchCard } from '../ai-employees/tools/SubAgentDispatchCard';
|
||||
import { ChatBoxRuntimeProvider, createChatBoxRuntime } from '../ai-employees/chatbox/stores/runtime';
|
||||
|
||||
type ChatMessage = {
|
||||
content?: {
|
||||
@@ -49,13 +50,10 @@ vi.mock('../ai-employees/chatbox/hooks/useChat', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../ai-employees/chatbox/stores/chat-conversations', () => ({
|
||||
useChatConversationsStore: {
|
||||
use: {
|
||||
currentConversation: () => undefined,
|
||||
},
|
||||
},
|
||||
}));
|
||||
const renderWithRuntime = (ui: React.ReactElement) => {
|
||||
const runtime = createChatBoxRuntime();
|
||||
return render(<ChatBoxRuntimeProvider runtime={runtime}>{ui}</ChatBoxRuntimeProvider>);
|
||||
};
|
||||
|
||||
describe('SubAgentDispatchCard', () => {
|
||||
beforeEach(() => {
|
||||
@@ -65,7 +63,7 @@ describe('SubAgentDispatchCard', () => {
|
||||
});
|
||||
|
||||
it('shows loading for the current live message when message ids are still empty', () => {
|
||||
const { container } = render(
|
||||
const { container } = renderWithRuntime(
|
||||
<SubAgentDispatchCard
|
||||
messageId=""
|
||||
tools={{
|
||||
@@ -101,7 +99,7 @@ describe('SubAgentDispatchCard', () => {
|
||||
it('does not show loading for historical messages', () => {
|
||||
chatState.responseLoading = false;
|
||||
|
||||
const { container } = render(
|
||||
const { container } = renderWithRuntime(
|
||||
<SubAgentDispatchCard
|
||||
messageId=""
|
||||
tools={{
|
||||
|
||||
+7
-12
@@ -33,20 +33,15 @@ vi.mock('../ai-employees/chatbox/hooks/useChat', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../ai-employees/chatbox/stores/chat-box', () => ({
|
||||
useChatBoxStore: {
|
||||
use: {
|
||||
readonly: () => false,
|
||||
vi.mock('../ai-employees/chatbox/stores/runtime', () => ({
|
||||
useChatBoxRuntime: () => ({
|
||||
chatBoxModel: {
|
||||
readonly: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../ai-employees/chatbox/stores/chat-conversations', () => ({
|
||||
useChatConversationsStore: {
|
||||
use: {
|
||||
currentConversation: () => undefined,
|
||||
chatConversationModel: {
|
||||
currentConversation: undefined,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const createProps = () => ({
|
||||
|
||||
+177
-132
@@ -9,14 +9,17 @@
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Avatar, Popover } from 'antd';
|
||||
import { type FlowModelContext, useFlowContext } from '@nocobase/flow-engine';
|
||||
import { observer, type FlowModelContext, useFlowContext } from '@nocobase/flow-engine';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { avatars } from './avatars';
|
||||
import { AIEmployeeProfileCard } from './ProfileCard';
|
||||
import { useChat } from './chatbox/hooks/useChat';
|
||||
import { useChatBoxActions } from './chatbox/hooks/useChatBoxActions';
|
||||
import { useChatMessageActions } from './chatbox/hooks/useChatMessageActions';
|
||||
import { useChatConversationsStore } from './chatbox/stores/chat-conversations';
|
||||
import { getTargetChatBoxUid } from './chatbox/utils';
|
||||
import { getMountedChatBox, type MountedChatBoxEntry } from './chatbox/stores/mounted-chat-boxes';
|
||||
import { type ChatBoxRuntime, useResolvedChatBoxRuntime } from './chatbox/stores/runtime';
|
||||
import { useT } from '../locale';
|
||||
import type { AIEmployee, ContextItem, Task } from './types';
|
||||
|
||||
type ShortcutAIEmployee = Pick<AIEmployee, 'username'> & Partial<AIEmployee>;
|
||||
@@ -37,146 +40,188 @@ export const AIEmployeeShortcut: React.FC<{
|
||||
onTaskClick?: (task: Task) => void;
|
||||
taskLoadingTitle?: string;
|
||||
loadingTaskTitle?: string;
|
||||
}> = ({
|
||||
aiEmployee,
|
||||
tasks,
|
||||
size = 52,
|
||||
mask,
|
||||
showNotice,
|
||||
context,
|
||||
auto,
|
||||
onClick,
|
||||
onTaskClick,
|
||||
taskLoadingTitle,
|
||||
loadingTaskTitle,
|
||||
}) => {
|
||||
const ctx = useFlowContext<FlowModelContext>();
|
||||
const [focus, setFocus] = useState(false);
|
||||
const { data: aiEmployees = [], loading } = useRequest(async (): Promise<AIEmployee[]> => {
|
||||
const response = await ctx.app.apiClient.resource('aiEmployees').listByUser();
|
||||
return response?.data?.data || [];
|
||||
});
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
const { clear, triggerTask } = useChatBoxActions();
|
||||
const { syncContextAttachments } = useChatMessageActions();
|
||||
const resolvedAIEmployee = useMemo(() => {
|
||||
return aiEmployees.find((item) => item.username === aiEmployee?.username);
|
||||
}, [aiEmployee, aiEmployees]);
|
||||
|
||||
const currentAvatar = useMemo(() => {
|
||||
const avatar = resolvedAIEmployee?.avatar;
|
||||
if (!avatar) {
|
||||
return undefined;
|
||||
}
|
||||
if (focus || showNotice) {
|
||||
return avatars(avatar, {
|
||||
mask: undefined,
|
||||
flip: true,
|
||||
});
|
||||
}
|
||||
return avatars(avatar, {
|
||||
mouth: undefined,
|
||||
mask: mask !== false ? ['dark'] : undefined,
|
||||
runtime?: ChatBoxRuntime;
|
||||
}> = observer(
|
||||
({
|
||||
aiEmployee,
|
||||
tasks,
|
||||
size = 52,
|
||||
mask,
|
||||
showNotice,
|
||||
context,
|
||||
auto,
|
||||
onClick,
|
||||
onTaskClick,
|
||||
taskLoadingTitle,
|
||||
loadingTaskTitle,
|
||||
runtime,
|
||||
}) => {
|
||||
const resolvedRuntime = useResolvedChatBoxRuntime(runtime);
|
||||
const ctx = useFlowContext<FlowModelContext>();
|
||||
const t = useT();
|
||||
const [focus, setFocus] = useState(false);
|
||||
const { data: aiEmployees = [], loading } = useRequest(async (): Promise<AIEmployee[]> => {
|
||||
const response = await ctx.app.apiClient.resource('aiEmployees').listByUser();
|
||||
return response?.data?.data || [];
|
||||
});
|
||||
}, [resolvedAIEmployee?.avatar, focus, mask, showNotice]);
|
||||
const currentConversation = resolvedRuntime.chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, resolvedRuntime);
|
||||
const { clear, triggerTask } = useChatBoxActions(resolvedRuntime);
|
||||
const { syncContextAttachments } = useChatMessageActions(resolvedRuntime);
|
||||
const resolvedAIEmployee = useMemo(() => {
|
||||
return aiEmployees.find((item) => item.username === aiEmployee?.username);
|
||||
}, [aiEmployee, aiEmployees]);
|
||||
|
||||
const getShortcutContext = useCallback(() => {
|
||||
const workContext = context?.workContext ?? [];
|
||||
if (!workContext.length) {
|
||||
return workContext;
|
||||
}
|
||||
const nextWorkContext = workContext.filter((item) => {
|
||||
if (item.type !== 'flow-model') {
|
||||
return true;
|
||||
const currentAvatar = useMemo(() => {
|
||||
const avatar = resolvedAIEmployee?.avatar;
|
||||
if (!avatar) {
|
||||
return undefined;
|
||||
}
|
||||
return Boolean(ctx.engine.getModel(item.uid));
|
||||
});
|
||||
if (nextWorkContext.every((item) => item.type !== 'flow-model')) {
|
||||
const parent = ctx.model.parent;
|
||||
if (parent) {
|
||||
nextWorkContext.push({
|
||||
type: 'flow-model',
|
||||
uid: parent.uid,
|
||||
if (focus || showNotice) {
|
||||
return avatars(avatar, {
|
||||
mask: undefined,
|
||||
flip: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return nextWorkContext;
|
||||
}, [context?.workContext, ctx.engine, ctx.model.parent]);
|
||||
return avatars(avatar, {
|
||||
mouth: undefined,
|
||||
mask: mask !== false ? ['dark'] : undefined,
|
||||
});
|
||||
}, [resolvedAIEmployee?.avatar, focus, mask, showNotice]);
|
||||
|
||||
const syncShortcutContext = useCallback(() => {
|
||||
const shortcutContext = getShortcutContext();
|
||||
if (!shortcutContext.length) {
|
||||
return;
|
||||
}
|
||||
const activeConversation = useChatConversationsStore.getState().currentConversation;
|
||||
chat.for(activeConversation).addContextItems(shortcutContext);
|
||||
syncContextAttachments(shortcutContext);
|
||||
}, [chat, getShortcutContext, syncContextAttachments]);
|
||||
const getShortcutContext = useCallback(() => {
|
||||
const workContext = context?.workContext ?? [];
|
||||
if (!workContext.length) {
|
||||
return workContext;
|
||||
}
|
||||
const shouldSkipFlowModelContext = resolvedRuntime.mode === 'block';
|
||||
const nextWorkContext = workContext.filter((item) => {
|
||||
if (item.type !== 'flow-model') {
|
||||
return true;
|
||||
}
|
||||
if (shouldSkipFlowModelContext) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(ctx.engine.getModel(item.uid));
|
||||
});
|
||||
if (!shouldSkipFlowModelContext && nextWorkContext.every((item) => item.type !== 'flow-model')) {
|
||||
const parent = ctx.model.parent;
|
||||
if (parent) {
|
||||
nextWorkContext.push({
|
||||
type: 'flow-model',
|
||||
uid: parent.uid,
|
||||
});
|
||||
}
|
||||
}
|
||||
return nextWorkContext;
|
||||
}, [context?.workContext, ctx.engine, ctx.model.parent, resolvedRuntime.mode]);
|
||||
|
||||
const openGlobalChatBox = useCallback(
|
||||
async (taskOptions?: Task[]) => {
|
||||
if (!resolvedAIEmployee) {
|
||||
const syncShortcutContext = useCallback(
|
||||
(targetChatBox?: MountedChatBoxEntry) => {
|
||||
const shortcutContext = getShortcutContext();
|
||||
if (!shortcutContext.length) {
|
||||
return;
|
||||
}
|
||||
if (targetChatBox) {
|
||||
targetChatBox.syncContextItems(shortcutContext);
|
||||
return;
|
||||
}
|
||||
const activeConversation = resolvedRuntime.chatConversationModel.currentConversation;
|
||||
chat.for(activeConversation).addContextItems(shortcutContext);
|
||||
syncContextAttachments(shortcutContext);
|
||||
},
|
||||
[chat, getShortcutContext, resolvedRuntime.chatConversationModel, syncContextAttachments],
|
||||
);
|
||||
|
||||
const showChatBoxNotFound = useCallback(
|
||||
(chatBoxUid: string) => {
|
||||
ctx.message?.error?.(t('AI chat box not found', { uid: chatBoxUid }));
|
||||
},
|
||||
[ctx.message, t],
|
||||
);
|
||||
|
||||
const openChatBox = useCallback(
|
||||
async (taskOptions?: Task[]) => {
|
||||
if (!resolvedAIEmployee) {
|
||||
return;
|
||||
}
|
||||
const resolvedTasks = taskOptions ?? tasks;
|
||||
const targetChatBoxUid = getTargetChatBoxUid(resolvedTasks);
|
||||
const targetChatBox = targetChatBoxUid ? getMountedChatBox(targetChatBoxUid) : undefined;
|
||||
if (targetChatBoxUid && !targetChatBox) {
|
||||
showChatBoxNotFound(targetChatBoxUid);
|
||||
return;
|
||||
}
|
||||
const shouldClearShortcutContext = resolvedTasks?.length === 1 && auto !== false && resolvedTasks[0]?.autoSend;
|
||||
const taskOptionsPayload = { aiEmployee: resolvedAIEmployee, tasks: resolvedTasks, auto };
|
||||
await (targetChatBox ? targetChatBox.triggerTask(taskOptionsPayload) : triggerTask(taskOptionsPayload));
|
||||
syncShortcutContext(targetChatBox);
|
||||
if (shouldClearShortcutContext) {
|
||||
if (targetChatBox) {
|
||||
targetChatBox.clear(undefined, undefined);
|
||||
} else {
|
||||
clear(undefined, undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
[auto, clear, resolvedAIEmployee, showChatBoxNotFound, syncShortcutContext, tasks, triggerTask],
|
||||
);
|
||||
|
||||
const handleTaskClick = useCallback(
|
||||
(task: Task) => {
|
||||
if (onTaskClick) {
|
||||
onTaskClick(task);
|
||||
return;
|
||||
}
|
||||
if (!resolvedAIEmployee) {
|
||||
return;
|
||||
}
|
||||
const targetChatBoxUid = task.chatBoxUid;
|
||||
const targetChatBox = targetChatBoxUid ? getMountedChatBox(targetChatBoxUid) : undefined;
|
||||
if (targetChatBoxUid && !targetChatBox) {
|
||||
showChatBoxNotFound(targetChatBoxUid);
|
||||
return;
|
||||
}
|
||||
syncShortcutContext(targetChatBox);
|
||||
const taskOptions = { aiEmployee: resolvedAIEmployee, tasks: [task] };
|
||||
(targetChatBox ? targetChatBox.triggerTask(taskOptions) : triggerTask(taskOptions)).catch(console.error);
|
||||
},
|
||||
[onTaskClick, resolvedAIEmployee, showChatBoxNotFound, syncShortcutContext, triggerTask],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
return;
|
||||
}
|
||||
const resolvedTasks = taskOptions ?? tasks;
|
||||
const shouldClearShortcutContext = resolvedTasks?.length === 1 && auto !== false && resolvedTasks[0]?.autoSend;
|
||||
await triggerTask({ aiEmployee: resolvedAIEmployee, tasks: resolvedTasks, auto });
|
||||
syncShortcutContext();
|
||||
if (shouldClearShortcutContext) {
|
||||
clear(undefined, undefined);
|
||||
}
|
||||
},
|
||||
[auto, clear, resolvedAIEmployee, syncShortcutContext, tasks, triggerTask],
|
||||
);
|
||||
openChatBox().catch(console.error);
|
||||
}, [onClick, openChatBox]);
|
||||
|
||||
const handleTaskClick = useCallback(
|
||||
(task: Task) => {
|
||||
if (onTaskClick) {
|
||||
onTaskClick(task);
|
||||
return;
|
||||
}
|
||||
if (!resolvedAIEmployee) {
|
||||
return;
|
||||
}
|
||||
syncShortcutContext();
|
||||
triggerTask({ aiEmployee: resolvedAIEmployee, tasks: [task] }).catch(console.error);
|
||||
},
|
||||
[onTaskClick, resolvedAIEmployee, syncShortcutContext, triggerTask],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
return;
|
||||
if (loading || !resolvedAIEmployee) {
|
||||
return null;
|
||||
}
|
||||
openGlobalChatBox().catch(console.error);
|
||||
}, [onClick, openGlobalChatBox]);
|
||||
|
||||
if (loading || !resolvedAIEmployee) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
<AIEmployeeProfileCard
|
||||
aiEmployee={resolvedAIEmployee}
|
||||
tasks={tasks}
|
||||
onTaskClick={handleTaskClick}
|
||||
taskLoadingTitle={taskLoadingTitle}
|
||||
loadingTaskTitle={loadingTaskTitle}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span
|
||||
style={{ cursor: 'pointer', display: 'inline-block' }}
|
||||
onMouseEnter={() => setFocus(true)}
|
||||
onMouseLeave={() => setFocus(false)}
|
||||
onClick={handleClick}
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
<AIEmployeeProfileCard
|
||||
aiEmployee={resolvedAIEmployee}
|
||||
tasks={tasks}
|
||||
onTaskClick={handleTaskClick}
|
||||
taskLoadingTitle={taskLoadingTitle}
|
||||
loadingTaskTitle={loadingTaskTitle}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar src={currentAvatar} size={size} shape="circle" />
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
<span
|
||||
style={{ cursor: 'pointer', display: 'inline-block' }}
|
||||
onMouseEnter={() => setFocus(true)}
|
||||
onMouseLeave={() => setFocus(false)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Avatar src={currentAvatar} size={size} shape="circle" />
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
+5
-2
@@ -16,6 +16,7 @@ import { useT } from '../locale';
|
||||
import type { AIManager } from '../manager/ai-manager';
|
||||
import type { ContextItem, WorkContextOptions } from './types';
|
||||
import { useChatMessageActions } from './chatbox/hooks/useChatMessageActions';
|
||||
import type { ChatBoxRuntime } from './chatbox/stores/runtime';
|
||||
|
||||
type AIPluginLike = {
|
||||
aiManager?: AIManager;
|
||||
@@ -55,13 +56,14 @@ export const AddContextButton: React.FC<{
|
||||
onRemove: (type: string, uid: string) => void;
|
||||
disabled?: boolean;
|
||||
ignore?: (key: string, workContext: WorkContextOptions) => boolean;
|
||||
}> = ({ contextItems, onAdd, onRemove, disabled, ignore }) => {
|
||||
runtime?: ChatBoxRuntime;
|
||||
}> = ({ contextItems, onAdd, onRemove, disabled, ignore, runtime }) => {
|
||||
const t = useT();
|
||||
const app = useApp();
|
||||
const ctx = useFlowContext();
|
||||
const plugin = app.pm.get('ai') as AIPluginLike | undefined;
|
||||
const workContext = plugin?.aiManager?.workContext;
|
||||
const { syncContextAttachments } = useChatMessageActions();
|
||||
const { syncContextAttachments } = useChatMessageActions(runtime);
|
||||
|
||||
const [items, onClick] = useMemo(() => {
|
||||
const context = Array.from(workContext?.getValues() || []);
|
||||
@@ -100,6 +102,7 @@ export const AddContextButton: React.FC<{
|
||||
});
|
||||
|
||||
const handleClick: MenuProps['onClick'] = (event) => {
|
||||
event.domEvent.stopPropagation();
|
||||
const key = String(event.key);
|
||||
const workContextItem = contextItemMapping.get(key);
|
||||
workContextItem?.menu?.onClick?.({
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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 from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AddContextButton } from '../AddContextButton';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
workContextOnClick: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('antd', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('antd')>();
|
||||
return {
|
||||
...actual,
|
||||
Dropdown: ({ children, menu }: React.PropsWithChildren<{ menu: { onClick?: (event: unknown) => void } }>) => (
|
||||
<div>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="work-context-menu-item"
|
||||
onClick={(domEvent) => {
|
||||
menu.onClick?.({ key: 'flow-model', domEvent });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@nocobase/client-v2', () => ({
|
||||
useApp: () => ({
|
||||
pm: {
|
||||
get: () => ({
|
||||
aiManager: {
|
||||
workContext: {
|
||||
getValues: () => [
|
||||
{
|
||||
name: 'flow-model',
|
||||
menu: {
|
||||
label: 'Pick block',
|
||||
onClick: mocks.workContextOnClick,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@nocobase/flow-engine', () => ({
|
||||
useFlowContext: () => ({ engine: {} }),
|
||||
}));
|
||||
|
||||
vi.mock('../../locale', () => ({
|
||||
useT: () => (value: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock('../chatbox/hooks/useChatMessageActions', () => ({
|
||||
useChatMessageActions: () => ({
|
||||
syncContextAttachments: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('AddContextButton', () => {
|
||||
it('does not let the pick-block menu click select the containing block', () => {
|
||||
const onParentClick = vi.fn();
|
||||
|
||||
render(
|
||||
<div onClick={onParentClick}>
|
||||
<AddContextButton onAdd={vi.fn()} onRemove={vi.fn()} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('work-context-menu-item'));
|
||||
|
||||
expect(mocks.workContextOnClick).toHaveBeenCalledTimes(1);
|
||||
expect(onParentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+8
-7
@@ -17,8 +17,7 @@ import { useAIConfigRepository } from '../../repositories/hooks/useAIConfigRepos
|
||||
import { avatars } from '../avatars';
|
||||
import { useChat } from '../chatbox/hooks/useChat';
|
||||
import { useChatBoxActions } from '../chatbox/hooks/useChatBoxActions';
|
||||
import { useChatBoxStore } from '../chatbox/stores/chat-box';
|
||||
import { useChatConversationsStore } from '../chatbox/stores/chat-conversations';
|
||||
import { getGlobalChatBoxRuntime } from '../chatbox/stores/runtime';
|
||||
import { AIEmployeeProfileCard } from '../ProfileCard';
|
||||
import type { AIEmployee, Task } from '../types';
|
||||
import prompts from './prompts';
|
||||
@@ -46,11 +45,13 @@ export const AICodingButton: React.FC<AICodingButtonProps> = observer(
|
||||
const { token } = theme.useToken();
|
||||
const aiConfigRepository = useAIConfigRepository();
|
||||
const aiEmployees = aiConfigRepository.aiEmployees;
|
||||
const open = useChatBoxStore.use.open();
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
const { triggerTask } = useChatBoxActions();
|
||||
const runtime = getGlobalChatBoxRuntime();
|
||||
const { chatBoxModel, chatConversationModel } = runtime;
|
||||
const open = chatBoxModel.open;
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const currentConversation = chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const { triggerTask } = useChatBoxActions(runtime);
|
||||
const addContextItems = chat.addContextItems;
|
||||
const setEditorRef = chat.setEditorRef;
|
||||
const setCurrentEditorRefUid = chat.setCurrentEditorRefUid;
|
||||
|
||||
+3
-3
@@ -8,13 +8,13 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { normalizeTriggerTaskOptions } from '../normalizeTriggerTaskOptions';
|
||||
import type { AIEmployee } from '../../../types';
|
||||
import { normalizeTriggerTaskOptions } from '../utils';
|
||||
import type { AIEmployee } from '../../types';
|
||||
|
||||
describe('normalizeTriggerTaskOptions', () => {
|
||||
it('keeps an AIEmployee object unchanged', async () => {
|
||||
const aiEmployee: AIEmployee = { username: 'nathan', nickname: 'Nathan' };
|
||||
const options = { aiEmployee, tasks: [{ title: 'Write' }] };
|
||||
const options = { aiEmployee, tasks: [{ title: 'Write' }], chatBoxUid: 'chat-box-1' };
|
||||
|
||||
await expect(normalizeTriggerTaskOptions(options)).resolves.toBe(options);
|
||||
});
|
||||
+18
-1
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isCurrentLiveMessage, parseWorkContext } from '../utils';
|
||||
import { isCurrentLiveMessage, parseTask, parseWorkContext } from '../utils';
|
||||
|
||||
describe('client-v2 chatbox utils', () => {
|
||||
it('matches live messages by rendered message id', () => {
|
||||
@@ -57,4 +57,21 @@ describe('client-v2 chatbox utils', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('flattens valid task attachments and ignores entries without a filename', async () => {
|
||||
const first = { id: 1, filename: 'first.txt' };
|
||||
const second = { id: 2, filename: 'second.txt' };
|
||||
|
||||
await expect(
|
||||
parseTask({
|
||||
message: {
|
||||
user: 'Summarize the files',
|
||||
attachments: [first, [second, { id: 3 }]],
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
userMessage: { type: 'text', content: 'Summarize the files' },
|
||||
attachments: [first, second],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+9
-4
@@ -12,14 +12,19 @@ import { observer } from '@nocobase/flow-engine';
|
||||
import { AIEmployeeDropdown } from '../../AIEmployeeDropdown';
|
||||
import { useAIConfigRepository } from '../../../repositories/hooks/useAIConfigRepository';
|
||||
import { useChatBoxActions } from '../hooks/useChatBoxActions';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
export const AIEmployeeSwitcher: React.FC<{
|
||||
disabled?: boolean;
|
||||
}> = observer(({ disabled }) => {
|
||||
allowedUsernames?: string[];
|
||||
}> = observer(({ disabled, allowedUsernames }) => {
|
||||
const repository = useAIConfigRepository();
|
||||
const aiEmployees = repository.aiEmployees;
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const allowedUsernameSet = allowedUsernames?.length ? new Set(allowedUsernames) : null;
|
||||
const aiEmployees = allowedUsernameSet
|
||||
? repository.aiEmployees.filter((employee) => allowedUsernameSet.has(employee.username))
|
||||
: repository.aiEmployees;
|
||||
const { chatBoxModel } = useChatBoxRuntime();
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const { switchAIEmployee } = useChatBoxActions();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+9
-7
@@ -10,12 +10,12 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Button, Space, theme } from 'antd';
|
||||
import { useApp } from '@nocobase/client-v2';
|
||||
import { observer } from '@nocobase/flow-engine';
|
||||
import type { AIManager } from '../../../manager/ai-manager';
|
||||
import { useT } from '../../../locale';
|
||||
import type { ActionOptions, ContextItem, Message } from '../../types';
|
||||
import { useChat } from '../hooks/useChat';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
type AIPluginLike = {
|
||||
aiManager?: AIManager;
|
||||
@@ -25,15 +25,17 @@ export const Actions: React.FC<{
|
||||
message: Message['content'];
|
||||
responseType: string;
|
||||
value?: string;
|
||||
}> = ({ responseType, message, value }) => {
|
||||
}> = observer(({ responseType, message, value }) => {
|
||||
const app = useApp();
|
||||
const t = useT();
|
||||
const { token } = theme.useToken();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
const runtime = useChatBoxRuntime();
|
||||
const currentConversation = runtime.chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const responseLoading = chat.use.responseLoading();
|
||||
const messages = chat.use.messages();
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const { chatBoxModel } = runtime;
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const plugin = app.pm.get('ai') as AIPluginLike | undefined;
|
||||
|
||||
const lastEmployeeMessageKey = useMemo(() => {
|
||||
@@ -104,6 +106,6 @@ export const Actions: React.FC<{
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
Actions.displayName = 'Actions';
|
||||
|
||||
+24
-44
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Badge, Button, Divider, Layout, theme, Tooltip, Typography } from 'antd';
|
||||
import { Button, Divider, Layout, theme, Tooltip } from 'antd';
|
||||
import {
|
||||
BugOutlined,
|
||||
CloseOutlined,
|
||||
@@ -27,41 +27,33 @@ import { Sender } from './Sender';
|
||||
import { UserPrompt } from './UserPrompt';
|
||||
import { useChatBoxActions } from '../hooks/useChatBoxActions';
|
||||
import { useChatBoxEffect } from '../hooks/useChatBoxEffect';
|
||||
import { useChatConversationActions } from '../hooks/useChatConversationActions';
|
||||
import { useWorkflowTasks } from '../hooks/useWorkflowTasks';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { observer } from '@nocobase/flow-engine';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
import { ChatBoxUnreadBadge } from './ChatBoxUnreadBadge';
|
||||
|
||||
const { Header, Footer, Sider } = Layout;
|
||||
|
||||
export const ChatBox: React.FC<{
|
||||
onClose?: () => void;
|
||||
}> = ({ onClose }) => {
|
||||
}> = observer(({ onClose }) => {
|
||||
const t = useT();
|
||||
const { token } = theme.useToken();
|
||||
const chatBoxRef = useRef<HTMLDivElement | null>(null);
|
||||
const setChatBoxRef = useChatBoxStore.use.setChatBoxRef();
|
||||
const setOpen = useChatBoxStore.use.setOpen();
|
||||
const expanded = useChatBoxStore.use.expanded();
|
||||
const setExpanded = useChatBoxStore.use.setExpanded();
|
||||
const setMinimize = useChatBoxStore.use.setMinimize();
|
||||
const showConversations = useChatBoxStore.use.showConversations();
|
||||
const setShowConversations = useChatBoxStore.use.setShowConversations();
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const showDebugPanel = useChatBoxStore.use.showDebugPanel();
|
||||
const setShowDebugPanel = useChatBoxStore.use.setShowDebugPanel();
|
||||
const { chatBoxModel } = useChatBoxRuntime();
|
||||
const expanded = chatBoxModel.expanded;
|
||||
const showConversations = chatBoxModel.showConversations;
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const showDebugPanel = chatBoxModel.showDebugPanel;
|
||||
const { startNewConversation } = useChatBoxActions();
|
||||
const { unreadCount: unreadConversationCount } = useChatConversationActions();
|
||||
const { unreadCount: unreadWorkflowTaskCount } = useWorkflowTasks();
|
||||
const unreadCount = unreadConversationCount + unreadWorkflowTaskCount;
|
||||
const { isMobileLayout } = useMobileLayout();
|
||||
useChatBoxEffect();
|
||||
|
||||
useEffect(() => {
|
||||
setChatBoxRef(chatBoxRef);
|
||||
chatBoxModel.setChatBoxRef(chatBoxRef);
|
||||
return () => {
|
||||
setChatBoxRef(null);
|
||||
chatBoxModel.setChatBoxRef(null);
|
||||
};
|
||||
}, [setChatBoxRef]);
|
||||
}, [chatBoxModel]);
|
||||
|
||||
const conversationPanelWidth = 300;
|
||||
const headerHeight = 48;
|
||||
@@ -82,11 +74,11 @@ export const ChatBox: React.FC<{
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => {
|
||||
setShowConversations(false);
|
||||
chatBoxModel.setShowConversations(false);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
setShowConversations(false);
|
||||
chatBoxModel.setShowConversations(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -135,17 +127,17 @@ export const ChatBox: React.FC<{
|
||||
>
|
||||
<div>
|
||||
<Tooltip title={t('Conversation list')}>
|
||||
<Badge dot={unreadCount > 0} offset={[-4, 4]}>
|
||||
<ChatBoxUnreadBadge offset={[-4, 4]}>
|
||||
<Button
|
||||
aria-label={t('Conversation list')}
|
||||
icon={showConversations ? <MenuFoldOutlined /> : <MenuUnfoldOutlined />}
|
||||
type="text"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setShowConversations(!showConversations);
|
||||
chatBoxModel.setShowConversations(!showConversations);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
</ChatBoxUnreadBadge>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div>
|
||||
@@ -166,7 +158,7 @@ export const ChatBox: React.FC<{
|
||||
aria-label={t('Debug Panel')}
|
||||
icon={<BugOutlined />}
|
||||
type="text"
|
||||
onClick={() => setShowDebugPanel(!showDebugPanel)}
|
||||
onClick={() => chatBoxModel.setShowDebugPanel(!showDebugPanel)}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@@ -180,7 +172,7 @@ export const ChatBox: React.FC<{
|
||||
icon={<CompressOutlined />}
|
||||
type="text"
|
||||
onClick={() => {
|
||||
setMinimize(true);
|
||||
chatBoxModel.setMinimize(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -192,9 +184,9 @@ export const ChatBox: React.FC<{
|
||||
type="text"
|
||||
onClick={() => {
|
||||
if (!expanded) {
|
||||
setShowDebugPanel(false);
|
||||
chatBoxModel.setShowDebugPanel(false);
|
||||
}
|
||||
setExpanded(!expanded);
|
||||
chatBoxModel.setExpanded(!expanded);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -209,7 +201,7 @@ export const ChatBox: React.FC<{
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
chatBoxModel.setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -223,20 +215,8 @@ export const ChatBox: React.FC<{
|
||||
}}
|
||||
>
|
||||
<Sender />
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
margin: '10px 0',
|
||||
fontSize: token.fontSizeSM,
|
||||
color: token.colorTextTertiary,
|
||||
}}
|
||||
>
|
||||
{t('AI disclaimer')}
|
||||
</Typography.Text>
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
+42
-23
@@ -20,34 +20,42 @@ import { AISelection } from '../../AISelection';
|
||||
import { AISelectionControl } from '../../AISelectionControl';
|
||||
import { avatars } from '../../avatars';
|
||||
import { dialogController } from '../../stores/dialog-controller';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatToolsStore } from '../stores/chat-tools';
|
||||
import { useChatConversationActions } from '../hooks/useChatConversationActions';
|
||||
import { useChatBoxActions } from '../hooks/useChatBoxActions';
|
||||
import { useAIConfigRepository } from '../../../repositories/hooks/useAIConfigRepository';
|
||||
import { AI_EMPLOYEE_TRIGGER_TASK_EVENT } from '../../../manager/ai-manager';
|
||||
import { useT } from '../../../locale';
|
||||
import type { PluginAIClientV2 } from '../../../plugin';
|
||||
import {
|
||||
normalizeTriggerTaskOptions,
|
||||
type RunJSAIEmployeeTriggerTaskOptions,
|
||||
} from '../utils/normalizeTriggerTaskOptions';
|
||||
import { normalizeTriggerTaskOptions, type RunJSAIEmployeeTriggerTaskOptions } from '../utils';
|
||||
import { getMountedChatBox } from '../stores/mounted-chat-boxes';
|
||||
import { ChatBoxRuntimeProvider, getGlobalChatBoxRuntime, useChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export const ChatBoxLayout: React.FC<{
|
||||
children?: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
return (
|
||||
<ChatBoxRuntimeProvider runtime={getGlobalChatBoxRuntime()}>
|
||||
<ChatBoxLayoutContent>{children}</ChatBoxLayoutContent>
|
||||
</ChatBoxRuntimeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatBoxLayoutContent: React.FC<{
|
||||
children?: React.ReactNode;
|
||||
}> = observer(({ children }) => {
|
||||
const app = useApp();
|
||||
const { isMobileLayout } = useMobileLayout();
|
||||
const open = useChatBoxStore.use.open();
|
||||
const expanded = useChatBoxStore.use.expanded();
|
||||
const showDebugPanel = useChatBoxStore.use.showDebugPanel();
|
||||
const setOpen = useChatBoxStore.use.setOpen();
|
||||
const activeTool = useChatToolsStore.use.activeTool();
|
||||
const { chatBoxModel, chatToolModel } = useChatBoxRuntime();
|
||||
const open = chatBoxModel.open;
|
||||
const expanded = chatBoxModel.expanded;
|
||||
const showDebugPanel = chatBoxModel.showDebugPanel;
|
||||
const activeTool = chatToolModel.activeTool;
|
||||
const { loadUnreadCounts } = useChatConversationActions();
|
||||
const { triggerTask } = useChatBoxActions();
|
||||
const aiConfigRepository = useAIConfigRepository();
|
||||
const t = useT();
|
||||
|
||||
const refreshUnreadCounts = useCallback(() => {
|
||||
loadUnreadCounts().catch(console.error);
|
||||
@@ -77,6 +85,17 @@ export const ChatBoxLayout: React.FC<{
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const targetChatBoxUid = normalized.chatBoxUid;
|
||||
const targetChatBox = targetChatBoxUid ? getMountedChatBox(targetChatBoxUid) : undefined;
|
||||
if (targetChatBoxUid && !targetChatBox) {
|
||||
notification.error({
|
||||
message: t('AI chat box not found', { uid: targetChatBoxUid }),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
if (targetChatBox) {
|
||||
return targetChatBox.triggerTask(normalized);
|
||||
}
|
||||
return triggerTask(normalized);
|
||||
})
|
||||
.catch(console.error);
|
||||
@@ -88,7 +107,7 @@ export const ChatBoxLayout: React.FC<{
|
||||
aiManager?.onChatBoxUnmounted();
|
||||
app.eventBus.removeEventListener(AI_EMPLOYEE_TRIGGER_TASK_EVENT, handler);
|
||||
};
|
||||
}, [aiConfigRepository, app.apiClient, app.eventBus, app.pm, triggerTask]);
|
||||
}, [aiConfigRepository, app.apiClient, app.eventBus, app.pm, t, triggerTask]);
|
||||
|
||||
const panelWidth = 450;
|
||||
const zIndex = 1100;
|
||||
@@ -128,7 +147,7 @@ html body {
|
||||
panelWidth={panelWidth}
|
||||
zIndex={zIndex}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
chatBoxModel.setOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
@@ -138,7 +157,7 @@ html body {
|
||||
{showDebugPanel ? <DebugPanel /> : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const ChatBoxWrapper: React.FC<{
|
||||
expanded: boolean;
|
||||
@@ -148,7 +167,8 @@ const ChatBoxWrapper: React.FC<{
|
||||
onClose: () => void;
|
||||
}> = observer(({ expanded, isMobileLayout, panelWidth, zIndex, onClose }) => {
|
||||
const { token } = theme.useToken();
|
||||
const minimize = useChatBoxStore.use.minimize();
|
||||
const { chatBoxModel } = useChatBoxRuntime();
|
||||
const minimize = chatBoxModel.minimize;
|
||||
const dialogZIndex = dialogController.shouldHide ? -1 : zIndex;
|
||||
|
||||
if (isMobileLayout) {
|
||||
@@ -232,10 +252,9 @@ const MobileLayoutChatBox: React.FC<{
|
||||
});
|
||||
|
||||
const ChatBoxMinimizeControl: React.FC = () => {
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const minimize = useChatBoxStore.use.minimize();
|
||||
const setMinimize = useChatBoxStore.use.setMinimize();
|
||||
const setOpen = useChatBoxStore.use.setOpen();
|
||||
const { chatBoxModel } = useChatBoxRuntime();
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const minimize = chatBoxModel.minimize;
|
||||
const t = useT();
|
||||
const [api, contextHolder] = notification.useNotification();
|
||||
const key = React.useRef(`ai-chat-box-minimize-control-${Date.now()}`);
|
||||
@@ -257,8 +276,8 @@ const ChatBoxMinimizeControl: React.FC = () => {
|
||||
type="text"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
setMinimize(false);
|
||||
chatBoxModel.setOpen(false);
|
||||
chatBoxModel.setMinimize(false);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
@@ -269,7 +288,7 @@ const ChatBoxMinimizeControl: React.FC = () => {
|
||||
width: 200,
|
||||
},
|
||||
onClick() {
|
||||
setMinimize(false);
|
||||
chatBoxModel.setMinimize(false);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -279,7 +298,7 @@ const ChatBoxMinimizeControl: React.FC = () => {
|
||||
return () => {
|
||||
api.destroy(notificationKey);
|
||||
};
|
||||
}, [api, currentEmployeeAvatar, minimize, setMinimize, setOpen, t]);
|
||||
}, [api, chatBoxModel, currentEmployeeAvatar, minimize, t]);
|
||||
|
||||
return <>{contextHolder}</>;
|
||||
};
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 from 'react';
|
||||
import { Badge, type BadgeProps } from 'antd';
|
||||
import { observer } from '@nocobase/flow-engine';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
export type ChatBoxUnreadBadgeProps = {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
offset?: BadgeProps['offset'];
|
||||
showCount?: boolean;
|
||||
};
|
||||
|
||||
export const ChatBoxUnreadBadge: React.FC<ChatBoxUnreadBadgeProps> = observer(
|
||||
({ children, className, offset, showCount = false }) => {
|
||||
const runtime = useChatBoxRuntime();
|
||||
const unreadCount =
|
||||
runtime.mode === 'block'
|
||||
? runtime.chatConversationModel.conversations.filter((conversation) => !conversation.read).length
|
||||
: runtime.chatConversationModel.unreadCount + runtime.workflowTaskModel.unreadCount;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={className}
|
||||
count={showCount ? unreadCount : undefined}
|
||||
dot={showCount ? false : unreadCount > 0}
|
||||
offset={offset}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
);
|
||||
+5
-6
@@ -18,7 +18,7 @@ import { useChat } from '../hooks/useChat';
|
||||
import { useChatBoxActions } from '../hooks/useChatBoxActions';
|
||||
import { useChatConversationActions } from '../hooks/useChatConversationActions';
|
||||
import { useWorkflowTasks } from '../hooks/useWorkflowTasks';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
const icon = new URL('../../icon.svg', import.meta.url).toString();
|
||||
|
||||
@@ -35,10 +35,9 @@ export const ChatButton: React.FC = observer(() => {
|
||||
const repository = useAIConfigRepository();
|
||||
const aiEmployees = repository.aiEmployees;
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const open = useChatBoxStore.use.open();
|
||||
const { chatBoxModel } = useChatBoxRuntime();
|
||||
const open = chatBoxModel.open;
|
||||
const chat = useChat();
|
||||
const setOpen = useChatBoxStore.use.setOpen();
|
||||
const setReadonly = useChatBoxStore.use.setReadonly();
|
||||
const [badgeAnimating, setBadgeAnimating] = useState(false);
|
||||
const prevUnreadCountRef = useRef(0);
|
||||
const badgeAnimationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -85,9 +84,9 @@ export const ChatButton: React.FC = observer(() => {
|
||||
}
|
||||
setBadgeAnimating(false);
|
||||
setDropdownOpen(false);
|
||||
setReadonly(false);
|
||||
chatBoxModel.setReadonly(false);
|
||||
chat.setResponseLoading(false);
|
||||
setOpen(true);
|
||||
chatBoxModel.setOpen(true);
|
||||
const leaderEmployee = aiEmployees.find((employee) => employee.builtIn && employee.username === 'atlas');
|
||||
if (leaderEmployee) {
|
||||
switchAIEmployee(leaderEmployee);
|
||||
|
||||
+103
-96
@@ -7,7 +7,7 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
App as AntdApp,
|
||||
Badge,
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
theme,
|
||||
} from 'antd';
|
||||
import { DeleteOutlined, EditOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { Conversations as AntConversations, type ConversationsProps } from '@ant-design/x';
|
||||
import { Conversations as AntConversations, type ConversationsProps as AntConversationsProps } from '@ant-design/x';
|
||||
import { useApp } from '@nocobase/client-v2';
|
||||
import { dayjs } from '@nocobase/utils/client';
|
||||
import { useT } from '../../../locale';
|
||||
@@ -37,18 +37,38 @@ import { useChatBoxActions } from '../hooks/useChatBoxActions';
|
||||
import { useChatConversationActions } from '../hooks/useChatConversationActions';
|
||||
import { useChatMessageActions } from '../hooks/useChatMessageActions';
|
||||
import { useWorkflowTasks } from '../hooks/useWorkflowTasks';
|
||||
import { useChatBoxStore, type ModelRef } from '../stores/chat-box';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { useWorkflowTasksStore } from '../stores/workflow-tasks';
|
||||
import { type ModelRef } from '../stores/chat-box';
|
||||
import type { WorkflowTask } from '../stores/workflow-tasks';
|
||||
import { useAIConfigRepository } from '../../../repositories/hooks/useAIConfigRepository';
|
||||
import { observer } from '@nocobase/flow-engine';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
type RenameTarget = {
|
||||
key: string;
|
||||
title: string;
|
||||
} | null;
|
||||
|
||||
export const Conversations: React.FC = memo(() => {
|
||||
export const getConversationItems = (
|
||||
conversations: Conversation[],
|
||||
t: (key: string) => string,
|
||||
): AntConversationsProps['items'] => {
|
||||
return conversations.map((item) => {
|
||||
const title = item.title || t('New conversation');
|
||||
return {
|
||||
key: item.sessionId,
|
||||
title,
|
||||
label: title,
|
||||
icon: !item.read ? <Badge dot offset={[-3, 0]} /> : undefined,
|
||||
timestamp: item.updatedAt ? new Date(item.updatedAt).getTime() : undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export type ConversationsProps = {
|
||||
onOpen?: () => void;
|
||||
};
|
||||
|
||||
export const Conversations: React.FC<ConversationsProps> = observer(({ onOpen }) => {
|
||||
const t = useT();
|
||||
const app = useApp();
|
||||
const { modal, message } = AntdApp.useApp();
|
||||
@@ -58,26 +78,22 @@ export const Conversations: React.FC = memo(() => {
|
||||
const [pendingWorkflowTask, setPendingWorkflowTask] = useState<string>();
|
||||
const aiConfigRepository = useAIConfigRepository();
|
||||
const aiEmployeesMap = aiConfigRepository.getAIEmployeesMap();
|
||||
const conversations = useChatConversationsStore.use.conversations();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const conversationSegmented = useChatConversationsStore.use.conversationSegmented();
|
||||
const setConversationSegmented = useChatConversationsStore.use.setConversationSegmented();
|
||||
const keyword = useChatConversationsStore.use.keyword();
|
||||
const setKeyword = useChatConversationsStore.use.setKeyword();
|
||||
const setCurrentConversation = useChatConversationsStore.use.setCurrentConversation();
|
||||
const setCurrentEmployee = useChatBoxStore.use.setCurrentEmployee();
|
||||
const setReadonly = useChatBoxStore.use.setReadonly();
|
||||
const setShowConversations = useChatBoxStore.use.setShowConversations();
|
||||
const setModel = useChatBoxStore.use.setModel();
|
||||
const expanded = useChatBoxStore.use.expanded();
|
||||
const setCurrentWorkflowTask = useWorkflowTasksStore.use.setCurrentWorkflowTask();
|
||||
const runtime = useChatBoxRuntime();
|
||||
const { chatBoxModel, chatConversationModel, workflowTaskModel } = runtime;
|
||||
const showWorkflowTasks = runtime.mode !== 'block';
|
||||
const conversations = chatConversationModel.conversations;
|
||||
const currentConversation = chatConversationModel.currentConversation;
|
||||
const conversationSegmented = chatConversationModel.conversationSegmented;
|
||||
const activeSegmented = showWorkflowTasks ? conversationSegmented : 'conversations';
|
||||
const keyword = chatConversationModel.keyword;
|
||||
const expanded = chatBoxModel.expanded;
|
||||
const {
|
||||
refresh,
|
||||
runSearch: runSearchConversations,
|
||||
conversationsService,
|
||||
lastConversationRef,
|
||||
unreadCount: unreadConversationCount,
|
||||
} = useChatConversationActions();
|
||||
} = useChatConversationActions(runtime);
|
||||
const {
|
||||
refresh: refreshWorkflowTasks,
|
||||
runSearch: runSearchWorkflowTasks,
|
||||
@@ -91,10 +107,10 @@ export const Conversations: React.FC = memo(() => {
|
||||
unreadCount: unreadWorkflowTaskCount,
|
||||
acceptWorkflowTask,
|
||||
getWorkflowTaskBySession,
|
||||
} = useWorkflowTasks();
|
||||
const { loadMessages, getConversationLLMActiveState, resumeStream } = useChatMessageActions();
|
||||
const { startNewConversation, clear } = useChatBoxActions();
|
||||
const chat = useChat(currentConversation);
|
||||
} = useWorkflowTasks(runtime);
|
||||
const { loadMessages, getConversationLLMActiveState, resumeStream } = useChatMessageActions(runtime);
|
||||
const { startNewConversation, clear } = useChatBoxActions(runtime);
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const latestOpenVersionRef = useRef(0);
|
||||
|
||||
const hasActiveStream = useCallback(
|
||||
@@ -116,7 +132,7 @@ export const Conversations: React.FC = memo(() => {
|
||||
!aiEmployee ||
|
||||
hasActiveStream(sessionId) ||
|
||||
latestOpenVersionRef.current !== openVersion ||
|
||||
useChatConversationsStore.getState().currentConversation !== sessionId
|
||||
chatConversationModel.currentConversation !== sessionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -125,7 +141,7 @@ export const Conversations: React.FC = memo(() => {
|
||||
if (
|
||||
hasActiveStream(sessionId) ||
|
||||
latestOpenVersionRef.current !== openVersion ||
|
||||
useChatConversationsStore.getState().currentConversation !== sessionId ||
|
||||
chatConversationModel.currentConversation !== sessionId ||
|
||||
llmActiveState !== 'streaming'
|
||||
) {
|
||||
return;
|
||||
@@ -136,7 +152,7 @@ export const Conversations: React.FC = memo(() => {
|
||||
aiEmployee,
|
||||
});
|
||||
},
|
||||
[aiEmployeesMap, getConversationLLMActiveState, hasActiveStream, loadMessages, resumeStream],
|
||||
[aiEmployeesMap, chatConversationModel, getConversationLLMActiveState, hasActiveStream, loadMessages, resumeStream],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -144,12 +160,12 @@ export const Conversations: React.FC = memo(() => {
|
||||
}, [aiConfigRepository]);
|
||||
|
||||
useEffect(() => {
|
||||
if (conversationSegmented === 'conversations') {
|
||||
if (activeSegmented === 'conversations') {
|
||||
refresh();
|
||||
} else {
|
||||
refreshWorkflowTasks();
|
||||
}
|
||||
}, [conversationSegmented, refresh, refreshWorkflowTasks]);
|
||||
}, [activeSegmented, refresh, refreshWorkflowTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
const lastItem = listRef.current?.querySelector('.ant-conversations-item:last-child');
|
||||
@@ -173,19 +189,20 @@ export const Conversations: React.FC = memo(() => {
|
||||
}, [currentConversation, pendingWorkflowTask]);
|
||||
|
||||
const openConversation = useCallback(
|
||||
(sessionId: string, username?: string, model?: ModelRef) => {
|
||||
(sessionId: string, username?: string, model?: ModelRef | null) => {
|
||||
if (sessionId === currentConversation) {
|
||||
setShowConversations(false);
|
||||
chatBoxModel.setShowConversations(false);
|
||||
onOpen?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = conversations.find((item) => item.sessionId === sessionId);
|
||||
setCurrentConversation(sessionId);
|
||||
chatConversationModel.setCurrentConversation(sessionId);
|
||||
const aiEmployee = username ? aiEmployeesMap[username] : conversation?.aiEmployee;
|
||||
if (username) {
|
||||
setCurrentEmployee(aiEmployee);
|
||||
chatBoxModel.setCurrentEmployee(aiEmployee);
|
||||
} else {
|
||||
setCurrentEmployee(conversation?.aiEmployee);
|
||||
chatBoxModel.setCurrentEmployee(conversation?.aiEmployee);
|
||||
}
|
||||
const sessionChat = chat.for(sessionId);
|
||||
const sessionState = sessionChat.getState();
|
||||
@@ -204,27 +221,27 @@ export const Conversations: React.FC = memo(() => {
|
||||
sessionChat.setMessages([]);
|
||||
clear(undefined, sessionId);
|
||||
}
|
||||
setModel(model ?? (conversation ? getConversationModel(conversation) : null));
|
||||
chatBoxModel.setModel(model ?? (conversation ? getConversationModel(conversation) : null));
|
||||
if (!shouldReuseLocalSession) {
|
||||
resumeAfterLoad(sessionId, aiEmployee?.username).catch(console.error);
|
||||
}
|
||||
if (!expanded) {
|
||||
setShowConversations(false);
|
||||
chatBoxModel.setShowConversations(false);
|
||||
}
|
||||
onOpen?.();
|
||||
},
|
||||
[
|
||||
aiEmployeesMap,
|
||||
chatBoxModel,
|
||||
chat,
|
||||
clear,
|
||||
conversations,
|
||||
currentConversation,
|
||||
expanded,
|
||||
hasActiveStream,
|
||||
onOpen,
|
||||
resumeAfterLoad,
|
||||
setCurrentConversation,
|
||||
setCurrentEmployee,
|
||||
setModel,
|
||||
setShowConversations,
|
||||
chatConversationModel,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -234,16 +251,16 @@ export const Conversations: React.FC = memo(() => {
|
||||
try {
|
||||
await acceptWorkflowTask(sessionId);
|
||||
const task = await getWorkflowTaskBySession(sessionId);
|
||||
setReadonly(task?.readonly === true);
|
||||
chatBoxModel.setReadonly(task?.readonly === true);
|
||||
chat.for(sessionId).setResponseLoading(task?.status === 'processing');
|
||||
setShowConversations(false);
|
||||
chatBoxModel.setShowConversations(false);
|
||||
openConversation(sessionId, task?.config?.username, task?.config?.model ?? undefined);
|
||||
} catch (error) {
|
||||
setPendingWorkflowTask(undefined);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[acceptWorkflowTask, chat, getWorkflowTaskBySession, openConversation, setReadonly, setShowConversations],
|
||||
[acceptWorkflowTask, chat, chatBoxModel, getWorkflowTaskBySession, openConversation],
|
||||
);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
@@ -269,18 +286,8 @@ export const Conversations: React.FC = memo(() => {
|
||||
[deleteConversation, modal, t],
|
||||
);
|
||||
|
||||
const items = useMemo<ConversationsProps['items']>(
|
||||
() =>
|
||||
conversations.map((item) => {
|
||||
const title = item.title || t('New conversation');
|
||||
return {
|
||||
key: item.sessionId,
|
||||
title,
|
||||
label: title,
|
||||
icon: !item.read ? <Badge dot offset={[-3, 0]} /> : undefined,
|
||||
timestamp: item.updatedAt ? new Date(item.updatedAt).getTime() : undefined,
|
||||
};
|
||||
}),
|
||||
const items = useMemo<AntConversationsProps['items']>(
|
||||
() => getConversationItems(conversations, t),
|
||||
[conversations, t],
|
||||
);
|
||||
|
||||
@@ -332,18 +339,18 @@ export const Conversations: React.FC = memo(() => {
|
||||
<Input.Search
|
||||
value={keyword}
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value);
|
||||
chatConversationModel.setKeyword(event.target.value);
|
||||
}}
|
||||
placeholder={t('Search')}
|
||||
onSearch={(value) => {
|
||||
if (conversationSegmented === 'conversations') {
|
||||
if (activeSegmented === 'conversations') {
|
||||
runSearchConversations(value);
|
||||
} else {
|
||||
runSearchWorkflowTasks(value);
|
||||
}
|
||||
}}
|
||||
onClear={() => {
|
||||
if (conversationSegmented === 'conversations') {
|
||||
if (activeSegmented === 'conversations') {
|
||||
runSearchConversations('');
|
||||
} else {
|
||||
runSearchWorkflowTasks('');
|
||||
@@ -351,34 +358,36 @@ export const Conversations: React.FC = memo(() => {
|
||||
}}
|
||||
allowClear
|
||||
/>
|
||||
<Segmented
|
||||
style={{ width: '100%', marginTop: 8 }}
|
||||
className="ai-chatbox-conversations-segmented"
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<Space>
|
||||
{t('Conversations')}
|
||||
<Badge count={unreadConversationCount} size="small" />
|
||||
</Space>
|
||||
),
|
||||
value: 'conversations',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Space>
|
||||
{t('Workflow tasks')}
|
||||
<Badge count={unreadWorkflowTaskCount} size="small" />
|
||||
</Space>
|
||||
),
|
||||
value: 'workflowTasks',
|
||||
},
|
||||
]}
|
||||
value={conversationSegmented}
|
||||
onChange={(value) => {
|
||||
setConversationSegmented(String(value));
|
||||
}}
|
||||
/>
|
||||
{showWorkflowTasks ? (
|
||||
<Segmented
|
||||
style={{ width: '100%', marginTop: 8 }}
|
||||
className="ai-chatbox-conversations-segmented"
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<Space>
|
||||
{t('Conversations')}
|
||||
<Badge count={unreadConversationCount} size="small" />
|
||||
</Space>
|
||||
),
|
||||
value: 'conversations',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Space>
|
||||
{t('Workflow tasks')}
|
||||
<Badge count={unreadWorkflowTaskCount} size="small" />
|
||||
</Space>
|
||||
),
|
||||
value: 'workflowTasks',
|
||||
},
|
||||
]}
|
||||
value={conversationSegmented}
|
||||
onChange={(value) => {
|
||||
chatConversationModel.setConversationSegmented(String(value));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
ref={listRef}
|
||||
@@ -389,15 +398,15 @@ export const Conversations: React.FC = memo(() => {
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
{conversationSegmented === 'conversations' && conversationsService.loading ? <Spin /> : null}
|
||||
{conversationSegmented === 'conversations' && items.length ? (
|
||||
{activeSegmented === 'conversations' && conversationsService.loading ? <Spin /> : null}
|
||||
{activeSegmented === 'conversations' && items.length ? (
|
||||
<AntConversations
|
||||
className="ai-chatbox-conversations-list"
|
||||
activeKey={currentConversation}
|
||||
items={items}
|
||||
onActiveChange={(sessionId) => {
|
||||
setReadonly(false);
|
||||
setCurrentWorkflowTask(undefined);
|
||||
chatBoxModel.setReadonly(false);
|
||||
workflowTaskModel.setCurrentWorkflowTask(undefined);
|
||||
const conversation = conversations.find((item) => item.sessionId === sessionId);
|
||||
openConversation(sessionId, conversation?.aiEmployee?.username, getConversationModel(conversation));
|
||||
}}
|
||||
@@ -429,7 +438,7 @@ export const Conversations: React.FC = memo(() => {
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
{conversationSegmented === 'workflowTasks' ? (
|
||||
{showWorkflowTasks && activeSegmented === 'workflowTasks' ? (
|
||||
<WorkflowTasksList
|
||||
currentConversation={currentConversation}
|
||||
pendingConversation={pendingWorkflowTask}
|
||||
@@ -443,9 +452,7 @@ export const Conversations: React.FC = memo(() => {
|
||||
onOpen={openWorkflowTask}
|
||||
/>
|
||||
) : null}
|
||||
{conversationSegmented === 'conversations' && !items.length ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : null}
|
||||
{activeSegmented === 'conversations' && !items.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} /> : null}
|
||||
</div>
|
||||
<Modal
|
||||
title={t('Rename conversation')}
|
||||
@@ -467,8 +474,8 @@ export const Conversations: React.FC = memo(() => {
|
||||
);
|
||||
});
|
||||
|
||||
function getConversationModel(conversation: Conversation): ModelRef | null {
|
||||
const modelSettings = conversation.options?.modelSettings;
|
||||
export function getConversationModel(conversation?: Conversation): ModelRef | null {
|
||||
const modelSettings = conversation?.options?.modelSettings;
|
||||
if (!modelSettings?.llmService || !modelSettings.model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
+8
-8
@@ -10,10 +10,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Drawer, Empty, Input, List, Select, Space, Tag, Tooltip, Typography, message, theme } from 'antd';
|
||||
import { CopyOutlined, DownloadOutlined, DownOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { observer } from '@nocobase/flow-engine';
|
||||
import { useT } from '../../../locale';
|
||||
import { aiDebugLogger, type LogEntry, type LogType } from '../../../debug-logger';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
const { Search } = Input;
|
||||
const { Text } = Typography;
|
||||
@@ -47,12 +47,12 @@ const LOG_TYPE_OPTIONS = [
|
||||
{ value: 'error', label: 'Error' },
|
||||
];
|
||||
|
||||
export const DebugPanel: React.FC = () => {
|
||||
export const DebugPanel: React.FC = observer(() => {
|
||||
const t = useT();
|
||||
const { token } = theme.useToken();
|
||||
const showDebugPanel = useChatBoxStore.use.showDebugPanel();
|
||||
const setShowDebugPanel = useChatBoxStore.use.setShowDebugPanel();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const { chatBoxModel, chatConversationModel } = useChatBoxRuntime();
|
||||
const showDebugPanel = chatBoxModel.showDebugPanel;
|
||||
const currentConversation = chatConversationModel.currentConversation;
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [filterType, setFilterType] = useState<string>('all');
|
||||
const [searchText, setSearchText] = useState('');
|
||||
@@ -134,7 +134,7 @@ export const DebugPanel: React.FC = () => {
|
||||
placement="right"
|
||||
width={500}
|
||||
open={showDebugPanel}
|
||||
onClose={() => setShowDebugPanel(false)}
|
||||
onClose={() => chatBoxModel.setShowDebugPanel(false)}
|
||||
styles={{ body: { padding: 0, overflow: 'hidden' } }}
|
||||
extra={
|
||||
<Button icon={<DownloadOutlined />} size="small" onClick={handleExport} disabled={logs.length === 0}>
|
||||
@@ -193,7 +193,7 @@ export const DebugPanel: React.FC = () => {
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const LogItem: React.FC<{
|
||||
log: LogEntry;
|
||||
|
||||
+124
-119
@@ -40,10 +40,11 @@ import remarkGfm from 'remark-gfm';
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter';
|
||||
import { dark, defaultStyle } from 'react-syntax-highlighter/dist/esm/styles/hljs';
|
||||
import { useGlobalTheme } from '@nocobase/client-v2';
|
||||
import { observer } from '@nocobase/flow-engine';
|
||||
import { useT } from '../../../locale';
|
||||
import type { Message } from '../../types';
|
||||
import { useChat } from '../hooks/useChat';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
import { Actions } from './Actions';
|
||||
|
||||
const downloadIconPath =
|
||||
@@ -155,13 +156,14 @@ export const CodeBasic: React.FC<CodeProps> = ({ children, className, node, ...r
|
||||
);
|
||||
};
|
||||
|
||||
export const Code: React.FC<CodeProps> = ({ children, className, node, message: chatMessage, ...rest }) => {
|
||||
export const Code: React.FC<CodeProps> = observer(({ children, className, node, message: chatMessage, ...rest }) => {
|
||||
const { token } = theme.useToken();
|
||||
const { message } = App.useApp();
|
||||
const { isDarkTheme } = useGlobalTheme();
|
||||
const t = useT();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
const runtime = useChatBoxRuntime();
|
||||
const currentConversation = runtime.chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const editorRefMap = chat.use.editorRef();
|
||||
const currentEditorRefUid = chat.use.currentEditorRefUid();
|
||||
const editorRef = currentEditorRefUid ? editorRefMap?.[currentEditorRefUid] : null;
|
||||
@@ -269,7 +271,7 @@ export const Code: React.FC<CodeProps> = ({ children, className, node, message:
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
type FormProps = React.ComponentProps<'form'> & {
|
||||
node?: {
|
||||
@@ -404,131 +406,134 @@ class EchartsErrorBoundary extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
const Echarts: React.FC<EchartsProps> = React.memo(
|
||||
({ children, message, ...rest }) => {
|
||||
const chartRef = useRef<ReactECharts>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { token } = theme.useToken();
|
||||
const { isDarkTheme } = useGlobalTheme();
|
||||
const t = useT();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
const responseLoading = chat.use.responseLoading();
|
||||
const optionSource = React.Children.toArray(children).join('');
|
||||
let option: Record<string, unknown> = {};
|
||||
let optionValid = true;
|
||||
const Echarts: React.FC<EchartsProps> = observer(
|
||||
React.memo(
|
||||
({ children, message, ...rest }) => {
|
||||
const chartRef = useRef<ReactECharts>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { token } = theme.useToken();
|
||||
const { isDarkTheme } = useGlobalTheme();
|
||||
const t = useT();
|
||||
const runtime = useChatBoxRuntime();
|
||||
const currentConversation = runtime.chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const responseLoading = chat.use.responseLoading();
|
||||
const optionSource = React.Children.toArray(children).join('');
|
||||
let option: Record<string, unknown> = {};
|
||||
let optionValid = true;
|
||||
|
||||
try {
|
||||
option = JSON.parse(optionSource) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
optionValid = false;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!optionValid) {
|
||||
return;
|
||||
try {
|
||||
option = JSON.parse(optionSource) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
optionValid = false;
|
||||
}
|
||||
|
||||
const resizeChart = () => {
|
||||
chartRef.current?.getEchartsInstance()?.resize();
|
||||
};
|
||||
const timeouts = [0, 100, 300].map((delay) => window.setTimeout(resizeChart, delay));
|
||||
const observer =
|
||||
typeof ResizeObserver !== 'undefined' && containerRef.current
|
||||
? new ResizeObserver(() => {
|
||||
resizeChart();
|
||||
})
|
||||
: null;
|
||||
|
||||
if (observer && containerRef.current) {
|
||||
observer.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
timeouts.forEach((timeoutId) => window.clearTimeout(timeoutId));
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [optionSource, optionValid]);
|
||||
|
||||
if (responseLoading && !message?.messageId) {
|
||||
return <Spin size="small" tip={t('Generating')} />;
|
||||
}
|
||||
|
||||
if (!optionValid) {
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={t('ECharts')}
|
||||
styles={{
|
||||
title: {
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: 400,
|
||||
},
|
||||
body: {
|
||||
width: '100%',
|
||||
fontSize: token.fontSizeSM,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SyntaxHighlighter {...rest} PreTag="div" language="json" style={isDarkTheme ? dark : defaultStyle}>
|
||||
{optionSource.replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EchartsErrorBoundary
|
||||
resetKey={optionSource}
|
||||
fallback={(error) =>
|
||||
responseLoading ? null : (
|
||||
<Alert showIcon type="error" message={t('Invalid chart options')} description={error.message} />
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!optionValid) {
|
||||
return;
|
||||
}
|
||||
>
|
||||
<div ref={containerRef}>
|
||||
<ReactECharts
|
||||
ref={chartRef}
|
||||
echarts={echarts}
|
||||
option={{
|
||||
...option,
|
||||
toolbox: {
|
||||
show: true,
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
title: t('Save as image'),
|
||||
icon: `path://${downloadIconPath}`,
|
||||
iconStyle: {
|
||||
fontSize: token.fontSizeSM,
|
||||
color: token.colorText,
|
||||
borderWidth: 0,
|
||||
},
|
||||
emphasis: {
|
||||
|
||||
const resizeChart = () => {
|
||||
chartRef.current?.getEchartsInstance()?.resize();
|
||||
};
|
||||
const timeouts = [0, 100, 300].map((delay) => window.setTimeout(resizeChart, delay));
|
||||
const observer =
|
||||
typeof ResizeObserver !== 'undefined' && containerRef.current
|
||||
? new ResizeObserver(() => {
|
||||
resizeChart();
|
||||
})
|
||||
: null;
|
||||
|
||||
if (observer && containerRef.current) {
|
||||
observer.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
timeouts.forEach((timeoutId) => window.clearTimeout(timeoutId));
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [optionSource, optionValid]);
|
||||
|
||||
if (responseLoading && !message?.messageId) {
|
||||
return <Spin size="small" tip={t('Generating')} />;
|
||||
}
|
||||
|
||||
if (!optionValid) {
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={t('ECharts')}
|
||||
styles={{
|
||||
title: {
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: 400,
|
||||
},
|
||||
body: {
|
||||
width: '100%',
|
||||
fontSize: token.fontSizeSM,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SyntaxHighlighter {...rest} PreTag="div" language="json" style={isDarkTheme ? dark : defaultStyle}>
|
||||
{optionSource.replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EchartsErrorBoundary
|
||||
resetKey={optionSource}
|
||||
fallback={(error) =>
|
||||
responseLoading ? null : (
|
||||
<Alert showIcon type="error" message={t('Invalid chart options')} description={error.message} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<div ref={containerRef}>
|
||||
<ReactECharts
|
||||
ref={chartRef}
|
||||
echarts={echarts}
|
||||
option={{
|
||||
...option,
|
||||
toolbox: {
|
||||
show: true,
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
title: t('Save as image'),
|
||||
icon: `path://${downloadIconPath}`,
|
||||
iconStyle: {
|
||||
fontSize: token.fontSizeSM,
|
||||
color: token.colorLinkHover,
|
||||
color: token.colorText,
|
||||
borderWidth: 0,
|
||||
},
|
||||
emphasis: {
|
||||
iconStyle: {
|
||||
fontSize: token.fontSizeSM,
|
||||
color: token.colorLinkHover,
|
||||
borderWidth: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
theme={isDarkTheme ? 'dark' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
</EchartsErrorBoundary>
|
||||
);
|
||||
},
|
||||
(previous, next) => {
|
||||
const previousChildren = React.Children.toArray(previous.children).join('');
|
||||
const nextChildren = React.Children.toArray(next.children).join('');
|
||||
return (
|
||||
previousChildren === nextChildren &&
|
||||
previous.message?.messageId === next.message?.messageId &&
|
||||
previous.index === next.index
|
||||
);
|
||||
},
|
||||
}}
|
||||
theme={isDarkTheme ? 'dark' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
</EchartsErrorBoundary>
|
||||
);
|
||||
},
|
||||
(previous, next) => {
|
||||
const previousChildren = React.Children.toArray(previous.children).join('');
|
||||
const nextChildren = React.Children.toArray(next.children).join('');
|
||||
return (
|
||||
previousChildren === nextChildren &&
|
||||
previous.message?.messageId === next.message?.messageId &&
|
||||
previous.index === next.index
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Echarts.displayName = 'Echarts';
|
||||
|
||||
+36
-26
@@ -19,8 +19,7 @@ import { useAIConfigRepository } from '../../../repositories/hooks/useAIConfigRe
|
||||
import { useChat } from '../hooks/useChat';
|
||||
import { useChatBoxActions } from '../hooks/useChatBoxActions';
|
||||
import { useChatMessageActions } from '../hooks/useChatMessageActions';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
import { FileCardList } from './Attachments';
|
||||
import { Actions } from './Actions';
|
||||
import { ContextItem } from './ContextItem';
|
||||
@@ -30,7 +29,10 @@ import { ToolCard } from './ToolCard';
|
||||
const { Link, Paragraph, Text } = Typography;
|
||||
|
||||
type MessagePayload = Message['content'];
|
||||
type ChatBubbleRole = Omit<React.ComponentProps<typeof Bubble>, 'content' | 'messageRender' | 'loadingRender'> & {
|
||||
export type ChatBubbleRole = Omit<
|
||||
React.ComponentProps<typeof Bubble>,
|
||||
'content' | 'messageRender' | 'loadingRender'
|
||||
> & {
|
||||
nickname?: string;
|
||||
content?: MessagePayload;
|
||||
messageRender?: (content: MessagePayload) => React.ReactNode;
|
||||
@@ -214,10 +216,12 @@ export const AIMessage: React.FC<{
|
||||
const aiConfigRepository = useAIConfigRepository();
|
||||
const toolsLoading = aiConfigRepository.aiToolsLoading;
|
||||
const toolsMap = useMemo(() => toToolsMap(aiConfigRepository.aiTools || []), [aiConfigRepository.aiTools]);
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const readonly = useChatBoxStore.use.readonly();
|
||||
const { resendMessages } = useChatMessageActions();
|
||||
const runtime = useChatBoxRuntime();
|
||||
const { chatBoxModel, chatConversationModel } = runtime;
|
||||
const currentConversation = chatConversationModel.currentConversation;
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const readonly = chatBoxModel.readonly;
|
||||
const { resendMessages } = useChatMessageActions(runtime);
|
||||
const footerButtonStyle: React.CSSProperties = {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSizeSM,
|
||||
@@ -319,13 +323,14 @@ const Reference: React.FC<{ references: { title?: string; url?: string }[] }> =
|
||||
|
||||
export const UserMessage: React.FC<{
|
||||
msg: MessagePayload;
|
||||
}> = memo(({ msg }) => {
|
||||
}> = observer(({ msg }) => {
|
||||
const t = useT();
|
||||
const { token } = theme.useToken();
|
||||
const setSenderValue = useChatBoxStore.use.setSenderValue();
|
||||
const senderRef = useChatBoxStore.use.senderRef();
|
||||
const readonly = useChatBoxStore.use.readonly();
|
||||
const { startEditingMessage } = useChatMessageActions();
|
||||
const runtime = useChatBoxRuntime();
|
||||
const { chatBoxModel, chatSenderModel } = runtime;
|
||||
const senderRef = chatSenderModel.senderRef;
|
||||
const readonly = chatBoxModel.readonly;
|
||||
const { startEditingMessage } = useChatMessageActions(runtime);
|
||||
const footerButtonStyle: React.CSSProperties = {
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: token.fontSizeSM,
|
||||
@@ -367,7 +372,7 @@ export const UserMessage: React.FC<{
|
||||
...msg,
|
||||
messageId: msg.messageId,
|
||||
});
|
||||
setSenderValue(stringifyContent(msg.content));
|
||||
chatSenderModel.setSenderValue(stringifyContent(msg.content));
|
||||
senderRef?.current?.focus();
|
||||
}}
|
||||
/>
|
||||
@@ -428,12 +433,14 @@ UserMessage.displayName = 'UserMessage';
|
||||
|
||||
export const ErrorMessage: React.FC<{
|
||||
msg: MessagePayload;
|
||||
}> = memo(({ msg }) => {
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
}> = observer(({ msg }) => {
|
||||
const runtime = useChatBoxRuntime();
|
||||
const { chatBoxModel, chatConversationModel } = runtime;
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const currentConversation = chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const messages = chat.use.messages();
|
||||
const { resendMessages } = useChatMessageActions();
|
||||
const { resendMessages } = useChatMessageActions(runtime);
|
||||
const showAlert = msg.content !== 'GraphRecursionError';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -489,13 +496,15 @@ HintMessage.displayName = 'HintMessage';
|
||||
|
||||
export const TaskMessage: React.FC<{
|
||||
msg: MessagePayload;
|
||||
}> = memo(({ msg }) => {
|
||||
}> = observer(({ msg }) => {
|
||||
const t = useT();
|
||||
const rawTasks = msg.content;
|
||||
const tasks: Task[] = Array.isArray(rawTasks) ? rawTasks : rawTasks ? [rawTasks as Task] : [];
|
||||
const taskVariables = useChatBoxStore.use.taskVariables();
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const { triggerTask } = useChatBoxActions();
|
||||
const runtime = useChatBoxRuntime();
|
||||
const { chatBoxModel } = runtime;
|
||||
const taskVariables = chatBoxModel.taskVariables;
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const { triggerTask } = useChatBoxActions(runtime);
|
||||
const taskItems = tasks
|
||||
.map((task, index) => ({ ...task, title: task.title || `${t('Task')} ${index + 1}` }))
|
||||
.sort((a, b) => (b.title?.length ?? 0) - (a.title?.length ?? 0));
|
||||
@@ -528,11 +537,12 @@ export const TaskMessage: React.FC<{
|
||||
|
||||
TaskMessage.displayName = 'TaskMessage';
|
||||
|
||||
export const AIThinking: React.FC<{ nickname?: string }> = ({ nickname }) => {
|
||||
export const AIThinking: React.FC<{ nickname?: string }> = observer(({ nickname }) => {
|
||||
const t = useT();
|
||||
const { token } = theme.useToken();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
const runtime = useChatBoxRuntime();
|
||||
const currentConversation = runtime.chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const webSearching = chat.use.webSearching();
|
||||
|
||||
return (
|
||||
@@ -556,7 +566,7 @@ export const AIThinking: React.FC<{ nickname?: string }> = ({ nickname }) => {
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
function stringifyContent(content: unknown) {
|
||||
if (typeof content === 'string') {
|
||||
|
||||
+31
-22
@@ -12,14 +12,13 @@ import { Bubble } from '@ant-design/x';
|
||||
import { Button, Divider, Layout, Space, Spin, theme, Typography } from 'antd';
|
||||
import { DownOutlined, LoadingOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { useApp } from '@nocobase/client-v2';
|
||||
import { observer } from '@nocobase/flow-engine';
|
||||
import { useT } from '../../../locale';
|
||||
import type { Message } from '../../types';
|
||||
import { useChat } from '../hooks/useChat';
|
||||
import { useChatMessageActions } from '../hooks/useChatMessageActions';
|
||||
import { useWorkflowTasks } from '../hooks/useWorkflowTasks';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { useChatToolsStore } from '../stores/chat-tools';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
import { flattenMessages, formatConversationDuration, type RenderedItem } from '../utils';
|
||||
import { createAIEmployeeRole, defaultMessageRoles } from './MessageRenderers';
|
||||
|
||||
@@ -27,6 +26,10 @@ const { Link, Text } = Typography;
|
||||
|
||||
const STICKY_BOTTOM_THRESHOLD = 48;
|
||||
|
||||
type MessagesProps = {
|
||||
containerStyle?: React.CSSProperties;
|
||||
};
|
||||
|
||||
const isSameMessageContent = (prev: Message['content'], next: Message['content']) =>
|
||||
prev === next ||
|
||||
(prev?.messageId === next?.messageId &&
|
||||
@@ -49,14 +52,16 @@ const MemoBubble = React.memo(Bubble, (prevProps, nextProps) => {
|
||||
);
|
||||
});
|
||||
|
||||
export const Messages: React.FC = () => {
|
||||
export const Messages: React.FC<MessagesProps> = observer(({ containerStyle }) => {
|
||||
const t = useT();
|
||||
const { token } = theme.useToken();
|
||||
const app = useApp();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
const roles = useChatBoxStore.use.roles();
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const runtime = useChatBoxRuntime();
|
||||
const { chatBoxModel, chatConversationModel, chatToolModel } = runtime;
|
||||
const currentConversation = chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const roles = chatBoxModel.roles;
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const messages = chat.use.messages();
|
||||
const messagesLoading = chat.use.messagesLoading();
|
||||
const renderedMessages = useMemo(() => flattenMessages(Array.isArray(messages) ? messages : []), [messages]);
|
||||
@@ -66,14 +71,13 @@ export const Messages: React.FC = () => {
|
||||
const [collapsedConversationKeys, setCollapsedConversationKeys] = useState<Record<string, boolean>>({});
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const updateTools = useChatToolsStore.use.updateTools();
|
||||
const { loadMessages, lastMessageRef } = useChatMessageActions();
|
||||
const { loadMessages, lastMessageRef } = useChatMessageActions(runtime);
|
||||
const setResponseLoading = chat.setResponseLoading;
|
||||
const { updateReadonly } = useWorkflowTasks();
|
||||
const { updateReadonly } = useWorkflowTasks(runtime);
|
||||
|
||||
useEffect(() => {
|
||||
updateTools(messages);
|
||||
}, [messages, updateTools]);
|
||||
chatToolModel.updateTools(messages);
|
||||
}, [chatToolModel, messages]);
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedConversationKeys((previous) => {
|
||||
@@ -134,10 +138,12 @@ export const Messages: React.FC = () => {
|
||||
if (currentConversation && currentConversation === sessionId && status !== 'processing') {
|
||||
loadMessages(sessionId);
|
||||
setResponseLoading(false);
|
||||
updateReadonly(sessionId).catch(console.error);
|
||||
if (runtime.mode === 'global') {
|
||||
updateReadonly(sessionId).catch(console.error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[currentConversation, loadMessages, setResponseLoading, updateReadonly],
|
||||
[currentConversation, loadMessages, runtime.mode, setResponseLoading, updateReadonly],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -257,6 +263,7 @@ export const Messages: React.FC = () => {
|
||||
margin: '16px 0',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
...containerStyle,
|
||||
}}
|
||||
>
|
||||
{messagesLoading ? (
|
||||
@@ -287,14 +294,16 @@ export const Messages: React.FC = () => {
|
||||
)}
|
||||
</Layout.Content>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const BackgroundWorkingHint: React.FC = () => {
|
||||
const BackgroundWorkingHint: React.FC = observer(() => {
|
||||
const t = useT();
|
||||
const { loadMessages, getConversationLLMActiveState } = useChatMessageActions();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const chat = useChat(currentConversation);
|
||||
const runtime = useChatBoxRuntime();
|
||||
const { loadMessages, getConversationLLMActiveState } = useChatMessageActions(runtime);
|
||||
const currentConversation = runtime.chatConversationModel.currentConversation;
|
||||
const { chatBoxModel } = runtime;
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const messages = chat.use.messages();
|
||||
const backgroundWorking = chat.use.backgroundWorking();
|
||||
const resumeStreamFailed = chat.use.resumeStreamFailed();
|
||||
@@ -371,4 +380,4 @@ const BackgroundWorkingHint: React.FC = () => {
|
||||
messageRender={() => <Content />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
+49
-14
@@ -23,24 +23,25 @@ import {
|
||||
MODEL_PREFERENCE_STORAGE_KEY,
|
||||
resolveModel,
|
||||
} from '../model';
|
||||
import { useChatBoxStore, type ModelRef } from '../stores/chat-box';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { type ModelRef } from '../stores/chat-box';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
import { AddLLMModal } from './AddLLMModal';
|
||||
|
||||
export const ModelSwitcher: React.FC<{
|
||||
disabled?: boolean;
|
||||
}> = observer(({ disabled }) => {
|
||||
allowedModelKeys?: string[];
|
||||
}> = observer(({ disabled, allowedModelKeys }) => {
|
||||
const t = useT();
|
||||
const app = useApp();
|
||||
const { message } = AntdApp.useApp();
|
||||
const { token } = theme.useToken();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const repository = useAIConfigRepository();
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const { chatBoxModel, chatConversationModel } = useChatBoxRuntime();
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const currentEmployeeUsername = currentEmployee?.username;
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const model = useChatBoxStore.use.model();
|
||||
const setModel = useChatBoxStore.use.setModel();
|
||||
const currentConversation = chatConversationModel.currentConversation;
|
||||
const model = chatBoxModel.model;
|
||||
const llmServices = repository.llmServices;
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const pluginSettingsManager = app.pluginSettingsManager as
|
||||
@@ -59,10 +60,14 @@ export const ModelSwitcher: React.FC<{
|
||||
() => getAIEmployeeModelServices(currentEmployee, llmServices),
|
||||
[currentEmployee, llmServices],
|
||||
);
|
||||
const scopedModels = useMemo(() => getAllModels(scopedServices), [scopedServices]);
|
||||
const visibleServices = useMemo(
|
||||
() => getVisibleModelServices(scopedServices, allowedModelKeys, currentConversation ? model : undefined),
|
||||
[allowedModelKeys, currentConversation, model, scopedServices],
|
||||
);
|
||||
const scopedModels = useMemo(() => getAllModels(visibleServices), [visibleServices]);
|
||||
const servicesWithModels = useMemo(
|
||||
() => scopedServices.filter((service) => Array.isArray(service.enabledModels) && service.enabledModels.length > 0),
|
||||
[scopedServices],
|
||||
() => visibleServices.filter((service) => Array.isArray(service.enabledModels) && service.enabledModels.length > 0),
|
||||
[visibleServices],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -71,9 +76,9 @@ export const ModelSwitcher: React.FC<{
|
||||
}
|
||||
const resolved = resolveModel(app.apiClient, currentEmployee, allModels, model);
|
||||
if (!isSameModel(resolved, model)) {
|
||||
setModel(resolved);
|
||||
chatBoxModel.setModel(resolved);
|
||||
}
|
||||
}, [allModels, app.apiClient, currentConversation, currentEmployee, currentEmployeeUsername, model, setModel]);
|
||||
}, [allModels, app.apiClient, chatBoxModel, currentConversation, currentEmployee, currentEmployeeUsername, model]);
|
||||
|
||||
const selectedModel = useMemo(() => {
|
||||
if (isValidModel(model, scopedModels)) {
|
||||
@@ -89,7 +94,7 @@ export const ModelSwitcher: React.FC<{
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(target: ModelRef) => {
|
||||
setModel(target);
|
||||
chatBoxModel.setModel(target);
|
||||
if (currentEmployee) {
|
||||
try {
|
||||
app.apiClient.storage?.setItem(MODEL_PREFERENCE_STORAGE_KEY + currentEmployee.username, getModelKey(target));
|
||||
@@ -98,7 +103,7 @@ export const ModelSwitcher: React.FC<{
|
||||
}
|
||||
}
|
||||
},
|
||||
[app.apiClient.storage, currentEmployee, setModel],
|
||||
[app.apiClient.storage, chatBoxModel, currentEmployee],
|
||||
);
|
||||
|
||||
const menuItems = useMemo<MenuProps['items']>(() => {
|
||||
@@ -232,6 +237,36 @@ function getModelKey(model: ModelRef) {
|
||||
return `${model.llmService}:${model.model}`;
|
||||
}
|
||||
|
||||
export function getVisibleModelServices(
|
||||
services: LLMServiceItem[],
|
||||
allowedModelKeys?: string[],
|
||||
currentConversationModel?: ModelRef | null,
|
||||
) {
|
||||
if (!allowedModelKeys?.length) {
|
||||
return services;
|
||||
}
|
||||
const allowedSet = new Set(allowedModelKeys);
|
||||
return services
|
||||
.map((service) => ({
|
||||
...service,
|
||||
enabledModels: service.enabledModels.filter((item) =>
|
||||
isAllowedOrCurrentModel(
|
||||
{ llmService: service.llmService, model: item.value },
|
||||
allowedSet,
|
||||
currentConversationModel,
|
||||
),
|
||||
),
|
||||
}))
|
||||
.filter((service) => service.enabledModels.length > 0);
|
||||
}
|
||||
|
||||
function isAllowedOrCurrentModel(model: ModelRef, allowedSet: Set<string>, currentConversationModel?: ModelRef | null) {
|
||||
if (allowedSet.has(getModelKey(model))) {
|
||||
return true;
|
||||
}
|
||||
return currentConversationModel ? isSameModel(model, currentConversationModel) : false;
|
||||
}
|
||||
|
||||
function getModelLabel(services: LLMServiceItem[], model: ModelRef | null | undefined) {
|
||||
if (!model) {
|
||||
return '';
|
||||
|
||||
+7
-8
@@ -13,15 +13,14 @@ import { GlobalOutlined } from '@ant-design/icons';
|
||||
import { observer } from '@nocobase/flow-engine';
|
||||
import { useT } from '../../../locale';
|
||||
import { useAIConfigRepository } from '../../../repositories/hooks/useAIConfigRepository';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
export const SearchSwitch: React.FC<{ disabled?: boolean }> = observer(({ disabled }) => {
|
||||
const t = useT();
|
||||
const repository = useAIConfigRepository();
|
||||
const webSearch = useChatConversationsStore.use.webSearch();
|
||||
const setWebSearch = useChatConversationsStore.use.setWebSearch();
|
||||
const model = useChatBoxStore.use.model();
|
||||
const { chatBoxModel, chatConversationModel } = useChatBoxRuntime();
|
||||
const webSearch = chatConversationModel.webSearch;
|
||||
const model = chatBoxModel.model;
|
||||
const services = repository.llmServices;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,13 +38,13 @@ export const SearchSwitch: React.FC<{ disabled?: boolean }> = observer(({ disabl
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportWebSearch && webSearch) {
|
||||
setWebSearch(false);
|
||||
chatConversationModel.setWebSearch(false);
|
||||
}
|
||||
}, [setWebSearch, supportWebSearch, webSearch]);
|
||||
}, [chatConversationModel, supportWebSearch, webSearch]);
|
||||
|
||||
const switchChecked = () => {
|
||||
if (supportWebSearch) {
|
||||
setWebSearch(!webSearch);
|
||||
chatConversationModel.setWebSearch(!webSearch);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+255
-104
@@ -9,24 +9,23 @@
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Attachments, Sender as AntSender } from '@ant-design/x';
|
||||
import { Alert, Button, Flex, Space, Spin, theme, Tooltip, type GetRef, type UploadFile } from 'antd';
|
||||
import { Alert, Button, Flex, Space, Spin, theme, Tooltip, Typography, type GetRef, type UploadFile } from 'antd';
|
||||
import { EditOutlined, InfoCircleOutlined, PaperClipOutlined } from '@ant-design/icons';
|
||||
import { css } from '@emotion/css';
|
||||
import { observer } from '@nocobase/flow-engine';
|
||||
import { useT } from '../../../locale';
|
||||
import type { Attachment } from '../../types';
|
||||
import type { AIEmployee, Attachment, ContextItem as AIContextItem, SendOptions, SkillSettings } from '../../types';
|
||||
import { useChat } from '../hooks/useChat';
|
||||
import { useChatBoxActions } from '../hooks/useChatBoxActions';
|
||||
import { useChatMessageActions } from '../hooks/useChatMessageActions';
|
||||
import { useUploadFiles } from '../hooks/useUploadFiles';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
import { AIEmployeeSwitcher } from './AIEmployeeSwitcher';
|
||||
import { AddContextButton } from '../../AddContextButton';
|
||||
import { ContextItem } from './ContextItem';
|
||||
import { FileCardList, useAttachmentFileCards } from './Attachments';
|
||||
import { ModelSwitcher } from './ModelSwitcher';
|
||||
import { SearchSwitch } from './SearchSwitch';
|
||||
import { normalizeAIFileUploadAttachment } from '../utils';
|
||||
|
||||
type SenderRef = GetRef<typeof AntSender> & {
|
||||
nativeElement?: HTMLElement;
|
||||
@@ -44,69 +43,183 @@ type UploadRequestOptions = {
|
||||
};
|
||||
|
||||
const senderClassName = css`
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
.ant-sender-content {
|
||||
padding: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ant-sender-footer {
|
||||
min-width: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Sender: React.FC = () => {
|
||||
export type SenderOptions = {
|
||||
placeholder?: string;
|
||||
showContextSelector?: boolean;
|
||||
showUpload?: boolean;
|
||||
showWebSearch?: boolean;
|
||||
showEmployeeSelect?: boolean;
|
||||
showModelSelect?: boolean;
|
||||
showDisclaimer?: boolean;
|
||||
defaultSystemMessage?: string;
|
||||
defaultUserMessage?: string;
|
||||
allowedAIEmployees?: string[];
|
||||
allowedModels?: string[];
|
||||
};
|
||||
|
||||
export type BuildSenderSendOptionsInput = {
|
||||
content: string;
|
||||
currentConversation?: string;
|
||||
currentEmployee?: AIEmployee;
|
||||
systemMessage?: string;
|
||||
attachments?: Attachment[];
|
||||
contextItems?: AIContextItem[];
|
||||
defaultSystemMessage?: string;
|
||||
defaultUserMessage?: string;
|
||||
isEditingMessage?: boolean;
|
||||
editingMessageId?: string | null;
|
||||
skillSettings?: SkillSettings | null;
|
||||
webSearch?: boolean;
|
||||
uploadEnabled?: boolean;
|
||||
webSearchEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const mergeSenderContextItems = (contextItems?: AIContextItem[]) => {
|
||||
const seen = new Set<string>();
|
||||
return [...(contextItems || [])].filter((item) => {
|
||||
const key = `${item.type}:${item.uid}`;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const buildSenderSendOptions = ({
|
||||
content,
|
||||
currentConversation,
|
||||
currentEmployee,
|
||||
systemMessage,
|
||||
attachments,
|
||||
contextItems,
|
||||
defaultSystemMessage,
|
||||
defaultUserMessage,
|
||||
isEditingMessage,
|
||||
editingMessageId,
|
||||
skillSettings,
|
||||
webSearch,
|
||||
uploadEnabled = true,
|
||||
webSearchEnabled = true,
|
||||
}: BuildSenderSendOptionsInput): SendOptions | null => {
|
||||
if (content && !content.trim()) {
|
||||
return null;
|
||||
}
|
||||
if (!currentEmployee) {
|
||||
return null;
|
||||
}
|
||||
const resolvedContent = content || defaultUserMessage || '';
|
||||
const resolvedContextItems = mergeSenderContextItems(contextItems);
|
||||
|
||||
if (!resolvedContent && !resolvedContextItems.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: currentConversation,
|
||||
aiEmployee: currentEmployee,
|
||||
systemMessage: systemMessage || defaultSystemMessage,
|
||||
messages: [
|
||||
{
|
||||
type: 'text',
|
||||
content: resolvedContent,
|
||||
},
|
||||
],
|
||||
attachments: uploadEnabled ? attachments?.filter((attachment) => attachment.status === 'done') : undefined,
|
||||
workContext: resolvedContextItems,
|
||||
editingMessageId: isEditingMessage && editingMessageId ? editingMessageId : undefined,
|
||||
skillSettings: skillSettings || undefined,
|
||||
webSearch: webSearchEnabled ? webSearch : false,
|
||||
};
|
||||
};
|
||||
|
||||
export const Sender: React.FC<SenderOptions> = observer((options) => {
|
||||
const t = useT();
|
||||
const { token } = theme.useToken();
|
||||
const senderRef = useRef<SenderRef | null>(null);
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const setShowSenderHint = useChatBoxStore.use.setShowSenderHint();
|
||||
const senderValue = useChatBoxStore.use.senderValue();
|
||||
const setSenderValue = useChatBoxStore.use.setSenderValue();
|
||||
const setSenderRef = useChatBoxStore.use.setSenderRef();
|
||||
const readonly = useChatBoxStore.use.readonly();
|
||||
const isEditingMessage = useChatBoxStore.use.isEditingMessage();
|
||||
const editingMessageId = useChatBoxStore.use.editingMessageId();
|
||||
const webSearch = useChatConversationsStore.use.webSearch();
|
||||
const chat = useChat(currentConversation);
|
||||
const runtime = useChatBoxRuntime();
|
||||
const { chatBoxModel, chatConversationModel, chatSenderModel } = runtime;
|
||||
const currentConversation = chatConversationModel.currentConversation;
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const senderValue = chatSenderModel.senderValue;
|
||||
const readonly = chatBoxModel.readonly;
|
||||
const isEditingMessage = chatSenderModel.isEditingMessage;
|
||||
const editingMessageId = chatSenderModel.editingMessageId;
|
||||
const webSearch = chatConversationModel.webSearch;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const attachments = chat.use.attachments();
|
||||
const contextItems = chat.use.contextItems();
|
||||
const systemMessage = chat.use.systemMessage();
|
||||
const responseLoading = chat.use.responseLoading();
|
||||
const skillSettings = chat.use.skillSettings();
|
||||
const uploadProps = useUploadFiles();
|
||||
const { send } = useChatBoxActions();
|
||||
const { cancelRequest, finishEditingMessage } = useChatMessageActions();
|
||||
const uploadProps = useUploadFiles(runtime);
|
||||
const { send } = useChatBoxActions(runtime);
|
||||
const { cancelRequest, finishEditingMessage } = useChatMessageActions(runtime);
|
||||
const [value, setValue] = useState(senderValue);
|
||||
const showContextSelector = options.showContextSelector !== false;
|
||||
const showUpload = options.showUpload !== false;
|
||||
const showWebSearch = options.showWebSearch !== false;
|
||||
const placeholder = options.placeholder || 'Enter your question';
|
||||
const containerMargin = runtime.mode === 'block' ? '8px 0 0' : '8px 16px';
|
||||
const disclaimerMargin = runtime.mode === 'block' ? '10px 0 0' : '10px 0';
|
||||
|
||||
useEffect(() => {
|
||||
setSenderRef(senderRef);
|
||||
chatSenderModel.setSenderRef(senderRef);
|
||||
return () => {
|
||||
setSenderRef(null);
|
||||
chatSenderModel.setSenderRef(null);
|
||||
};
|
||||
}, [setSenderRef]);
|
||||
}, [chatSenderModel]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(senderValue);
|
||||
}, [senderValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showWebSearch && webSearch) {
|
||||
chatConversationModel.setWebSearch(false);
|
||||
}
|
||||
}, [chatConversationModel, showWebSearch, webSearch]);
|
||||
|
||||
const submit = (content: string) => {
|
||||
if ((!content && !contextItems.length) || !currentEmployee || responseLoading || readonly) {
|
||||
if (responseLoading || readonly) {
|
||||
return;
|
||||
}
|
||||
setShowSenderHint(false);
|
||||
setValue('');
|
||||
setSenderValue('');
|
||||
send({
|
||||
sessionId: currentConversation,
|
||||
aiEmployee: currentEmployee,
|
||||
const sendOptions = buildSenderSendOptions({
|
||||
content,
|
||||
currentConversation,
|
||||
currentEmployee,
|
||||
systemMessage,
|
||||
messages: [
|
||||
{
|
||||
type: 'text',
|
||||
content,
|
||||
},
|
||||
],
|
||||
attachments: attachments.filter((attachment) => attachment.status === 'done'),
|
||||
workContext: contextItems,
|
||||
editingMessageId: isEditingMessage ? editingMessageId : undefined,
|
||||
attachments,
|
||||
contextItems,
|
||||
defaultSystemMessage: options.defaultSystemMessage,
|
||||
defaultUserMessage: options.defaultUserMessage,
|
||||
isEditingMessage,
|
||||
editingMessageId,
|
||||
skillSettings,
|
||||
webSearch,
|
||||
uploadEnabled: showUpload,
|
||||
webSearchEnabled: showWebSearch,
|
||||
});
|
||||
if (!sendOptions) {
|
||||
return;
|
||||
}
|
||||
chatSenderModel.setShowSenderHint(false);
|
||||
setValue('');
|
||||
chatSenderModel.setSenderValue('');
|
||||
send(sendOptions);
|
||||
|
||||
if (isEditingMessage) {
|
||||
finishEditingMessage();
|
||||
@@ -114,6 +227,9 @@ export const Sender: React.FC = () => {
|
||||
};
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent) => {
|
||||
if (!showUpload) {
|
||||
return;
|
||||
}
|
||||
const pastedFile = Array.from(event.clipboardData.items)
|
||||
.find((item) => item.kind === 'file')
|
||||
?.getAsFile();
|
||||
@@ -158,9 +274,7 @@ export const Sender: React.FC = () => {
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
const fileData = readUploadedFileData(response);
|
||||
chat.setAttachments((previous) =>
|
||||
previous.map((item) => (item.uid === uid ? normalizeAIFileUploadAttachment(fileData || item, 'done') : item)),
|
||||
);
|
||||
chat.setAttachments((previous) => previous.map((item) => (item.uid === uid ? fileData || item : item)));
|
||||
},
|
||||
onError: (error) => {
|
||||
chat.setAttachments((previous) =>
|
||||
@@ -173,7 +287,8 @@ export const Sender: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
margin: '8px 16px',
|
||||
margin: containerMargin,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<AntSender
|
||||
@@ -182,38 +297,59 @@ export const Sender: React.FC = () => {
|
||||
ref={senderRef}
|
||||
onChange={(nextValue) => {
|
||||
setValue(nextValue);
|
||||
setSenderValue(nextValue);
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
onSubmit={submit}
|
||||
onCancel={cancelRequest}
|
||||
onBlur={() => {
|
||||
setShowSenderHint(false);
|
||||
chatSenderModel.setSenderValue(value);
|
||||
chatSenderModel.setShowSenderHint(false);
|
||||
}}
|
||||
header={<SenderHeader />}
|
||||
header={<SenderHeader options={options} />}
|
||||
loading={responseLoading}
|
||||
footer={({ components }) => <SenderFooter components={components} handleSubmit={submit} />}
|
||||
footer={({ components }) => (
|
||||
<SenderFooter components={components} handleSubmit={submit} options={options} value={value} />
|
||||
)}
|
||||
disabled={!currentEmployee || readonly}
|
||||
placeholder={t('Enter your question')}
|
||||
placeholder={t(placeholder)}
|
||||
actions={false}
|
||||
autoSize={{ minRows: 2, maxRows: 8 }}
|
||||
/>
|
||||
{options.showDisclaimer !== false ? (
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
margin: disclaimerMargin,
|
||||
fontSize: token.fontSizeSM,
|
||||
color: token.colorTextTertiary,
|
||||
}}
|
||||
>
|
||||
{t('AI disclaimer')}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const SenderHeader: React.FC = () => {
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const isEditingMessage = useChatBoxStore.use.isEditingMessage();
|
||||
const isShowSenderHint = useChatBoxStore.use.isShowSenderHint();
|
||||
const readonly = useChatBoxStore.use.readonly();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
const SenderHeader: React.FC<{
|
||||
options: SenderOptions;
|
||||
}> = observer(({ options }) => {
|
||||
const { chatBoxModel, chatSenderModel } = useChatBoxRuntime();
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const isEditingMessage = chatSenderModel.isEditingMessage;
|
||||
const isShowSenderHint = chatSenderModel.isShowSenderHint;
|
||||
const readonly = chatBoxModel.readonly;
|
||||
const runtime = useChatBoxRuntime();
|
||||
const currentConversation = runtime.chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const contextItems = chat.use.contextItems();
|
||||
const attachments = chat.use.attachments();
|
||||
|
||||
const showAttachments = options.showUpload !== false;
|
||||
const hasContextItems = !!contextItems?.length;
|
||||
const hasAttachments = !!attachments?.length;
|
||||
const hasAttachments = showAttachments && !!attachments?.length;
|
||||
|
||||
if (!isShowSenderHint && !isEditingMessage && (!currentEmployee || (!hasContextItems && !hasAttachments))) {
|
||||
return null;
|
||||
@@ -231,11 +367,11 @@ const SenderHeader: React.FC = () => {
|
||||
<EditMessageHeader />
|
||||
</div>
|
||||
) : null}
|
||||
{currentEmployee ? <ContextItemsHeader /> : null}
|
||||
{currentEmployee ? <AttachmentsHeader readonly={readonly} /> : null}
|
||||
{currentEmployee && hasContextItems ? <ContextItemsHeader /> : null}
|
||||
{currentEmployee && showAttachments ? <AttachmentsHeader readonly={readonly} /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const SenderFooter: React.FC<{
|
||||
components: {
|
||||
@@ -243,22 +379,26 @@ const SenderFooter: React.FC<{
|
||||
LoadingButton: React.ComponentType<React.ComponentProps<typeof Button>>;
|
||||
};
|
||||
handleSubmit: (content: string) => void;
|
||||
}> = ({ components, handleSubmit }) => {
|
||||
options: SenderOptions;
|
||||
value: string;
|
||||
}> = observer(({ components, handleSubmit, options, value }) => {
|
||||
const { SendButton, LoadingButton } = components;
|
||||
const senderButtonRef = useRef<GetRef<typeof Button> | null>(null);
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
const readonly = useChatBoxStore.use.readonly();
|
||||
const runtime = useChatBoxRuntime();
|
||||
const { chatBoxModel, chatSenderModel } = runtime;
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const currentConversation = runtime.chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const readonly = chatBoxModel.readonly;
|
||||
const loading = chat.use.responseLoading();
|
||||
const addContextItems = chat.addContextItems;
|
||||
const removeContextItem = chat.removeContextItem;
|
||||
const senderValue = useChatBoxStore.use.senderValue();
|
||||
const contextItems = chat.use.contextItems();
|
||||
const senderRef = useChatBoxStore.use.senderRef() as React.MutableRefObject<SenderRef | null> | null;
|
||||
const senderRef = chatSenderModel.senderRef as React.MutableRefObject<SenderRef | null> | null;
|
||||
const disabled = !currentEmployee || readonly;
|
||||
const showContextSelector = options.showContextSelector !== false;
|
||||
const handleEmptySubmit = () => {
|
||||
if (!senderValue && contextItems.length) {
|
||||
if (!value && (contextItems.length || options.defaultUserMessage)) {
|
||||
handleSubmit('');
|
||||
}
|
||||
};
|
||||
@@ -272,7 +412,7 @@ const SenderFooter: React.FC<{
|
||||
nativeElement.onkeydown = (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (!senderValue && contextItems.length) {
|
||||
if (!value && contextItems.length) {
|
||||
senderButtonRef.current?.click();
|
||||
}
|
||||
}
|
||||
@@ -283,21 +423,27 @@ const SenderFooter: React.FC<{
|
||||
nativeElement.onkeydown = null;
|
||||
}
|
||||
};
|
||||
}, [contextItems, senderRef, senderValue]);
|
||||
}, [contextItems, senderRef, value]);
|
||||
|
||||
return (
|
||||
<Flex justify="space-between" align="center">
|
||||
<Flex gap="middle" align="center">
|
||||
<AddContextButton
|
||||
onAdd={addContextItems}
|
||||
onRemove={removeContextItem}
|
||||
disabled={disabled}
|
||||
ignore={(key) => key === 'flow-model.variable'}
|
||||
/>
|
||||
<UploadFiles disabled={disabled} />
|
||||
<SearchSwitch disabled={disabled} />
|
||||
<AIEmployeeSwitcher disabled={readonly} />
|
||||
<ModelSwitcher disabled={disabled} />
|
||||
<Flex justify="space-between" align="center" gap={8} style={{ minWidth: 0 }}>
|
||||
<Flex gap="middle" align="center" wrap style={{ minWidth: 0 }}>
|
||||
{showContextSelector ? (
|
||||
<AddContextButton
|
||||
onAdd={addContextItems}
|
||||
onRemove={removeContextItem}
|
||||
disabled={disabled}
|
||||
ignore={(key) => key === 'flow-model.variable'}
|
||||
/>
|
||||
) : null}
|
||||
{options.showUpload !== false ? <UploadFiles disabled={disabled} /> : null}
|
||||
{options.showWebSearch !== false ? <SearchSwitch disabled={disabled} /> : null}
|
||||
{options.showEmployeeSelect !== false ? (
|
||||
<AIEmployeeSwitcher disabled={readonly} allowedUsernames={options.allowedAIEmployees} />
|
||||
) : null}
|
||||
{options.showModelSelect !== false ? (
|
||||
<ModelSwitcher disabled={disabled} allowedModelKeys={options.allowedModels} />
|
||||
) : null}
|
||||
</Flex>
|
||||
<Flex align="center" gap="middle">
|
||||
{loading ? (
|
||||
@@ -308,14 +454,16 @@ const SenderFooter: React.FC<{
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const UploadFiles: React.FC<{ disabled?: boolean }> = ({ disabled }) => {
|
||||
const UploadFiles: React.FC<{ disabled?: boolean }> = observer(({ disabled }) => {
|
||||
const t = useT();
|
||||
const uploadProps = useUploadFiles();
|
||||
const chatBoxRef = useChatBoxStore.use.chatBoxRef();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
const runtime = useChatBoxRuntime();
|
||||
const uploadProps = useUploadFiles(runtime);
|
||||
const { chatBoxModel } = runtime;
|
||||
const chatBoxRef = chatBoxModel.chatBoxRef;
|
||||
const currentConversation = runtime.chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const attachments = chat.use.attachments();
|
||||
const items = useAttachmentFileCards(attachments);
|
||||
|
||||
@@ -349,11 +497,12 @@ const UploadFiles: React.FC<{ disabled?: boolean }> = ({ disabled }) => {
|
||||
</Tooltip>
|
||||
</Attachments>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const ContextItemsHeader: React.FC = () => {
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
const ContextItemsHeader: React.FC = observer(() => {
|
||||
const runtime = useChatBoxRuntime();
|
||||
const currentConversation = runtime.chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const contextItems = chat.use.contextItems();
|
||||
const removeContextItem = chat.removeContextItem;
|
||||
|
||||
@@ -382,11 +531,12 @@ const ContextItemsHeader: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const AttachmentsHeader: React.FC<{ readonly: boolean }> = ({ readonly }) => {
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
const AttachmentsHeader: React.FC<{ readonly: boolean }> = observer(({ readonly }) => {
|
||||
const runtime = useChatBoxRuntime();
|
||||
const currentConversation = runtime.chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const attachments = chat.use.attachments();
|
||||
|
||||
return (
|
||||
@@ -401,7 +551,7 @@ const AttachmentsHeader: React.FC<{ readonly: boolean }> = ({ readonly }) => {
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const HintMessageHeader: React.FC = () => {
|
||||
const t = useT();
|
||||
@@ -422,13 +572,14 @@ const HintMessageHeader: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const EditMessageHeader: React.FC = () => {
|
||||
const EditMessageHeader: React.FC = observer(() => {
|
||||
const t = useT();
|
||||
const { token } = theme.useToken();
|
||||
const setSenderValue = useChatBoxStore.use.setSenderValue();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
const { loadMessages, finishEditingMessage } = useChatMessageActions();
|
||||
const runtime = useChatBoxRuntime();
|
||||
const { chatBoxModel, chatSenderModel } = runtime;
|
||||
const currentConversation = runtime.chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const { loadMessages, finishEditingMessage } = useChatMessageActions(runtime);
|
||||
|
||||
return (
|
||||
<Alert
|
||||
@@ -444,13 +595,13 @@ const EditMessageHeader: React.FC = () => {
|
||||
closable
|
||||
onClose={() => {
|
||||
finishEditingMessage();
|
||||
setSenderValue('');
|
||||
chatSenderModel.setSenderValue('');
|
||||
chat.setMessages([]);
|
||||
loadMessages(currentConversation).catch(console.error);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
function readUploadedFileData(response: unknown): Attachment | undefined {
|
||||
if (!response || typeof response !== 'object' || !('data' in response)) {
|
||||
|
||||
+15
-12
@@ -30,8 +30,7 @@ import { useAIConfigRepository } from '../../../repositories/hooks/useAIConfigRe
|
||||
import type { ToolCall } from '../../types';
|
||||
import { useChat } from '../hooks/useChat';
|
||||
import { useToolCallActions } from '../hooks/useToolCallActions';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
import { isCurrentLiveMessage } from '../utils';
|
||||
|
||||
type ToolCardProps = {
|
||||
@@ -53,10 +52,11 @@ ${keyframes`
|
||||
|
||||
export const ToolCard: React.FC<ToolCardProps> = observer(({ toolCalls, messageId = '', inlineActions }) => {
|
||||
const repository = useAIConfigRepository();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const runtime = useChatBoxRuntime();
|
||||
const currentConversation = runtime.chatConversationModel.currentConversation;
|
||||
const toolItems = useMemo(() => (Array.isArray(toolCalls) ? toolCalls : []), [toolCalls]);
|
||||
const toolsMap = useMemo(() => toToolsMap(repository.aiTools), [repository.aiTools]);
|
||||
const { getDecisionActions } = useToolCallActions({ messageId });
|
||||
const { getDecisionActions } = useToolCallActions({ messageId, runtime });
|
||||
|
||||
useEffect(() => {
|
||||
repository.getAITools(currentConversation).catch(console.error);
|
||||
@@ -123,12 +123,14 @@ ToolCard.displayName = 'ToolCard';
|
||||
const CallButton: React.FC<{
|
||||
messageId: string;
|
||||
toolCalls: ToolCall[];
|
||||
}> = ({ messageId, toolCalls }) => {
|
||||
}> = observer(({ messageId, toolCalls }) => {
|
||||
const t = useT();
|
||||
const { token } = theme.useToken();
|
||||
const { getDecisionActions } = useToolCallActions({ messageId });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const readonly = useChatBoxStore.use.readonly();
|
||||
const runtime = useChatBoxRuntime();
|
||||
const { chatBoxModel } = runtime;
|
||||
const { getDecisionActions } = useToolCallActions({ messageId, runtime });
|
||||
const readonly = chatBoxModel.readonly;
|
||||
|
||||
return (
|
||||
<Flex align="center" gap={token.marginXS}>
|
||||
@@ -161,7 +163,7 @@ const CallButton: React.FC<{
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const InvokeStatus: React.FC<{ toolCall: ToolCall }> = ({ toolCall }) => {
|
||||
const t = useT();
|
||||
@@ -302,10 +304,11 @@ const DefaultToolCard: React.FC<{
|
||||
tools: ToolsEntry[];
|
||||
toolCalls: ToolCall[];
|
||||
inlineActions?: React.ReactNode;
|
||||
}> = ({ messageId, tools, toolCalls, inlineActions }) => {
|
||||
}> = observer(({ messageId, tools, toolCalls, inlineActions }) => {
|
||||
const toolsMap = useMemo(() => toToolsMap(tools), [tools]);
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
const runtime = useChatBoxRuntime();
|
||||
const currentConversation = runtime.chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, runtime);
|
||||
const messages = chat.use.messages();
|
||||
const responseLoading = chat.use.responseLoading();
|
||||
const latestMessageId = messages[messages.length - 1]?.content?.messageId;
|
||||
@@ -342,7 +345,7 @@ const DefaultToolCard: React.FC<{
|
||||
{showCallButton ? <CallButton messageId={messageId} toolCalls={toolCalls} /> : null}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const CodeHighlight: React.FC<{ language: string; value: string }> = ({ language, value }) => {
|
||||
const { isDarkTheme } = useGlobalTheme();
|
||||
|
||||
+21
-24
@@ -13,11 +13,9 @@ import { observer } from '@nocobase/flow-engine';
|
||||
import { toToolsMap, ToolsUIProperties, useApp } from '@nocobase/client-v2';
|
||||
import { useT } from '../../../locale';
|
||||
import { useAIConfigRepository } from '../../../repositories/hooks/useAIConfigRepository';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { useChatToolsStore } from '../stores/chat-tools';
|
||||
import { useChatMessageActions } from '../hooks/useChatMessageActions';
|
||||
import { useToolCallActions } from '../hooks/useToolCallActions';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
const useDefaultOnOk = (_decisions: ToolsUIProperties['decisions'], _adjustArgs: Record<string, unknown>) => {
|
||||
return {
|
||||
@@ -33,23 +31,22 @@ export const ToolModal: React.FC = observer(() => {
|
||||
const aiConfigRepository = useAIConfigRepository();
|
||||
const tools = aiConfigRepository.aiTools;
|
||||
const toolsMap = toToolsMap(tools);
|
||||
const runtime = useChatBoxRuntime();
|
||||
const { chatBoxModel, chatConversationModel, chatToolModel } = runtime;
|
||||
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const currentConversation = chatConversationModel.currentConversation;
|
||||
useEffect(() => {
|
||||
aiConfigRepository.getAITools(currentConversation).catch(console.error);
|
||||
}, [aiConfigRepository, currentConversation]);
|
||||
|
||||
const open = useChatToolsStore.use.openToolModal();
|
||||
const setOpen = useChatToolsStore.use.setOpenToolModal();
|
||||
const activeTool = useChatToolsStore.use.activeTool();
|
||||
const setActiveTool = useChatToolsStore.use.setActiveTool();
|
||||
const activeMessageId = useChatToolsStore.use.activeMessageId();
|
||||
const setActiveMessageId = useChatToolsStore.use.setActiveMessageId();
|
||||
const toolsByMessageId = useChatToolsStore.use.toolsByMessageId();
|
||||
const toolsByName = useChatToolsStore.use.toolsByName();
|
||||
const readonly = useChatBoxStore.use.readonly();
|
||||
const open = chatToolModel.openToolModal;
|
||||
const activeTool = chatToolModel.activeTool;
|
||||
const activeMessageId = chatToolModel.activeMessageId;
|
||||
const toolsByMessageId = chatToolModel.toolsByMessageId;
|
||||
const toolsByName = chatToolModel.toolsByName;
|
||||
const readonly = chatBoxModel.readonly;
|
||||
|
||||
const { updateToolArgs } = useChatMessageActions();
|
||||
const { updateToolArgs } = useChatMessageActions(runtime);
|
||||
|
||||
const resolvedActiveTool =
|
||||
(activeMessageId && activeTool?.id ? toolsByMessageId[activeMessageId]?.[activeTool.id] : null) || activeTool;
|
||||
@@ -61,8 +58,8 @@ export const ToolModal: React.FC = observer(() => {
|
||||
const FooterComponent = modal?.footer;
|
||||
const modalProps = modal?.props;
|
||||
|
||||
const adjustArgs = useChatToolsStore.use.adjustArgs();
|
||||
const { getDecisionActions } = useToolCallActions({ messageId: activeMessageId });
|
||||
const adjustArgs = chatToolModel.adjustArgs;
|
||||
const { getDecisionActions } = useToolCallActions({ messageId: activeMessageId, runtime });
|
||||
const decisions = getDecisionActions(resolvedActiveTool);
|
||||
const { onOk } = useOnOk(decisions, adjustArgs);
|
||||
|
||||
@@ -74,9 +71,9 @@ export const ToolModal: React.FC = observer(() => {
|
||||
}));
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setOpen(false);
|
||||
setActiveTool(null);
|
||||
}, [setActiveTool, setOpen]);
|
||||
chatToolModel.setOpenToolModal(false);
|
||||
chatToolModel.setActiveTool(null);
|
||||
}, [chatToolModel]);
|
||||
|
||||
const saveToolArgs = useCallback(
|
||||
async (args: unknown) => {
|
||||
@@ -92,12 +89,12 @@ export const ToolModal: React.FC = observer(() => {
|
||||
},
|
||||
});
|
||||
message.success(t('Saved successfully'));
|
||||
setActiveTool({
|
||||
chatToolModel.setActiveTool({
|
||||
...resolvedActiveTool,
|
||||
args,
|
||||
});
|
||||
},
|
||||
[activeMessageId, currentConversation, message, resolvedActiveTool, setActiveTool, t, updateToolArgs],
|
||||
[activeMessageId, chatToolModel, currentConversation, message, resolvedActiveTool, t, updateToolArgs],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -114,8 +111,8 @@ export const ToolModal: React.FC = observer(() => {
|
||||
}}
|
||||
onChange={(value) => {
|
||||
const toolCall = toolCalls.find((tool) => tool.id === value);
|
||||
setActiveMessageId(toolCall?.messageId || '');
|
||||
setActiveTool(toolCall);
|
||||
chatToolModel.setActiveMessageId(toolCall?.messageId || '');
|
||||
chatToolModel.setActiveTool(toolCall);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
@@ -128,7 +125,7 @@ export const ToolModal: React.FC = observer(() => {
|
||||
okText={modal?.okText ? t(modal.okText) : t('Submit')}
|
||||
onOk={async () => {
|
||||
await onOk?.();
|
||||
setOpen(false);
|
||||
chatToolModel.setOpenToolModal(false);
|
||||
}}
|
||||
okButtonProps={{
|
||||
disabled: !['init', 'interrupted', 'pending'].includes(resolvedActiveTool?.invokeStatus) || readonly,
|
||||
|
||||
+9
-8
@@ -11,23 +11,24 @@ import React, { useEffect, useState } from 'react';
|
||||
import { App as AntdApp, Button, Card, Grid, Input, Modal, Popover, Space, Tooltip, Typography, theme } from 'antd';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { useApp } from '@nocobase/client-v2';
|
||||
import { observer } from '@nocobase/flow-engine';
|
||||
import { useT } from '../../../locale';
|
||||
import { useAIConfigRepository } from '../../../repositories/hooks/useAIConfigRepository';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
type APIError = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export const UserPrompt: React.FC = () => {
|
||||
export const UserPrompt: React.FC = observer(() => {
|
||||
const t = useT();
|
||||
const app = useApp();
|
||||
const { message } = AntdApp.useApp();
|
||||
const { token } = theme.useToken();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobileLayout = !screens.md;
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const setCurrentEmployee = useChatBoxStore.use.setCurrentEmployee();
|
||||
const { chatBoxModel } = useChatBoxRuntime();
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const aiConfigRepository = useAIConfigRepository();
|
||||
const [desktopOpen, setDesktopOpen] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
@@ -62,10 +63,10 @@ export const UserPrompt: React.FC = () => {
|
||||
},
|
||||
});
|
||||
await aiConfigRepository.refreshAIEmployees();
|
||||
setCurrentEmployee((previous) => ({
|
||||
...previous,
|
||||
chatBoxModel.setCurrentEmployee((previous) => ({
|
||||
...(previous ?? currentEmployee),
|
||||
userConfig: {
|
||||
...previous.userConfig,
|
||||
...(previous ?? currentEmployee).userConfig,
|
||||
prompt,
|
||||
},
|
||||
}));
|
||||
@@ -172,7 +173,7 @@ export const UserPrompt: React.FC = () => {
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
function readErrorMessage(error: unknown) {
|
||||
return error && typeof error === 'object' && 'message' in error ? (error as APIError).message : undefined;
|
||||
|
||||
+72
-1
@@ -11,7 +11,9 @@ import React from 'react';
|
||||
import { cleanup, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AI_EMPLOYEE_TRIGGER_TASK_EVENT } from '../../../../manager/ai-manager';
|
||||
import type { RunJSAIEmployeeTriggerTaskOptions } from '../../utils/normalizeTriggerTaskOptions';
|
||||
import { clearMountedChatBoxes, registerMountedChatBox } from '../../stores/mounted-chat-boxes';
|
||||
import { getGlobalChatBoxRuntime } from '../../stores/runtime';
|
||||
import type { RunJSAIEmployeeTriggerTaskOptions } from '../../utils';
|
||||
import { ChatBoxLayout } from '../ChatBoxLayout';
|
||||
|
||||
const runtime = vi.hoisted(() => {
|
||||
@@ -125,6 +127,7 @@ describe('ChatBoxLayout AI employee task bridge', () => {
|
||||
runtime.getAIEmployees.mockClear();
|
||||
runtime.app.pm.get.mockClear();
|
||||
runtime.app.apiClient.resource.mockClear();
|
||||
clearMountedChatBoxes();
|
||||
});
|
||||
|
||||
it('calls hook triggerTask after receiving an AI employee task event', async () => {
|
||||
@@ -150,6 +153,74 @@ describe('ChatBoxLayout AI employee task bridge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('routes event tasks to the mounted AI chat box selected by chatBoxUid', async () => {
|
||||
const targetTriggerTask = vi.fn().mockResolvedValue(undefined);
|
||||
registerMountedChatBox({
|
||||
uid: 'chat-box-1',
|
||||
runtime: getGlobalChatBoxRuntime(),
|
||||
triggerTask: targetTriggerTask,
|
||||
clear: vi.fn(),
|
||||
syncContextItems: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ChatBoxLayout />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(runtime.onChatBoxMounted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const options: RunJSAIEmployeeTriggerTaskOptions = {
|
||||
aiEmployee: 'nathan',
|
||||
tasks: [{ title: 'Write in block' }],
|
||||
chatBoxUid: 'chat-box-1',
|
||||
open: true,
|
||||
};
|
||||
runtime.eventBus.dispatchEvent(new CustomEvent(AI_EMPLOYEE_TRIGGER_TASK_EVENT, { detail: options }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(targetTriggerTask).toHaveBeenCalledWith({
|
||||
aiEmployee: { username: 'nathan', nickname: 'Nathan' },
|
||||
tasks: [{ title: 'Write in block' }],
|
||||
chatBoxUid: 'chat-box-1',
|
||||
open: true,
|
||||
});
|
||||
});
|
||||
expect(runtime.triggerTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not route ctx.ai event tasks by tasks[].chatBoxUid', async () => {
|
||||
const targetTriggerTask = vi.fn().mockResolvedValue(undefined);
|
||||
registerMountedChatBox({
|
||||
uid: 'chat-box-1',
|
||||
runtime: getGlobalChatBoxRuntime(),
|
||||
triggerTask: targetTriggerTask,
|
||||
clear: vi.fn(),
|
||||
syncContextItems: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ChatBoxLayout />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(runtime.onChatBoxMounted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const options: RunJSAIEmployeeTriggerTaskOptions = {
|
||||
aiEmployee: 'nathan',
|
||||
tasks: [{ title: 'Write globally', chatBoxUid: 'chat-box-1' }],
|
||||
open: true,
|
||||
};
|
||||
runtime.eventBus.dispatchEvent(new CustomEvent(AI_EMPLOYEE_TRIGGER_TASK_EVENT, { detail: options }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(runtime.triggerTask).toHaveBeenCalledWith({
|
||||
aiEmployee: { username: 'nathan', nickname: 'Nathan' },
|
||||
tasks: [{ title: 'Write globally', chatBoxUid: 'chat-box-1' }],
|
||||
open: true,
|
||||
});
|
||||
});
|
||||
expect(targetTriggerTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the ChatBox unmounted before removing the bridge listener', async () => {
|
||||
const { unmount } = render(<ChatBoxLayout />);
|
||||
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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 from 'react';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Conversation } from '../../../types';
|
||||
import { ChatBoxRuntimeProvider, createChatBoxRuntime } from '../../stores/runtime';
|
||||
import { ChatBoxUnreadBadge } from '../ChatBoxUnreadBadge';
|
||||
|
||||
const conversation = (sessionId: string, read: boolean): Conversation =>
|
||||
({
|
||||
sessionId,
|
||||
read,
|
||||
}) as Conversation;
|
||||
|
||||
describe('ChatBoxUnreadBadge', () => {
|
||||
it('uses loaded scoped conversations in block mode', () => {
|
||||
const runtime = createChatBoxRuntime({ mode: 'block' });
|
||||
runtime.chatConversationModel.setConversations([conversation('session-a', true)]);
|
||||
runtime.chatConversationModel.setUnreadCount(3);
|
||||
|
||||
const { container, rerender } = render(
|
||||
<ChatBoxRuntimeProvider runtime={runtime}>
|
||||
<ChatBoxUnreadBadge>
|
||||
<button type="button">Conversations</button>
|
||||
</ChatBoxUnreadBadge>
|
||||
</ChatBoxRuntimeProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.ant-badge-dot')).toBeFalsy();
|
||||
|
||||
act(() => {
|
||||
runtime.chatConversationModel.setConversations([
|
||||
conversation('session-a', true),
|
||||
conversation('session-b', false),
|
||||
]);
|
||||
});
|
||||
rerender(
|
||||
<ChatBoxRuntimeProvider runtime={runtime}>
|
||||
<ChatBoxUnreadBadge>
|
||||
<button type="button">Conversations</button>
|
||||
</ChatBoxUnreadBadge>
|
||||
</ChatBoxRuntimeProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.ant-badge-dot')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses runtime unread counts in global mode', () => {
|
||||
const runtime = createChatBoxRuntime({ mode: 'global' });
|
||||
runtime.chatConversationModel.setConversations([conversation('session-a', true)]);
|
||||
runtime.chatConversationModel.setUnreadCount(0);
|
||||
runtime.workflowTaskModel.setUnreadCount(1);
|
||||
|
||||
const { container } = render(
|
||||
<ChatBoxRuntimeProvider runtime={runtime}>
|
||||
<ChatBoxUnreadBadge>
|
||||
<button type="button">Conversations</button>
|
||||
</ChatBoxUnreadBadge>
|
||||
</ChatBoxRuntimeProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.ant-badge-dot')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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 from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatBoxRuntimeProvider, createChatBoxRuntime, type ChatBoxRuntime } from '../../stores/runtime';
|
||||
import { Messages } from '../Messages';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listeners: new Map<string, EventListener>(),
|
||||
loadMessages: vi.fn(),
|
||||
setResponseLoading: vi.fn(),
|
||||
updateReadonly: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@nocobase/client-v2', () => ({
|
||||
useApp: () => ({
|
||||
eventBus: {
|
||||
addEventListener: (eventName: string, listener: EventListener) => {
|
||||
mocks.listeners.set(eventName, listener);
|
||||
},
|
||||
removeEventListener: (eventName: string) => {
|
||||
mocks.listeners.delete(eventName);
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../locale', () => ({
|
||||
useT: () => (text: string) => text,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useChat', () => ({
|
||||
useChat: () => ({
|
||||
use: {
|
||||
messages: () => [],
|
||||
messagesLoading: () => false,
|
||||
},
|
||||
setResponseLoading: mocks.setResponseLoading,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useChatMessageActions', () => ({
|
||||
useChatMessageActions: () => ({
|
||||
loadMessages: mocks.loadMessages,
|
||||
lastMessageRef: { current: null },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useWorkflowTasks', () => ({
|
||||
useWorkflowTasks: () => ({
|
||||
updateReadonly: mocks.updateReadonly,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderMessages = (runtime: ChatBoxRuntime) => {
|
||||
runtime.chatConversationModel.setCurrentConversation('session-a');
|
||||
return render(
|
||||
<ChatBoxRuntimeProvider runtime={runtime}>
|
||||
<Messages />
|
||||
</ChatBoxRuntimeProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
const dispatchTaskStatusUpdate = () => {
|
||||
const listener = mocks.listeners.get('ws:message:ai-employee-tasks:status');
|
||||
listener?.(
|
||||
new CustomEvent('ws:message:ai-employee-tasks:status', {
|
||||
detail: {
|
||||
sessionId: 'session-a',
|
||||
status: 'approved',
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe('Messages runtime mode', () => {
|
||||
afterEach(() => {
|
||||
mocks.listeners.clear();
|
||||
mocks.loadMessages.mockReset();
|
||||
mocks.setResponseLoading.mockReset();
|
||||
mocks.updateReadonly.mockReset();
|
||||
});
|
||||
|
||||
it('refreshes workflow task readonly state in global mode', async () => {
|
||||
mocks.updateReadonly.mockResolvedValue(undefined);
|
||||
renderMessages(createChatBoxRuntime({ mode: 'global' }));
|
||||
|
||||
dispatchTaskStatusUpdate();
|
||||
|
||||
expect(mocks.loadMessages).toHaveBeenCalledWith('session-a');
|
||||
expect(mocks.setResponseLoading).toHaveBeenCalledWith(false);
|
||||
await waitFor(() => expect(mocks.updateReadonly).toHaveBeenCalledWith('session-a'));
|
||||
});
|
||||
|
||||
it('does not refresh workflow task readonly state in block mode', () => {
|
||||
renderMessages(createChatBoxRuntime({ mode: 'block' }));
|
||||
|
||||
dispatchTaskStatusUpdate();
|
||||
|
||||
expect(mocks.loadMessages).toHaveBeenCalledWith('session-a');
|
||||
expect(mocks.setResponseLoading).toHaveBeenCalledWith(false);
|
||||
expect(mocks.updateReadonly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders as the native scrollable message area', () => {
|
||||
const { container } = renderMessages(createChatBoxRuntime({ mode: 'block' }));
|
||||
const content = container.querySelector('.ant-layout-content') as HTMLElement | null;
|
||||
|
||||
expect(content?.style.overflow).toBe('auto');
|
||||
});
|
||||
});
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import type { LLMServiceItem } from '../../../../repositories/AIConfigRepository';
|
||||
import { getVisibleModelServices } from '../ModelSwitcher';
|
||||
|
||||
const services: LLMServiceItem[] = [
|
||||
{
|
||||
llmService: 'openai',
|
||||
llmServiceTitle: 'OpenAI',
|
||||
enabledModels: [
|
||||
{ label: 'GPT 5', value: 'gpt-5' },
|
||||
{ label: 'GPT 4.1', value: 'gpt-4.1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
llmService: 'qwen',
|
||||
llmServiceTitle: 'Qwen',
|
||||
enabledModels: [{ label: 'Qwen Max', value: 'qwen-max' }],
|
||||
},
|
||||
];
|
||||
|
||||
describe('ModelSwitcher helpers', () => {
|
||||
it('filters allowed models for draft conversations', () => {
|
||||
expect(getVisibleModelServices(services, ['openai:gpt-5'])).toEqual([
|
||||
{
|
||||
llmService: 'openai',
|
||||
llmServiceTitle: 'OpenAI',
|
||||
enabledModels: [{ label: 'GPT 5', value: 'gpt-5' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the current conversation model visible while preserving restrictions', () => {
|
||||
expect(getVisibleModelServices(services, ['openai:gpt-5'], { llmService: 'qwen', model: 'qwen-max' })).toEqual([
|
||||
{
|
||||
llmService: 'openai',
|
||||
llmServiceTitle: 'OpenAI',
|
||||
enabledModels: [{ label: 'GPT 5', value: 'gpt-5' }],
|
||||
},
|
||||
{
|
||||
llmService: 'qwen',
|
||||
llmServiceTitle: 'Qwen',
|
||||
enabledModels: [{ label: 'Qwen Max', value: 'qwen-max' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import type { AIEmployee, ContextItem } from '../../../types';
|
||||
import { buildSenderSendOptions, mergeSenderContextItems } from '../Sender';
|
||||
|
||||
const employee: AIEmployee = {
|
||||
username: 'sales',
|
||||
nickname: 'Sales assistant',
|
||||
};
|
||||
|
||||
const context = (uid: string, title?: string): ContextItem => ({
|
||||
type: 'flow-model',
|
||||
uid,
|
||||
title,
|
||||
});
|
||||
|
||||
describe('Sender options helpers', () => {
|
||||
it('deduplicates context items in send order', () => {
|
||||
expect(
|
||||
mergeSenderContextItems([context('manual-1'), context('shared', 'First shared'), context('shared', 'Ignored')]),
|
||||
).toEqual([context('manual-1'), context('shared', 'First shared')]);
|
||||
});
|
||||
|
||||
it('builds direct send payload from AI chat box defaults', () => {
|
||||
expect(
|
||||
buildSenderSendOptions({
|
||||
content: '',
|
||||
currentConversation: undefined,
|
||||
currentEmployee: employee,
|
||||
systemMessage: '',
|
||||
attachments: [{ filename: 'draft.txt', status: 'done' }],
|
||||
contextItems: [context('manual-1')],
|
||||
defaultSystemMessage: 'Use sales tone',
|
||||
defaultUserMessage: 'Summarize this block',
|
||||
skillSettings: null,
|
||||
webSearch: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
sessionId: undefined,
|
||||
aiEmployee: employee,
|
||||
systemMessage: 'Use sales tone',
|
||||
messages: [{ type: 'text', content: 'Summarize this block' }],
|
||||
attachments: [{ filename: 'draft.txt', status: 'done' }],
|
||||
workContext: [context('manual-1')],
|
||||
webSearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('honors disabled upload and web search options without dropping work context', () => {
|
||||
expect(
|
||||
buildSenderSendOptions({
|
||||
content: 'Hello',
|
||||
currentEmployee: employee,
|
||||
attachments: [{ filename: 'draft.txt', status: 'done' }],
|
||||
contextItems: [context('manual-1')],
|
||||
webSearch: true,
|
||||
uploadEnabled: false,
|
||||
webSearchEnabled: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
messages: [{ type: 'text', content: 'Hello' }],
|
||||
attachments: undefined,
|
||||
workContext: [context('manual-1')],
|
||||
webSearch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not build a payload for whitespace-only content', () => {
|
||||
expect(
|
||||
buildSenderSendOptions({
|
||||
content: ' \n\t',
|
||||
currentEmployee: employee,
|
||||
defaultUserMessage: 'Hello',
|
||||
contextItems: [context('manual-1')],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not build payload without an employee or sendable content', () => {
|
||||
expect(
|
||||
buildSenderSendOptions({
|
||||
content: '',
|
||||
currentEmployee: undefined,
|
||||
defaultUserMessage: 'Hello',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildSenderSendOptions({
|
||||
content: '',
|
||||
currentEmployee: employee,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 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 from 'react';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Sender } from '../Sender';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const runtime = {
|
||||
mode: 'block',
|
||||
chatBoxModel: {
|
||||
currentEmployee: {
|
||||
username: 'sales',
|
||||
nickname: 'Sales',
|
||||
},
|
||||
readonly: false,
|
||||
},
|
||||
chatConversationModel: {
|
||||
currentConversation: undefined as string | undefined,
|
||||
webSearch: false,
|
||||
setWebSearch: vi.fn(),
|
||||
},
|
||||
chatSenderModel: {
|
||||
senderValue: 'prefill',
|
||||
isEditingMessage: false,
|
||||
editingMessageId: null as string | null,
|
||||
senderRef: null,
|
||||
setSenderValue: vi.fn((value: string) => {
|
||||
runtime.chatSenderModel.senderValue = value;
|
||||
}),
|
||||
setSenderRef: vi.fn((ref) => {
|
||||
runtime.chatSenderModel.senderRef = ref;
|
||||
}),
|
||||
setShowSenderHint: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
runtime,
|
||||
send: vi.fn(),
|
||||
cancelRequest: vi.fn(),
|
||||
finishEditingMessage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@ant-design/x', async () => {
|
||||
const React = await import('react');
|
||||
const MockButton = React.forwardRef<HTMLButtonElement, React.ComponentProps<'button'>>((props, ref) => (
|
||||
<button ref={ref} type="button" {...props} />
|
||||
));
|
||||
MockButton.displayName = 'MockButton';
|
||||
|
||||
return {
|
||||
Attachments: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
Sender: React.forwardRef<
|
||||
{ nativeElement?: HTMLTextAreaElement },
|
||||
{
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
onSubmit?: (value: string) => void;
|
||||
footer?: (options: {
|
||||
components: { SendButton: typeof MockButton; LoadingButton: typeof MockButton };
|
||||
}) => React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
}
|
||||
>(({ value = '', onChange, onBlur, onSubmit, footer, header }, ref) => {
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
nativeElement: textareaRef.current ?? undefined,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<textarea
|
||||
data-testid="sender-input"
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onBlur={onBlur}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
/>
|
||||
<button data-testid="sender-submit" type="button" onClick={() => onSubmit?.(value)}>
|
||||
submit
|
||||
</button>
|
||||
{footer?.({ components: { SendButton: MockButton, LoadingButton: MockButton } })}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../../locale', () => ({
|
||||
useT: () => (text: string) => text,
|
||||
}));
|
||||
|
||||
vi.mock('../AIEmployeeSwitcher', () => ({
|
||||
AIEmployeeSwitcher: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../ModelSwitcher', () => ({
|
||||
ModelSwitcher: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../SearchSwitch', () => ({
|
||||
SearchSwitch: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../../AddContextButton', () => ({
|
||||
AddContextButton: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../Attachments', () => ({
|
||||
FileCardList: () => null,
|
||||
useAttachmentFileCards: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useChat', () => ({
|
||||
useChat: () => ({
|
||||
use: {
|
||||
attachments: () => [],
|
||||
contextItems: () => [],
|
||||
systemMessage: () => '',
|
||||
responseLoading: () => false,
|
||||
skillSettings: () => null,
|
||||
},
|
||||
setAttachments: vi.fn(),
|
||||
addContextItems: vi.fn(),
|
||||
removeContextItem: vi.fn(),
|
||||
removeAttachment: vi.fn(),
|
||||
setMessages: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useChatBoxActions', () => ({
|
||||
useChatBoxActions: () => ({
|
||||
send: mocks.send,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useChatMessageActions', () => ({
|
||||
useChatMessageActions: () => ({
|
||||
cancelRequest: mocks.cancelRequest,
|
||||
finishEditingMessage: mocks.finishEditingMessage,
|
||||
loadMessages: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useUploadFiles', () => ({
|
||||
useUploadFiles: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/runtime', () => ({
|
||||
useChatBoxRuntime: () => mocks.runtime,
|
||||
}));
|
||||
|
||||
describe('Sender input state', () => {
|
||||
beforeEach(() => {
|
||||
mocks.runtime.chatSenderModel.senderValue = 'prefill';
|
||||
mocks.runtime.chatSenderModel.setSenderValue.mockClear();
|
||||
mocks.runtime.chatSenderModel.setSenderRef.mockClear();
|
||||
mocks.runtime.chatSenderModel.setShowSenderHint.mockClear();
|
||||
mocks.send.mockClear();
|
||||
});
|
||||
|
||||
it('keeps typing local until blur', () => {
|
||||
const { getByTestId } = render(
|
||||
<Sender
|
||||
showContextSelector={false}
|
||||
showUpload={false}
|
||||
showWebSearch={false}
|
||||
showEmployeeSelect={false}
|
||||
showModelSelect={false}
|
||||
/>,
|
||||
);
|
||||
const input = getByTestId('sender-input') as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: 'typed locally' } });
|
||||
|
||||
expect(input.value).toBe('typed locally');
|
||||
expect(mocks.runtime.chatSenderModel.setSenderValue).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(mocks.runtime.chatSenderModel.setSenderValue).toHaveBeenCalledWith('typed locally');
|
||||
});
|
||||
|
||||
it('submits local input and clears the external sender value', () => {
|
||||
const { getByTestId } = render(
|
||||
<Sender
|
||||
showContextSelector={false}
|
||||
showUpload={false}
|
||||
showWebSearch={false}
|
||||
showEmployeeSelect={false}
|
||||
showModelSelect={false}
|
||||
/>,
|
||||
);
|
||||
const input = getByTestId('sender-input') as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: 'send this' } });
|
||||
fireEvent.click(getByTestId('sender-submit'));
|
||||
|
||||
expect(mocks.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: [{ type: 'text', content: 'send this' }],
|
||||
}),
|
||||
);
|
||||
expect(mocks.runtime.chatSenderModel.setSenderValue).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('does not submit whitespace-only input', () => {
|
||||
const { getByTestId } = render(
|
||||
<Sender
|
||||
showContextSelector={false}
|
||||
showUpload={false}
|
||||
showWebSearch={false}
|
||||
showEmployeeSelect={false}
|
||||
showModelSelect={false}
|
||||
/>,
|
||||
);
|
||||
const input = getByTestId('sender-input') as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: ' \n\t' } });
|
||||
fireEvent.click(getByTestId('sender-submit'));
|
||||
|
||||
expect(mocks.send).not.toHaveBeenCalled();
|
||||
expect(mocks.runtime.chatSenderModel.setSenderValue).not.toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
+1
@@ -10,5 +10,6 @@
|
||||
export { ChatBox } from './ChatBox';
|
||||
export { ChatBoxLayout } from './ChatBoxLayout';
|
||||
export { ChatButton } from './ChatButton';
|
||||
export { ChatBoxUnreadBadge } from './ChatBoxUnreadBadge';
|
||||
export { AIEmployeeSwitcher } from './AIEmployeeSwitcher';
|
||||
export { ModelSwitcher } from './ModelSwitcher';
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 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 { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createChatBoxRuntime } from '../../stores/runtime';
|
||||
import { useChatConversationActions } from '../useChatConversationActions';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
unreadCounts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@nocobase/client-v2', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@nocobase/client-v2')>()),
|
||||
useApp: () => ({
|
||||
apiClient: {
|
||||
resource: (name: string) => {
|
||||
if (name === 'aiConversations') {
|
||||
return {
|
||||
list: mocks.list,
|
||||
unreadCounts: mocks.unreadCounts,
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected resource: ${name}`);
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useChatConversationActions', () => {
|
||||
beforeEach(() => {
|
||||
mocks.list.mockReset();
|
||||
mocks.unreadCounts.mockReset();
|
||||
mocks.list.mockResolvedValue({
|
||||
data: {
|
||||
data: [],
|
||||
meta: {
|
||||
count: 0,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
totalPage: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves block runtime scope before sending the list filter', async () => {
|
||||
const getScope = vi.fn().mockResolvedValue('shared-sales');
|
||||
const runtime = createChatBoxRuntime({ mode: 'block', getScope });
|
||||
const { result } = renderHook(() => useChatConversationActions(runtime));
|
||||
|
||||
act(() => {
|
||||
result.current.runSearch('Q3');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mocks.list).toHaveBeenCalled());
|
||||
expect(getScope).toHaveBeenCalledWith({ operation: 'list' });
|
||||
expect(mocks.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filter: {
|
||||
title: {
|
||||
$includes: 'Q3',
|
||||
},
|
||||
scope: 'shared-sales',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits scope from the list filter when block scope resolves empty', async () => {
|
||||
const runtime = createChatBoxRuntime({ mode: 'block', getScope: vi.fn().mockResolvedValue(undefined) });
|
||||
const { result } = renderHook(() => useChatConversationActions(runtime));
|
||||
|
||||
act(() => {
|
||||
result.current.runSearch();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mocks.list).toHaveBeenCalled());
|
||||
expect(mocks.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filter: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not read getScope for global conversation lists', async () => {
|
||||
const getScope = vi.fn().mockResolvedValue('ignored-scope');
|
||||
const runtime = createChatBoxRuntime({ mode: 'global', getScope });
|
||||
const { result } = renderHook(() => useChatConversationActions(runtime));
|
||||
|
||||
act(() => {
|
||||
result.current.runSearch();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mocks.list).toHaveBeenCalled());
|
||||
expect(getScope).not.toHaveBeenCalled();
|
||||
expect(mocks.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filter: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* 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 { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AIEmployee, Attachment, ContextItem, Message } from '../../../types';
|
||||
import { createChatBoxRuntime } from '../../stores/runtime';
|
||||
import { useChatBoxActions } from '../useChatBoxActions';
|
||||
import { useChatMessageActions } from '../useChatMessageActions';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resource: vi.fn(),
|
||||
request: vi.fn(),
|
||||
refreshConversations: vi.fn(),
|
||||
getLLMServices: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@nocobase/client-v2', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@nocobase/client-v2')>()),
|
||||
useApp: () => ({
|
||||
apiClient: {
|
||||
resource: mocks.resource,
|
||||
request: mocks.request,
|
||||
},
|
||||
flowEngine: {
|
||||
getModel: vi.fn(),
|
||||
},
|
||||
pm: {
|
||||
get: vi.fn(() => ({
|
||||
aiManager: {
|
||||
getWorkContext: vi.fn(),
|
||||
},
|
||||
})),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../locale', () => ({
|
||||
useT: () => (text: string, params?: { nickname?: string }) =>
|
||||
params?.nickname ? text.replace('{{nickname}}', params.nickname) : text,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../repositories/hooks/useAIConfigRepository', () => ({
|
||||
useAIConfigRepository: () => ({
|
||||
getLLMServices: mocks.getLLMServices,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../useChatConversationActions', () => ({
|
||||
useChatConversationActions: () => ({
|
||||
refresh: mocks.refreshConversations,
|
||||
}),
|
||||
}));
|
||||
|
||||
const employee: AIEmployee = {
|
||||
username: 'sales',
|
||||
nickname: 'Sales assistant',
|
||||
greeting: 'Hello from sales',
|
||||
};
|
||||
|
||||
const textMessage = (key: string, content: string): Message => ({
|
||||
key,
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
content,
|
||||
messageId: key,
|
||||
},
|
||||
});
|
||||
|
||||
describe('chatbox action integration', () => {
|
||||
beforeEach(() => {
|
||||
mocks.resource.mockReset();
|
||||
mocks.request.mockReset();
|
||||
mocks.refreshConversations.mockReset();
|
||||
mocks.getLLMServices.mockReset();
|
||||
mocks.getLLMServices.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('starts a new conversation in the provided block runtime only', () => {
|
||||
const runtime = createChatBoxRuntime({ mode: 'block' });
|
||||
const otherRuntime = createChatBoxRuntime({ mode: 'block' });
|
||||
runtime.chatBoxModel.setCurrentEmployee(employee);
|
||||
runtime.chatConversationModel.setCurrentConversation('old-session');
|
||||
runtime.workflowTaskModel.setCurrentWorkflowTask('task-1');
|
||||
runtime.chatSenderModel.setSenderValue('draft');
|
||||
runtime.chatMessageModel.setSessionAttachments(undefined, [{ filename: 'draft.txt' }]);
|
||||
otherRuntime.chatConversationModel.setCurrentConversation('other-session');
|
||||
otherRuntime.chatSenderModel.setSenderValue('other draft');
|
||||
|
||||
const { result } = renderHook(() => useChatBoxActions(runtime));
|
||||
|
||||
act(() => {
|
||||
result.current.startNewConversation();
|
||||
});
|
||||
|
||||
expect(runtime.chatConversationModel.currentConversation).toBeUndefined();
|
||||
expect(runtime.workflowTaskModel.currentWorkflowTask).toBeUndefined();
|
||||
expect(runtime.chatSenderModel.senderValue).toBe('');
|
||||
expect(runtime.chatMessageModel.getSessionState(undefined).attachments).toEqual([]);
|
||||
expect(runtime.chatMessageModel.getSessionState(undefined).messages).toMatchObject([
|
||||
{
|
||||
role: 'sales',
|
||||
content: {
|
||||
type: 'greeting',
|
||||
content: 'Hello from sales',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(otherRuntime.chatConversationModel.currentConversation).toBe('other-session');
|
||||
expect(otherRuntime.chatSenderModel.senderValue).toBe('other draft');
|
||||
});
|
||||
|
||||
it('creates a scoped conversation and switches the current session in the provided runtime', async () => {
|
||||
const getScope = vi.fn().mockResolvedValue('chat-box-1');
|
||||
const runtime = createChatBoxRuntime({ mode: 'block', getScope });
|
||||
const otherRuntime = createChatBoxRuntime({ mode: 'block' });
|
||||
const createConversation = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
sessionId: 'created-session',
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.resource.mockImplementation((name: string) => {
|
||||
if (name === 'aiConversations') {
|
||||
return {
|
||||
create: createConversation,
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected resource: ${name}`);
|
||||
});
|
||||
mocks.request.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useChatBoxActions(runtime));
|
||||
|
||||
act(() => {
|
||||
result.current.send({
|
||||
aiEmployee: employee,
|
||||
messages: [{ type: 'text', content: 'Summarize this block' }],
|
||||
workContext: [],
|
||||
model: {
|
||||
llmService: 'openai',
|
||||
model: 'gpt-5',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(createConversation).toHaveBeenCalled());
|
||||
|
||||
expect(getScope).toHaveBeenCalledWith({ operation: 'create' });
|
||||
expect(createConversation).toHaveBeenCalledWith({
|
||||
values: {
|
||||
aiEmployee: employee,
|
||||
systemMessage: undefined,
|
||||
skillSettings: undefined,
|
||||
scope: 'chat-box-1',
|
||||
modelSettings: {
|
||||
llmService: 'openai',
|
||||
model: 'gpt-5',
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(runtime.chatConversationModel.currentConversation).toBe('created-session'));
|
||||
expect(otherRuntime.chatConversationModel.currentConversation).toBeUndefined();
|
||||
expect(mocks.refreshConversations).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'aiConversations:sendMessages',
|
||||
data: expect.objectContaining({
|
||||
sessionId: 'created-session',
|
||||
aiEmployee: 'sales',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the block runtime scope when auto-sending a routed task', async () => {
|
||||
const getScope = vi.fn().mockResolvedValue('chat-box-1');
|
||||
const runtime = createChatBoxRuntime({ mode: 'block', getScope });
|
||||
const createConversation = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
sessionId: 'task-session',
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.resource.mockImplementation((name: string) => {
|
||||
if (name === 'aiConversations') {
|
||||
return {
|
||||
create: createConversation,
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected resource: ${name}`);
|
||||
});
|
||||
mocks.request.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useChatBoxActions(runtime));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.triggerTask({
|
||||
aiEmployee: employee,
|
||||
tasks: [
|
||||
{
|
||||
title: 'Analyze in block',
|
||||
message: {
|
||||
user: 'Summarize this block',
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(createConversation).toHaveBeenCalled());
|
||||
expect(getScope).toHaveBeenCalledWith({ operation: 'create' });
|
||||
expect(createConversation).toHaveBeenCalledWith({
|
||||
values: {
|
||||
aiEmployee: employee,
|
||||
systemMessage: undefined,
|
||||
skillSettings: undefined,
|
||||
scope: 'chat-box-1',
|
||||
modelSettings: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('notifies RunJS callers when an auto-sent response starts and stops loading', async () => {
|
||||
const runtime = createChatBoxRuntime({ mode: 'block' });
|
||||
const onResponseLoadingChange = vi.fn();
|
||||
const createConversation = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
sessionId: 'task-session',
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.resource.mockImplementation((name: string) => {
|
||||
if (name === 'aiConversations') {
|
||||
return {
|
||||
create: createConversation,
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected resource: ${name}`);
|
||||
});
|
||||
mocks.request.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useChatBoxActions(runtime));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.triggerTask({
|
||||
aiEmployee: employee,
|
||||
tasks: [
|
||||
{
|
||||
title: 'Analyze in block',
|
||||
message: {
|
||||
user: 'Summarize this block',
|
||||
},
|
||||
autoSend: true,
|
||||
},
|
||||
],
|
||||
onResponseLoadingChange,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onResponseLoadingChange.mock.calls).toEqual([[true], [false]]);
|
||||
});
|
||||
});
|
||||
|
||||
it('runs edit, cancel, and resume actions against the provided runtime session', async () => {
|
||||
vi.useFakeTimers();
|
||||
const runtime = createChatBoxRuntime({ mode: 'block' });
|
||||
runtime.chatBoxModel.setCurrentEmployee(employee);
|
||||
runtime.chatConversationModel.setCurrentConversation('session-1');
|
||||
runtime.chatMessageModel.setSessionMessages('session-1', [
|
||||
textMessage('message-1', 'First'),
|
||||
textMessage('message-2', 'Second'),
|
||||
]);
|
||||
const controller = new AbortController();
|
||||
const abortSpy = vi.spyOn(controller, 'abort');
|
||||
runtime.chatMessageModel.setSessionAbortController('session-1', controller);
|
||||
const attachment: Attachment = { filename: 'draft.txt' };
|
||||
const contextItem: ContextItem = { type: 'flow-model', uid: 'block-1' };
|
||||
const abort = vi.fn().mockResolvedValue(undefined);
|
||||
const getMessages = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
data: [],
|
||||
meta: {},
|
||||
},
|
||||
});
|
||||
mocks.resource.mockImplementation((name: string) => {
|
||||
if (name === 'aiConversations') {
|
||||
return {
|
||||
abort,
|
||||
getMessages,
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected resource: ${name}`);
|
||||
});
|
||||
mocks.request.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useChatMessageActions(runtime));
|
||||
|
||||
act(() => {
|
||||
result.current.startEditingMessage({
|
||||
messageId: 'message-2',
|
||||
attachments: [attachment],
|
||||
workContext: [contextItem],
|
||||
});
|
||||
});
|
||||
|
||||
expect(runtime.chatSenderModel.isEditingMessage).toBe(true);
|
||||
expect(runtime.chatSenderModel.editingMessageId).toBe('message-2');
|
||||
expect(runtime.chatMessageModel.getSessionState('session-1').messages).toMatchObject([
|
||||
{
|
||||
key: 'message-1',
|
||||
},
|
||||
]);
|
||||
expect(runtime.chatMessageModel.getSessionState('session-1').attachments).toEqual([attachment]);
|
||||
expect(runtime.chatMessageModel.getSessionState('session-1').contextItems).toEqual([contextItem]);
|
||||
|
||||
act(() => {
|
||||
result.current.finishEditingMessage();
|
||||
});
|
||||
|
||||
expect(runtime.chatSenderModel.isEditingMessage).toBe(false);
|
||||
expect(runtime.chatSenderModel.editingMessageId).toBeUndefined();
|
||||
expect(runtime.chatMessageModel.getSessionState('session-1').attachments).toEqual([]);
|
||||
expect(runtime.chatMessageModel.getSessionState('session-1').contextItems).toEqual([]);
|
||||
|
||||
const cancelPromise = result.current.cancelRequest();
|
||||
expect(abort).toHaveBeenCalledWith({ values: { sessionId: 'session-1' } });
|
||||
expect(abortSpy).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await cancelPromise;
|
||||
expect(getMessages).toHaveBeenCalledWith({
|
||||
sessionId: 'session-1',
|
||||
cursor: undefined,
|
||||
paginate: false,
|
||||
updateRead: true,
|
||||
});
|
||||
|
||||
await result.current.resumeStream({
|
||||
sessionId: 'session-1',
|
||||
aiEmployee: employee,
|
||||
});
|
||||
|
||||
expect(mocks.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'aiConversations:resumeStream',
|
||||
data: {
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks unread conversations as read when loading the current block conversation', async () => {
|
||||
const runtime = createChatBoxRuntime({ mode: 'block' });
|
||||
runtime.chatConversationModel.setCurrentConversation('session-1');
|
||||
runtime.chatConversationModel.setConversations([
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
title: 'Unread conversation',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
aiEmployee: employee,
|
||||
read: false,
|
||||
},
|
||||
]);
|
||||
runtime.chatConversationModel.setUnreadCount(1);
|
||||
const getMessages = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
data: [],
|
||||
meta: {},
|
||||
},
|
||||
});
|
||||
mocks.resource.mockImplementation((name: string) => {
|
||||
if (name === 'aiConversations') {
|
||||
return {
|
||||
getMessages,
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected resource: ${name}`);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChatMessageActions(runtime));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMessages('session-1');
|
||||
});
|
||||
|
||||
expect(getMessages).toHaveBeenCalledWith({
|
||||
sessionId: 'session-1',
|
||||
cursor: undefined,
|
||||
paginate: false,
|
||||
updateRead: true,
|
||||
});
|
||||
expect(runtime.chatConversationModel.conversations[0].read).toBe(true);
|
||||
expect(runtime.chatConversationModel.unreadCount).toBe(0);
|
||||
});
|
||||
});
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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 { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createChatBoxRuntime } from '../../stores/runtime';
|
||||
import { useUploadFiles, useUploadProps } from '../useUploadFiles';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
request: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@nocobase/client-v2', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@nocobase/client-v2')>()),
|
||||
useApp: () => ({
|
||||
apiClient: {
|
||||
request: mocks.request,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('ahooks', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('ahooks')>()),
|
||||
useRequest: () => ({ loading: false, data: null }),
|
||||
}));
|
||||
|
||||
describe('useUploadProps', () => {
|
||||
beforeEach(() => {
|
||||
mocks.request.mockReset();
|
||||
});
|
||||
|
||||
it('uses the shared AI upload pipeline and preserves the Upload response shape', async () => {
|
||||
const source = {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'aiFiles',
|
||||
};
|
||||
mocks.request.mockImplementation(async (options) => {
|
||||
options.onUploadProgress?.({ loaded: 50, total: 100 });
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
id: 1,
|
||||
filename: 'report.txt',
|
||||
meta: { source },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
const onError = vi.fn();
|
||||
const onProgress = vi.fn();
|
||||
const onSuccess = vi.fn();
|
||||
const file = new File(['report'], 'report.txt', { type: 'text/plain' });
|
||||
const { result } = renderHook(() => useUploadProps({ action: 'aiFiles:create' }));
|
||||
|
||||
const request = result.current.customRequest({
|
||||
action: 'aiFiles:create',
|
||||
file,
|
||||
filename: 'file',
|
||||
onError,
|
||||
onProgress,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
expect(request).toEqual({ abort: expect.any(Function) });
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalledWith(
|
||||
{
|
||||
data: {
|
||||
id: 1,
|
||||
filename: 'report.txt',
|
||||
meta: { source },
|
||||
source,
|
||||
status: 'done',
|
||||
},
|
||||
},
|
||||
file,
|
||||
);
|
||||
});
|
||||
expect(onProgress).toHaveBeenCalledWith({ percent: 50 });
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores the normalized attachment returned by the shared upload pipeline', () => {
|
||||
const runtime = createChatBoxRuntime();
|
||||
const attachment = {
|
||||
id: 1,
|
||||
filename: 'report.txt',
|
||||
source: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'aiFiles',
|
||||
},
|
||||
status: 'done',
|
||||
};
|
||||
const { result } = renderHook(() => useUploadFiles(runtime));
|
||||
|
||||
act(() => {
|
||||
result.current.onChange({
|
||||
fileList: [
|
||||
{
|
||||
filename: 'report.txt',
|
||||
status: 'done',
|
||||
response: { data: attachment },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
expect(runtime.chatMessageModel.getSessionState(undefined).attachments).toEqual([attachment]);
|
||||
});
|
||||
});
|
||||
+61
-68
@@ -11,145 +11,138 @@ import { useMemo } from 'react';
|
||||
import { Attachment, ChatEditorRef, ContextItem, Message, SkillSettings, WebSearching } from '../../types';
|
||||
import {
|
||||
CHAT_EMPTY_SESSION_STATE,
|
||||
ChatMessageModel,
|
||||
ChatSessionState,
|
||||
getChatSessionKey,
|
||||
useChatMessagesStore,
|
||||
} from '../stores/chat-messages';
|
||||
import { type ChatBoxRuntime, useResolvedChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
const selectSessionState = (state: ReturnType<typeof useChatMessagesStore.getState>, sessionId?: string) =>
|
||||
state.sessions[getChatSessionKey(sessionId)] ?? CHAT_EMPTY_SESSION_STATE;
|
||||
const selectSessionState = (model: ChatMessageModel, sessionId?: string) =>
|
||||
model.sessions[getChatSessionKey(sessionId)] ?? CHAT_EMPTY_SESSION_STATE;
|
||||
|
||||
type MessagesMeta = ChatSessionState['messagesMeta'];
|
||||
|
||||
const createChatFacade = (sessionId?: string) => {
|
||||
const createChatFacade = (chatMessageModel: ChatMessageModel, sessionId?: string) => {
|
||||
const sessionKey = getChatSessionKey(sessionId);
|
||||
const actions = {
|
||||
setMessages: (messages: Message[] | ((prev: Message[]) => Message[])) =>
|
||||
useChatMessagesStore.getState().setSessionMessages(sessionKey, messages),
|
||||
setMessagesLoading: (loading: boolean) =>
|
||||
useChatMessagesStore.getState().setSessionMessagesLoading(sessionKey, loading),
|
||||
setMessagesError: (error: unknown) => useChatMessagesStore.getState().setSessionMessagesError(sessionKey, error),
|
||||
chatMessageModel.setSessionMessages(sessionKey, messages),
|
||||
setMessagesLoading: (loading: boolean) => chatMessageModel.setSessionMessagesLoading(sessionKey, loading),
|
||||
setMessagesError: (error: unknown) => chatMessageModel.setSessionMessagesError(sessionKey, error),
|
||||
setMessagesMeta: (meta: MessagesMeta | ((prev: MessagesMeta) => MessagesMeta)) =>
|
||||
useChatMessagesStore.getState().setSessionMessagesMeta(sessionKey, meta),
|
||||
chatMessageModel.setSessionMessagesMeta(sessionKey, meta),
|
||||
setAttachments: (attachments: Attachment[] | ((prev: Attachment[]) => Attachment[])) =>
|
||||
useChatMessagesStore.getState().setSessionAttachments(sessionKey, attachments),
|
||||
chatMessageModel.setSessionAttachments(sessionKey, attachments),
|
||||
setContextItems: (items: ContextItem[] | ((prev: ContextItem[]) => ContextItem[])) =>
|
||||
useChatMessagesStore.getState().setSessionContextItems(sessionKey, items),
|
||||
chatMessageModel.setSessionContextItems(sessionKey, items),
|
||||
setSystemMessage: (msg: string | ((prev: string) => string)) =>
|
||||
useChatMessagesStore.getState().setSessionSystemMessage(sessionKey, msg),
|
||||
setResponseLoading: (loading: boolean) =>
|
||||
useChatMessagesStore.getState().setSessionResponseLoading(sessionKey, loading),
|
||||
chatMessageModel.setSessionSystemMessage(sessionKey, msg),
|
||||
setResponseLoading: (loading: boolean) => chatMessageModel.setSessionResponseLoading(sessionKey, loading),
|
||||
setBackgroundWorking: (backgroundWorking: boolean) =>
|
||||
useChatMessagesStore.getState().setSessionBackgroundWorking(sessionKey, backgroundWorking),
|
||||
chatMessageModel.setSessionBackgroundWorking(sessionKey, backgroundWorking),
|
||||
setResumeStreamFailed: (resumeStreamFailed: boolean) =>
|
||||
useChatMessagesStore.getState().setSessionResumeStreamFailed(sessionKey, resumeStreamFailed),
|
||||
addMessage: (msg: Message) => useChatMessagesStore.getState().addSessionMessage(sessionKey, msg),
|
||||
addMessages: (msgs: Message[]) => useChatMessagesStore.getState().addSessionMessages(sessionKey, msgs),
|
||||
chatMessageModel.setSessionResumeStreamFailed(sessionKey, resumeStreamFailed),
|
||||
addMessage: (msg: Message) => chatMessageModel.addSessionMessage(sessionKey, msg),
|
||||
addMessages: (msgs: Message[]) => chatMessageModel.addSessionMessages(sessionKey, msgs),
|
||||
updateLastMessage: (updater: (msg: Message) => Message) =>
|
||||
useChatMessagesStore.getState().updateSessionLastMessage(sessionKey, updater),
|
||||
removeMessage: (key: string) => useChatMessagesStore.getState().removeSessionMessage(sessionKey, key),
|
||||
chatMessageModel.updateSessionLastMessage(sessionKey, updater),
|
||||
removeMessage: (key: string) => chatMessageModel.removeSessionMessage(sessionKey, key),
|
||||
addAttachments: (attachments: Attachment | Attachment[]) =>
|
||||
useChatMessagesStore.getState().addSessionAttachments(sessionKey, attachments),
|
||||
removeAttachment: (filename: string) =>
|
||||
useChatMessagesStore.getState().removeSessionAttachment(sessionKey, filename),
|
||||
addContextItems: (items: ContextItem | ContextItem[]) =>
|
||||
useChatMessagesStore.getState().addSessionContextItems(sessionKey, items),
|
||||
removeContextItem: (type: string, uid: string) =>
|
||||
useChatMessagesStore.getState().removeSessionContextItem(sessionKey, type, uid),
|
||||
setAbortController: (controller: AbortController | undefined) =>
|
||||
useChatMessagesStore.getState().setSessionAbortController(sessionKey, controller),
|
||||
chatMessageModel.addSessionAttachments(sessionKey, attachments),
|
||||
removeAttachment: (filename: string) => chatMessageModel.removeSessionAttachment(sessionKey, filename),
|
||||
addContextItems: (items: ContextItem | ContextItem[]) => chatMessageModel.addSessionContextItems(sessionKey, items),
|
||||
removeContextItem: (type: string, uid: string) => chatMessageModel.removeSessionContextItem(sessionKey, type, uid),
|
||||
setAbortController: (controller: AbortController | undefined | null) =>
|
||||
chatMessageModel.setSessionAbortController(sessionKey, controller),
|
||||
setSkillSettings: (settings: SkillSettings | undefined) =>
|
||||
useChatMessagesStore.getState().setSessionSkillSettings(sessionKey, settings),
|
||||
setWebSearching: (webSearching: WebSearching) =>
|
||||
useChatMessagesStore.getState().setSessionWebSearching(sessionKey, webSearching),
|
||||
chatMessageModel.setSessionSkillSettings(sessionKey, settings),
|
||||
setWebSearching: (webSearching: WebSearching | null | undefined) =>
|
||||
chatMessageModel.setSessionWebSearching(sessionKey, webSearching),
|
||||
addSubAgentMessage: (subSessionId: string, msg: Message) =>
|
||||
useChatMessagesStore.getState().addSessionSubAgentMessage(sessionKey, subSessionId, msg),
|
||||
chatMessageModel.addSessionSubAgentMessage(sessionKey, subSessionId, msg),
|
||||
addSubAgentMessages: (subSessionId: string, msgs: Message[]) =>
|
||||
useChatMessagesStore.getState().addSessionSubAgentMessages(sessionKey, subSessionId, msgs),
|
||||
chatMessageModel.addSessionSubAgentMessages(sessionKey, subSessionId, msgs),
|
||||
updateLastSubAgentMessage: (subSessionId: string, username: string, updater: (msg: Message) => Message) =>
|
||||
useChatMessagesStore.getState().updateSessionLastSubAgentMessage(sessionKey, subSessionId, username, updater),
|
||||
chatMessageModel.updateSessionLastSubAgentMessage(sessionKey, subSessionId, username, updater),
|
||||
updateSubAgentConversationStatus: (subSessionId: string, status: 'pending' | 'completed') =>
|
||||
useChatMessagesStore.getState().updateSessionSubAgentConversationStatus(sessionKey, subSessionId, status),
|
||||
setEditorRef: (uid: string, editorRef: ChatEditorRef | null) =>
|
||||
useChatMessagesStore.getState().setEditorRef(uid, editorRef),
|
||||
setCurrentEditorRefUid: (uid: string) => useChatMessagesStore.getState().setCurrentEditorRefUid(uid),
|
||||
setFlowContext: (flowContext: unknown) => useChatMessagesStore.getState().setFlowContext(flowContext),
|
||||
migrateSessionState: (toSessionId: string) =>
|
||||
useChatMessagesStore.getState().migrateSessionState(sessionKey, toSessionId),
|
||||
resetSessionState: (patch?: Partial<ChatSessionState>) =>
|
||||
useChatMessagesStore.getState().resetSessionState(sessionKey, patch),
|
||||
chatMessageModel.updateSessionSubAgentConversationStatus(sessionKey, subSessionId, status),
|
||||
setEditorRef: (uid: string, editorRef: ChatEditorRef | null) => chatMessageModel.setEditorRef(uid, editorRef),
|
||||
setCurrentEditorRefUid: (uid: string) => chatMessageModel.setCurrentEditorRefUid(uid),
|
||||
setFlowContext: (flowContext: unknown) => chatMessageModel.setFlowContext(flowContext),
|
||||
migrateSessionState: (toSessionId: string) => chatMessageModel.migrateSessionState(sessionKey, toSessionId),
|
||||
resetSessionState: (patch?: Partial<ChatSessionState>) => chatMessageModel.resetSessionState(sessionKey, patch),
|
||||
};
|
||||
|
||||
return {
|
||||
sessionKey,
|
||||
for: (targetSessionId?: string) => createChatFacade(targetSessionId),
|
||||
for: (targetSessionId?: string) => createChatFacade(chatMessageModel, targetSessionId),
|
||||
...actions,
|
||||
use: {
|
||||
messages: function useMessages() {
|
||||
return useChatMessagesStore((state) => selectSessionState(state, sessionKey).messages);
|
||||
return selectSessionState(chatMessageModel, sessionKey).messages;
|
||||
},
|
||||
messagesLoading: function useMessagesLoading() {
|
||||
return useChatMessagesStore((state) => selectSessionState(state, sessionKey).messagesLoading);
|
||||
return selectSessionState(chatMessageModel, sessionKey).messagesLoading;
|
||||
},
|
||||
messagesError: function useMessagesError() {
|
||||
return useChatMessagesStore((state) => selectSessionState(state, sessionKey).messagesError);
|
||||
return selectSessionState(chatMessageModel, sessionKey).messagesError;
|
||||
},
|
||||
messagesMeta: function useMessagesMeta() {
|
||||
return useChatMessagesStore((state) => selectSessionState(state, sessionKey).messagesMeta);
|
||||
return selectSessionState(chatMessageModel, sessionKey).messagesMeta;
|
||||
},
|
||||
attachments: function useAttachments() {
|
||||
return useChatMessagesStore((state) => selectSessionState(state, sessionKey).attachments);
|
||||
return selectSessionState(chatMessageModel, sessionKey).attachments;
|
||||
},
|
||||
contextItems: function useContextItems() {
|
||||
return useChatMessagesStore((state) => selectSessionState(state, sessionKey).contextItems);
|
||||
return selectSessionState(chatMessageModel, sessionKey).contextItems;
|
||||
},
|
||||
systemMessage: function useSystemMessage() {
|
||||
return useChatMessagesStore((state) => selectSessionState(state, sessionKey).systemMessage);
|
||||
return selectSessionState(chatMessageModel, sessionKey).systemMessage;
|
||||
},
|
||||
responseLoading: function useResponseLoading() {
|
||||
return useChatMessagesStore((state) => selectSessionState(state, sessionKey).responseLoading);
|
||||
return selectSessionState(chatMessageModel, sessionKey).responseLoading;
|
||||
},
|
||||
backgroundWorking: function useBackgroundWorking() {
|
||||
return useChatMessagesStore((state) => selectSessionState(state, sessionKey).backgroundWorking);
|
||||
return selectSessionState(chatMessageModel, sessionKey).backgroundWorking;
|
||||
},
|
||||
resumeStreamFailed: function useResumeStreamFailed() {
|
||||
return useChatMessagesStore((state) => selectSessionState(state, sessionKey).resumeStreamFailed);
|
||||
return selectSessionState(chatMessageModel, sessionKey).resumeStreamFailed;
|
||||
},
|
||||
abortController: function useAbortController() {
|
||||
return useChatMessagesStore((state) => selectSessionState(state, sessionKey).abortController);
|
||||
return selectSessionState(chatMessageModel, sessionKey).abortController;
|
||||
},
|
||||
skillSettings: function useSkillSettings() {
|
||||
return useChatMessagesStore((state) => selectSessionState(state, sessionKey).skillSettings);
|
||||
return selectSessionState(chatMessageModel, sessionKey).skillSettings;
|
||||
},
|
||||
webSearching: function useWebSearching() {
|
||||
return useChatMessagesStore((state) => selectSessionState(state, sessionKey).webSearching);
|
||||
return selectSessionState(chatMessageModel, sessionKey).webSearching;
|
||||
},
|
||||
|
||||
editorRef: function useEditorRef() {
|
||||
return useChatMessagesStore.use.editorRef();
|
||||
return chatMessageModel.editorRef;
|
||||
},
|
||||
currentEditorRefUid: function useCurrentEditorRefUid() {
|
||||
return useChatMessagesStore.use.currentEditorRefUid();
|
||||
return chatMessageModel.currentEditorRefUid;
|
||||
},
|
||||
flowContext: function useFlowContext() {
|
||||
return useChatMessagesStore.use.flowContext();
|
||||
return chatMessageModel.flowContext;
|
||||
},
|
||||
},
|
||||
getState: () => {
|
||||
const state = useChatMessagesStore.getState();
|
||||
const sessionState = state.getSessionState(sessionKey);
|
||||
const sessionState = chatMessageModel.getSessionState(sessionKey);
|
||||
return {
|
||||
...sessionState,
|
||||
editorRef: state.editorRef,
|
||||
currentEditorRefUid: state.currentEditorRefUid,
|
||||
flowContext: state.flowContext,
|
||||
editorRef: chatMessageModel.editorRef,
|
||||
currentEditorRefUid: chatMessageModel.currentEditorRefUid,
|
||||
flowContext: chatMessageModel.flowContext,
|
||||
...actions,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const useChat = (sessionId?: string) => {
|
||||
export const useChat = (sessionId?: string, runtime?: ChatBoxRuntime) => {
|
||||
const { chatMessageModel } = useResolvedChatBoxRuntime(runtime);
|
||||
const sessionKey = getChatSessionKey(sessionId);
|
||||
return useMemo(() => createChatFacade(sessionKey), [sessionKey]);
|
||||
return useMemo(() => createChatFacade(chatMessageModel, sessionKey), [chatMessageModel, sessionKey]);
|
||||
};
|
||||
|
||||
+64
-86
@@ -11,53 +11,33 @@ import { useCallback } from 'react';
|
||||
import { AIEmployee, Attachment, ClearOptions, Message, SendOptions, TriggerTaskOptions } from '../../types';
|
||||
import { useApp } from '@nocobase/client-v2';
|
||||
import { randomId } from '@nocobase/flow-engine';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { useChat } from '../hooks/useChat';
|
||||
import { useChatConversationActions } from './useChatConversationActions';
|
||||
import { useChatMessageActions } from './useChatMessageActions';
|
||||
import { useT } from '../../../locale';
|
||||
import { parseTask } from '../utils';
|
||||
import { aiEmployeeRole } from '../roles';
|
||||
import { useChatToolsStore } from '../stores/chat-tools';
|
||||
import { useWorkflowTasksStore } from '../stores/workflow-tasks';
|
||||
import { useAIConfigRepository } from '../../../repositories/hooks/useAIConfigRepository';
|
||||
import { getAllModels, isSameModel, isValidModel, resolveModel } from '../model';
|
||||
import { resolveChatBoxScope, type ChatBoxRuntime, useResolvedChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
export const useChatBoxActions = () => {
|
||||
export const useChatBoxActions = (runtime?: ChatBoxRuntime) => {
|
||||
const app = useApp();
|
||||
const api = app.apiClient;
|
||||
const aiConfigRepository = useAIConfigRepository();
|
||||
const t = useT();
|
||||
const resolvedRuntime = useResolvedChatBoxRuntime(runtime);
|
||||
const { chatBoxModel, chatConversationModel, chatSenderModel, chatToolModel, workflowTaskModel } = resolvedRuntime;
|
||||
|
||||
const open = useChatBoxStore.use.open();
|
||||
const setOpen = useChatBoxStore.use.setOpen();
|
||||
const setReadonly = useChatBoxStore.use.setReadonly();
|
||||
const setSenderValue = useChatBoxStore.use.setSenderValue();
|
||||
const setTaskVariables = useChatBoxStore.use.setTaskVariables();
|
||||
const roles = useChatBoxStore.use.roles();
|
||||
const setRoles = useChatBoxStore.use.setRoles();
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const setCurrentEmployee = useChatBoxStore.use.setCurrentEmployee();
|
||||
const senderRef = useChatBoxStore.use.senderRef();
|
||||
const setModel = useChatBoxStore.use.setModel();
|
||||
const currentConversation = chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, resolvedRuntime);
|
||||
const draftChat = useChat(undefined, resolvedRuntime);
|
||||
|
||||
const setCurrentConversation = useChatConversationsStore.use.setCurrentConversation();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const setWebSearch = useChatConversationsStore.use.setWebSearch();
|
||||
const chat = useChat(currentConversation);
|
||||
const draftChat = useChat();
|
||||
|
||||
const setOpenToolModal = useChatToolsStore.use.setOpenToolModal();
|
||||
const setActiveTool = useChatToolsStore.use.setActiveTool();
|
||||
const setActiveMessageId = useChatToolsStore.use.setActiveMessageId();
|
||||
const setCurrentWorkflowTask = useWorkflowTasksStore.use.setCurrentWorkflowTask();
|
||||
|
||||
const { refresh: refreshConversations } = useChatConversationActions();
|
||||
const { sendMessages, syncContextAttachments } = useChatMessageActions();
|
||||
const { refresh: refreshConversations } = useChatConversationActions(resolvedRuntime);
|
||||
const { sendMessages, syncContextAttachments } = useChatMessageActions(resolvedRuntime);
|
||||
|
||||
const clear = useCallback(
|
||||
(options?: ClearOptions, sessionId: string | undefined = currentConversation) => {
|
||||
(options?: ClearOptions, sessionId: string | undefined = chatConversationModel.currentConversation) => {
|
||||
const sessionChat = chat.for(sessionId);
|
||||
const {
|
||||
sender,
|
||||
@@ -71,7 +51,7 @@ export const useChatBoxActions = () => {
|
||||
skillSettings,
|
||||
} = options ?? {};
|
||||
if (sender !== false) {
|
||||
setSenderValue('');
|
||||
chatSenderModel.setSenderValue('');
|
||||
}
|
||||
if (systemMessage !== false) {
|
||||
sessionChat.setSystemMessage('');
|
||||
@@ -83,95 +63,98 @@ export const useChatBoxActions = () => {
|
||||
sessionChat.setContextItems([]);
|
||||
}
|
||||
if (taskVariables !== false) {
|
||||
setTaskVariables({});
|
||||
chatBoxModel.setTaskVariables({});
|
||||
}
|
||||
if (toolModal !== false) {
|
||||
setOpenToolModal(false);
|
||||
chatToolModel.setOpenToolModal(false);
|
||||
}
|
||||
if (activeTool !== false) {
|
||||
setActiveTool(null);
|
||||
chatToolModel.setActiveTool(null);
|
||||
}
|
||||
if (activeMessageId !== false) {
|
||||
setActiveMessageId('');
|
||||
chatToolModel.setActiveMessageId('');
|
||||
}
|
||||
if (skillSettings !== false) {
|
||||
sessionChat.setSkillSettings(undefined);
|
||||
}
|
||||
},
|
||||
[chat, currentConversation, setActiveMessageId, setActiveTool, setOpenToolModal, setSenderValue, setTaskVariables],
|
||||
[chat, chatBoxModel, chatConversationModel, chatSenderModel, chatToolModel],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
(options: SendOptions) => {
|
||||
async (options: SendOptions) => {
|
||||
const scope = await resolveChatBoxScope(resolvedRuntime, { operation: 'create' });
|
||||
const sendOptions = {
|
||||
...options,
|
||||
scope,
|
||||
onConversationCreate: (sessionId: string) => {
|
||||
setCurrentConversation(sessionId);
|
||||
chatConversationModel.setCurrentConversation(sessionId);
|
||||
refreshConversations();
|
||||
},
|
||||
};
|
||||
clear();
|
||||
sendMessages(sendOptions);
|
||||
},
|
||||
[clear, refreshConversations, sendMessages, setCurrentConversation],
|
||||
[chatConversationModel, clear, refreshConversations, resolvedRuntime, sendMessages],
|
||||
);
|
||||
|
||||
const updateRole = useCallback(
|
||||
(aiEmployee: AIEmployee) => {
|
||||
if (!roles[aiEmployee.username]) {
|
||||
setRoles((prev) => ({
|
||||
if (!chatBoxModel.roles[aiEmployee.username]) {
|
||||
chatBoxModel.setRoles((prev) => ({
|
||||
...prev,
|
||||
[aiEmployee.username]: aiEmployeeRole(aiEmployee),
|
||||
}));
|
||||
}
|
||||
},
|
||||
[roles, setRoles],
|
||||
[chatBoxModel],
|
||||
);
|
||||
|
||||
const ensureModel = useCallback(
|
||||
async (aiEmployee: AIEmployee) => {
|
||||
const allModels = getAllModels(await aiConfigRepository.getLLMServices());
|
||||
const currentModel = useChatBoxStore.getState().model;
|
||||
const currentModel = chatBoxModel.model;
|
||||
const resolvedModel = resolveModel(api, aiEmployee, allModels, currentModel);
|
||||
if (!isSameModel(currentModel, resolvedModel)) {
|
||||
setModel(resolvedModel);
|
||||
chatBoxModel.setModel(resolvedModel);
|
||||
}
|
||||
return resolvedModel;
|
||||
},
|
||||
[api, aiConfigRepository, setModel],
|
||||
[api, aiConfigRepository, chatBoxModel],
|
||||
);
|
||||
|
||||
const resolveTaskModel = useCallback(
|
||||
async (aiEmployee: AIEmployee, taskModel?: { llmService: string; model: string } | null) => {
|
||||
const allModels = getAllModels(await aiConfigRepository.getLLMServices());
|
||||
if (!allModels.length) {
|
||||
const currentModel = useChatBoxStore.getState().model;
|
||||
const currentModel = chatBoxModel.model;
|
||||
if (currentModel) {
|
||||
setModel(null);
|
||||
chatBoxModel.setModel(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (isValidModel(taskModel, allModels)) {
|
||||
const currentModel = useChatBoxStore.getState().model;
|
||||
const currentModel = chatBoxModel.model;
|
||||
if (!isSameModel(currentModel, taskModel)) {
|
||||
setModel(taskModel);
|
||||
chatBoxModel.setModel(taskModel);
|
||||
}
|
||||
return taskModel;
|
||||
}
|
||||
const currentModel = useChatBoxStore.getState().model;
|
||||
const currentModel = chatBoxModel.model;
|
||||
const resolvedModel = resolveModel(api, aiEmployee, allModels, currentModel);
|
||||
if (!isSameModel(currentModel, resolvedModel)) {
|
||||
setModel(resolvedModel);
|
||||
chatBoxModel.setModel(resolvedModel);
|
||||
}
|
||||
return resolvedModel;
|
||||
},
|
||||
[api, aiConfigRepository, setModel],
|
||||
[api, aiConfigRepository, chatBoxModel],
|
||||
);
|
||||
|
||||
const startNewConversation = useCallback(() => {
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
if (!currentEmployee) {
|
||||
setCurrentConversation(undefined);
|
||||
setCurrentWorkflowTask(undefined);
|
||||
chatConversationModel.setCurrentConversation(undefined);
|
||||
workflowTaskModel.setCurrentWorkflowTask(undefined);
|
||||
clear(undefined, undefined);
|
||||
draftChat.setMessages([]);
|
||||
return;
|
||||
@@ -184,20 +167,20 @@ export const useChatBoxActions = () => {
|
||||
content: currentEmployee.greeting || t('Default greeting message', { nickname: currentEmployee.nickname }),
|
||||
},
|
||||
};
|
||||
setCurrentConversation(undefined);
|
||||
setCurrentWorkflowTask(undefined);
|
||||
chatConversationModel.setCurrentConversation(undefined);
|
||||
workflowTaskModel.setCurrentWorkflowTask(undefined);
|
||||
clear(undefined, undefined);
|
||||
draftChat.setMessages([greetingMsg]);
|
||||
senderRef?.current?.focus();
|
||||
}, [clear, currentEmployee, draftChat, senderRef, setCurrentConversation, setCurrentWorkflowTask, t]);
|
||||
chatSenderModel.senderRef?.current?.focus();
|
||||
}, [chatBoxModel, chatConversationModel, chatSenderModel, clear, draftChat, t, workflowTaskModel]);
|
||||
|
||||
const switchAIEmployee = useCallback(
|
||||
(aiEmployee: AIEmployee, options?: { clear?: ClearOptions }) => {
|
||||
setCurrentEmployee(aiEmployee);
|
||||
setCurrentConversation(undefined);
|
||||
setCurrentWorkflowTask(undefined);
|
||||
chatBoxModel.setCurrentEmployee(aiEmployee);
|
||||
chatConversationModel.setCurrentConversation(undefined);
|
||||
workflowTaskModel.setCurrentWorkflowTask(undefined);
|
||||
clear(options?.clear, undefined);
|
||||
setModel(null);
|
||||
chatBoxModel.setModel(null);
|
||||
if (aiEmployee) {
|
||||
const greetingMsg = {
|
||||
key: randomId(),
|
||||
@@ -207,13 +190,13 @@ export const useChatBoxActions = () => {
|
||||
content: aiEmployee.greeting || t('Default greeting message', { nickname: aiEmployee.nickname }),
|
||||
},
|
||||
};
|
||||
senderRef?.current?.focus();
|
||||
chatSenderModel.senderRef?.current?.focus();
|
||||
draftChat.setMessages([greetingMsg]);
|
||||
} else {
|
||||
draftChat.setMessages([]);
|
||||
}
|
||||
},
|
||||
[clear, draftChat, senderRef, setCurrentConversation, setCurrentEmployee, setCurrentWorkflowTask, setModel, t],
|
||||
[chatBoxModel, chatConversationModel, chatSenderModel, clear, draftChat, t, workflowTaskModel],
|
||||
);
|
||||
|
||||
const triggerTask = useCallback(
|
||||
@@ -221,24 +204,24 @@ export const useChatBoxActions = () => {
|
||||
clear(undefined, undefined);
|
||||
const { aiEmployee, tasks } = options;
|
||||
if (!aiEmployee) {
|
||||
setCurrentEmployee(undefined);
|
||||
chatBoxModel.setCurrentEmployee(undefined);
|
||||
draftChat.setMessages([]);
|
||||
return;
|
||||
}
|
||||
updateRole(aiEmployee);
|
||||
setReadonly(false);
|
||||
chatBoxModel.setReadonly(false);
|
||||
draftChat.setResponseLoading(false);
|
||||
if (options.open !== false && !open) {
|
||||
setOpen(true);
|
||||
if (options.open !== false && !chatBoxModel.open) {
|
||||
chatBoxModel.setOpen(true);
|
||||
}
|
||||
if (currentConversation) {
|
||||
setCurrentConversation(undefined);
|
||||
setCurrentWorkflowTask(undefined);
|
||||
if (chatConversationModel.currentConversation) {
|
||||
chatConversationModel.setCurrentConversation(undefined);
|
||||
workflowTaskModel.setCurrentWorkflowTask(undefined);
|
||||
draftChat.setMessages([]);
|
||||
}
|
||||
setCurrentEmployee(aiEmployee);
|
||||
chatBoxModel.setCurrentEmployee(aiEmployee);
|
||||
await ensureModel(aiEmployee);
|
||||
senderRef?.current?.focus();
|
||||
chatSenderModel.senderRef?.current?.focus();
|
||||
const msgs: Message[] = [
|
||||
{
|
||||
key: randomId(),
|
||||
@@ -271,11 +254,11 @@ export const useChatBoxActions = () => {
|
||||
);
|
||||
const resolvedWebSearch =
|
||||
service?.supportWebSearch === false ? false : typeof webSearch === 'boolean' ? webSearch : false;
|
||||
setWebSearch(resolvedWebSearch);
|
||||
chatConversationModel.setWebSearch(resolvedWebSearch);
|
||||
if (userMessage && userMessage.type === 'text') {
|
||||
setSenderValue(userMessage.content);
|
||||
chatSenderModel.setSenderValue(userMessage.content);
|
||||
} else {
|
||||
setSenderValue('');
|
||||
chatSenderModel.setSenderValue('');
|
||||
}
|
||||
let contextAttachments: Attachment[] = [];
|
||||
if (workContext) {
|
||||
@@ -302,6 +285,7 @@ export const useChatBoxActions = () => {
|
||||
skillSettings,
|
||||
webSearch: resolvedWebSearch,
|
||||
model: resolvedModel,
|
||||
onResponseLoadingChange: options.onResponseLoadingChange,
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -316,25 +300,19 @@ export const useChatBoxActions = () => {
|
||||
draftChat.setMessages(msgs);
|
||||
},
|
||||
[
|
||||
open,
|
||||
currentConversation,
|
||||
chatBoxModel,
|
||||
clear,
|
||||
draftChat,
|
||||
ensureModel,
|
||||
aiConfigRepository,
|
||||
chatSenderModel,
|
||||
resolveTaskModel,
|
||||
send,
|
||||
senderRef,
|
||||
setCurrentConversation,
|
||||
setCurrentEmployee,
|
||||
setCurrentWorkflowTask,
|
||||
setOpen,
|
||||
setReadonly,
|
||||
setSenderValue,
|
||||
setWebSearch,
|
||||
chatConversationModel,
|
||||
syncContextAttachments,
|
||||
t,
|
||||
updateRole,
|
||||
workflowTaskModel,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+9
-10
@@ -10,17 +10,16 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAIConfigRepository } from '../../../repositories/hooks/useAIConfigRepository';
|
||||
import { aiEmployeeRole, defaultRoles } from '../roles';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { type ChatBoxRuntime, useResolvedChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
export const useChatBoxEffect = () => {
|
||||
export const useChatBoxEffect = (runtime?: ChatBoxRuntime) => {
|
||||
const aiConfigRepository = useAIConfigRepository();
|
||||
const aiEmployees = aiConfigRepository.aiEmployees;
|
||||
const open = useChatBoxStore.use.open();
|
||||
const senderRef = useChatBoxStore.use.senderRef();
|
||||
const currentEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const setRoles = useChatBoxStore.use.setRoles();
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const { chatBoxModel, chatConversationModel, chatSenderModel } = useResolvedChatBoxRuntime(runtime);
|
||||
const open = chatBoxModel.open;
|
||||
const senderRef = chatSenderModel.senderRef;
|
||||
const currentEmployee = chatBoxModel.currentEmployee;
|
||||
const currentConversation = chatConversationModel.currentConversation;
|
||||
|
||||
useEffect(() => {
|
||||
aiConfigRepository.getAIEmployees().catch(console.error);
|
||||
@@ -37,12 +36,12 @@ export const useChatBoxEffect = () => {
|
||||
{},
|
||||
);
|
||||
|
||||
setRoles((previous) => ({
|
||||
chatBoxModel.setRoles((previous) => ({
|
||||
...previous,
|
||||
...defaultRoles,
|
||||
...employeeRoles,
|
||||
}));
|
||||
}, [aiEmployees, setRoles]);
|
||||
}, [aiEmployees, chatBoxModel]);
|
||||
|
||||
useEffect(() => {
|
||||
senderRef?.current?.focus();
|
||||
|
||||
+20
-18
@@ -10,19 +10,15 @@
|
||||
import { useApp } from '@nocobase/client-v2';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { Conversation } from '../../types';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useLoadMoreObserver } from './useLoadMoreObserver';
|
||||
import { useWorkflowTasksStore } from '../stores/workflow-tasks';
|
||||
import { resolveChatBoxScope, type ChatBoxRuntime, useResolvedChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
export const useChatConversationActions = () => {
|
||||
export const useChatConversationActions = (runtime?: ChatBoxRuntime) => {
|
||||
const app = useApp();
|
||||
const api = app.apiClient;
|
||||
const setConversations = useChatConversationsStore.use.setConversations();
|
||||
const keyword = useChatConversationsStore.use.keyword();
|
||||
const unreadCount = useChatConversationsStore.use.unreadCount();
|
||||
const setUnreadCount = useChatConversationsStore.use.setUnreadCount();
|
||||
const setWorkflowTaskUnreadCount = useWorkflowTasksStore.use.setUnreadCount();
|
||||
const resolvedRuntime = useResolvedChatBoxRuntime(runtime);
|
||||
const { chatConversationModel, workflowTaskModel } = resolvedRuntime;
|
||||
|
||||
const conversationsService = useRequest<
|
||||
{
|
||||
@@ -31,13 +27,17 @@ export const useChatConversationActions = () => {
|
||||
},
|
||||
[number?, string?]
|
||||
>(
|
||||
(page = 1, keyword = '') => {
|
||||
const filter: { title?: { $includes: string } } = {};
|
||||
async (page = 1, keyword = '') => {
|
||||
const filter: { title?: { $includes: string }; scope?: string } = {};
|
||||
const scope = await resolveChatBoxScope(resolvedRuntime, { operation: 'list' });
|
||||
|
||||
// Filter by keyword
|
||||
if (keyword) {
|
||||
filter.title = { $includes: keyword };
|
||||
}
|
||||
if (scope) {
|
||||
filter.scope = scope;
|
||||
}
|
||||
|
||||
return api
|
||||
.resource('aiConversations')
|
||||
@@ -58,9 +58,9 @@ export const useChatConversationActions = () => {
|
||||
return;
|
||||
}
|
||||
if (!page || page === 1) {
|
||||
setConversations(data.data);
|
||||
chatConversationModel.setConversations(data.data);
|
||||
} else {
|
||||
setConversations((prev) => [...prev, ...data.data]);
|
||||
chatConversationModel.setConversations((prev) => [...prev, ...data.data]);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -73,16 +73,16 @@ export const useChatConversationActions = () => {
|
||||
if (conversationsService.loading || (meta && meta.page >= meta.totalPage)) {
|
||||
return;
|
||||
}
|
||||
await conversationsService.runAsync(meta?.page ? meta.page + 1 : 1, keyword);
|
||||
}, [keyword]);
|
||||
await conversationsService.runAsync(meta?.page ? meta.page + 1 : 1, chatConversationModel.keyword);
|
||||
}, [chatConversationModel]);
|
||||
const { ref: lastConversationRef } = useLoadMoreObserver({ loadMore: loadMoreConversations });
|
||||
|
||||
const loadUnreadCounts = useCallback(async () => {
|
||||
const res = await api.resource('aiConversations').unreadCounts();
|
||||
const data = res?.data?.data;
|
||||
setUnreadCount(data?.conversationUnreadCount || 0);
|
||||
setWorkflowTaskUnreadCount(data?.workflowTaskUnreadCount || 0);
|
||||
}, [api, setUnreadCount, setWorkflowTaskUnreadCount]);
|
||||
chatConversationModel.setUnreadCount(data?.conversationUnreadCount || 0);
|
||||
workflowTaskModel.setUnreadCount(data?.workflowTaskUnreadCount || 0);
|
||||
}, [api, chatConversationModel, workflowTaskModel]);
|
||||
|
||||
const runSearch = (keyword = '') => conversationsService.run(1, keyword);
|
||||
const refresh = useCallback(() => {
|
||||
@@ -95,6 +95,8 @@ export const useChatConversationActions = () => {
|
||||
lastConversationRef,
|
||||
runSearch,
|
||||
refresh,
|
||||
unreadCount,
|
||||
get unreadCount() {
|
||||
return chatConversationModel.unreadCount;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+104
-74
@@ -14,15 +14,13 @@ import { randomId } from '@nocobase/flow-engine';
|
||||
import { AIEmployee, Attachment, ContextItem, Message, ResendOptions, SendOptions, ToolCall } from '../../types';
|
||||
import { useLoadMoreObserver } from './useLoadMoreObserver';
|
||||
import { useT } from '../../../locale';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
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';
|
||||
import { ensureModel, getAllModels, isSameModel, isValidModel } from '../model';
|
||||
import { FlowUtils } from '../../flow';
|
||||
import { UploadFieldModel } from '@nocobase/plugin-file-manager/client-v2';
|
||||
import { type ChatBoxRuntime, useResolvedChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
const STREAM_UPDATE_INTERVAL = 50;
|
||||
|
||||
@@ -98,31 +96,17 @@ const getErrorMessage = (error: unknown) => (error instanceof Error ? error.mess
|
||||
|
||||
const getErrorName = (error: unknown) => (error instanceof Error ? error.name : undefined);
|
||||
|
||||
export const useChatMessageActions = () => {
|
||||
export const useChatMessageActions = (runtime?: ChatBoxRuntime) => {
|
||||
const app = useApp();
|
||||
const t = useT();
|
||||
const api = app.apiClient;
|
||||
const aiConfigRepository = useAIConfigRepository();
|
||||
const resolvedRuntime = useResolvedChatBoxRuntime(runtime);
|
||||
const { chatBoxModel, chatConversationModel, chatSenderModel, chatToolCallModel } = resolvedRuntime;
|
||||
|
||||
const isEditingMessage = useChatBoxStore.use.isEditingMessage();
|
||||
const setIsEditingMessage = useChatBoxStore.use.setIsEditingMessage();
|
||||
const setEditingMessageId = useChatBoxStore.use.setEditingMessageId();
|
||||
const setModel = useChatBoxStore.use.setModel();
|
||||
const currentConversation = chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, resolvedRuntime);
|
||||
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation?.();
|
||||
const currentWebSearch = useChatConversationsStore.use.webSearch();
|
||||
const setConversationUnreadCount = useChatConversationsStore.use.setUnreadCount();
|
||||
const markConversationRead = useChatConversationsStore.use.markConversationRead();
|
||||
const chat = useChat(currentConversation);
|
||||
const messages = chat.use.messages();
|
||||
const abortController = chat.use.abortController();
|
||||
const setMessages = chat.setMessages;
|
||||
const setResponseLoading = chat.setResponseLoading;
|
||||
const setAbortController = chat.setAbortController;
|
||||
const setAttachments = chat.setAttachments;
|
||||
const setContextItems = chat.setContextItems;
|
||||
|
||||
const updateToolCallInvokeStatus = useChatToolCallStore.use.updateToolCallInvokeStatus();
|
||||
const getSessionChat = useCallback((sessionId?: string) => chat.for(sessionId).getState(), [chat]);
|
||||
const getConversationModel = useCallback(
|
||||
(messages: Message[], services: Awaited<ReturnType<typeof aiConfigRepository.getLLMServices>>) => {
|
||||
@@ -152,21 +136,22 @@ export const useChatMessageActions = () => {
|
||||
);
|
||||
const ensureModelFromStore = useCallback(
|
||||
async (username?: string) => {
|
||||
const state = useChatBoxStore.getState();
|
||||
const targetUsername = username || state.currentEmployee?.username;
|
||||
const targetUsername = username || chatBoxModel.currentEmployee?.username;
|
||||
if (!targetUsername) {
|
||||
return state.model;
|
||||
return chatBoxModel.model;
|
||||
}
|
||||
return ensureModel({
|
||||
api,
|
||||
aiConfigRepository,
|
||||
aiEmployee:
|
||||
state.currentEmployee?.username === targetUsername ? state.currentEmployee : { username: targetUsername },
|
||||
currentOverride: state.model,
|
||||
onResolved: setModel,
|
||||
chatBoxModel.currentEmployee?.username === targetUsername
|
||||
? chatBoxModel.currentEmployee
|
||||
: { username: targetUsername },
|
||||
currentOverride: chatBoxModel.model,
|
||||
onResolved: chatBoxModel.setModel,
|
||||
});
|
||||
},
|
||||
[api, aiConfigRepository, setModel],
|
||||
[api, aiConfigRepository, chatBoxModel],
|
||||
);
|
||||
|
||||
const extractContextAttachments = useCallback(
|
||||
@@ -204,12 +189,12 @@ export const useChatMessageActions = () => {
|
||||
(items: ContextItem | ContextItem[]) => {
|
||||
const attachments = extractContextAttachments(items);
|
||||
if (attachments.length) {
|
||||
const sessionChat = getSessionChat(useChatConversationsStore.getState().currentConversation);
|
||||
const sessionChat = getSessionChat(chatConversationModel.currentConversation);
|
||||
sessionChat.addAttachments(attachments);
|
||||
}
|
||||
return attachments;
|
||||
},
|
||||
[extractContextAttachments, getSessionChat],
|
||||
[chatConversationModel, extractContextAttachments, getSessionChat],
|
||||
);
|
||||
|
||||
const loadMessages = useCallback(
|
||||
@@ -221,16 +206,17 @@ export const useChatMessageActions = () => {
|
||||
sessionChat.setMessagesLoading(true);
|
||||
sessionChat.setMessagesError(null);
|
||||
try {
|
||||
const activeConversation = useChatConversationsStore.getState().currentConversation;
|
||||
const chatBoxOpen = useChatBoxStore.getState().open;
|
||||
const activeConversation = chatConversationModel.currentConversation;
|
||||
const shouldUpdateRead =
|
||||
sessionId === activeConversation && (chatBoxModel.open || resolvedRuntime.mode === 'block');
|
||||
const res = await api.resource('aiConversations').getMessages({
|
||||
sessionId,
|
||||
cursor,
|
||||
paginate: false,
|
||||
updateRead: sessionId === activeConversation && chatBoxOpen,
|
||||
updateRead: shouldUpdateRead,
|
||||
});
|
||||
if (sessionId === activeConversation && chatBoxOpen) {
|
||||
markConversationRead(sessionId);
|
||||
if (shouldUpdateRead) {
|
||||
chatConversationModel.markConversationRead(sessionId);
|
||||
}
|
||||
|
||||
const data = res?.data as MessagesResponse | undefined;
|
||||
@@ -242,10 +228,10 @@ export const useChatMessageActions = () => {
|
||||
const services = !cursor ? await aiConfigRepository.getLLMServices() : [];
|
||||
const conversationModel = !cursor ? getConversationModel(newMessages, services) : null;
|
||||
if (conversationModel) {
|
||||
const currentModel = useChatBoxStore.getState().model;
|
||||
const currentModel = chatBoxModel.model;
|
||||
const allModels = getAllModels(services);
|
||||
if (isValidModel(conversationModel, allModels) && !isSameModel(currentModel, conversationModel)) {
|
||||
setModel(conversationModel);
|
||||
chatBoxModel.setModel(conversationModel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +241,7 @@ export const useChatMessageActions = () => {
|
||||
if (toolCalls?.length) {
|
||||
for (const tc of toolCalls) {
|
||||
if (tc.willInterrupt) {
|
||||
updateToolCallInvokeStatus(sessionId, msg.content.messageId, tc.id, tc.invokeStatus);
|
||||
chatToolCallModel.updateToolCallInvokeStatus(sessionId, msg.content.messageId, tc.id, tc.invokeStatus);
|
||||
}
|
||||
if (tc.invokeStatus === 'done' || tc.invokeStatus === 'confirmed') {
|
||||
const contentStr = typeof tc.content === 'string' ? tc.content : JSON.stringify(tc.content);
|
||||
@@ -282,7 +268,7 @@ export const useChatMessageActions = () => {
|
||||
}
|
||||
for (const tc of subToolCalls) {
|
||||
if (tc.willInterrupt) {
|
||||
updateToolCallInvokeStatus(sessionId, subMessageId, tc.id, tc.invokeStatus);
|
||||
chatToolCallModel.updateToolCallInvokeStatus(sessionId, subMessageId, tc.id, tc.invokeStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,11 +294,12 @@ export const useChatMessageActions = () => {
|
||||
[
|
||||
api,
|
||||
aiConfigRepository,
|
||||
chatBoxModel,
|
||||
chatToolCallModel,
|
||||
chatConversationModel,
|
||||
getConversationModel,
|
||||
getSessionChat,
|
||||
markConversationRead,
|
||||
setModel,
|
||||
updateToolCallInvokeStatus,
|
||||
resolvedRuntime.mode,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -327,7 +314,12 @@ export const useChatMessageActions = () => {
|
||||
);
|
||||
|
||||
const processStreamResponse = useCallback(
|
||||
async (stream: ReadableStream<Uint8Array>, sessionId: string, aiEmployee: AIEmployee) => {
|
||||
async (
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
sessionId: string,
|
||||
aiEmployee: AIEmployee,
|
||||
onResponseLoadingChange?: (loading: boolean) => void,
|
||||
) => {
|
||||
const sessionChat = getSessionChat(sessionId);
|
||||
sessionChat.setBackgroundWorking(false);
|
||||
const reader = stream.getReader();
|
||||
@@ -537,7 +529,12 @@ export const useChatMessageActions = () => {
|
||||
if (body?.toolCall) {
|
||||
const { toolCall, invokeStatus } = body;
|
||||
if (toolCall.willInterrupt) {
|
||||
updateToolCallInvokeStatus(sessionId, toolCall.messageId, toolCall.id, String(invokeStatus));
|
||||
chatToolCallModel.updateToolCallInvokeStatus(
|
||||
sessionId,
|
||||
toolCall.messageId,
|
||||
toolCall.id,
|
||||
String(invokeStatus),
|
||||
);
|
||||
}
|
||||
}
|
||||
store.updateLast((last) => {
|
||||
@@ -614,6 +611,7 @@ export const useChatMessageActions = () => {
|
||||
if (done || error) {
|
||||
flushAllPendingStreamUpdates();
|
||||
sessionChat.setResponseLoading(false);
|
||||
onResponseLoadingChange?.(false);
|
||||
sessionChat.setWebSearching(null);
|
||||
break;
|
||||
}
|
||||
@@ -683,11 +681,14 @@ export const useChatMessageActions = () => {
|
||||
if (getErrorName(err) === 'AbortError') {
|
||||
clearAllPendingStreamUpdates();
|
||||
sessionChat.setResponseLoading(false);
|
||||
onResponseLoadingChange?.(false);
|
||||
sessionChat.setWebSearching(null);
|
||||
return;
|
||||
}
|
||||
error = true;
|
||||
result = getErrorMessage(err);
|
||||
sessionChat.setResponseLoading(false);
|
||||
onResponseLoadingChange?.(false);
|
||||
|
||||
aiDebugLogger.log(sessionId, 'error', {
|
||||
message: getErrorMessage(err),
|
||||
@@ -710,7 +711,7 @@ export const useChatMessageActions = () => {
|
||||
|
||||
await loadMessages(sessionId);
|
||||
},
|
||||
[getSessionChat, loadMessages, t, updateToolCallInvokeStatus],
|
||||
[chatToolCallModel, getSessionChat, loadMessages, t],
|
||||
);
|
||||
|
||||
const sendMessages = async ({
|
||||
@@ -724,18 +725,29 @@ export const useChatMessageActions = () => {
|
||||
onConversationCreate,
|
||||
skillSettings,
|
||||
webSearch,
|
||||
scope,
|
||||
model: inputModel,
|
||||
onResponseLoadingChange,
|
||||
}: SendOptions & {
|
||||
scope?: string;
|
||||
onConversationCreate?: (sessionId: string) => void;
|
||||
}) => {
|
||||
if (!sendMsgs.length) return;
|
||||
let lastNotifiedResponseLoading: boolean | undefined;
|
||||
const notifyResponseLoadingChange = (loading: boolean) => {
|
||||
if (lastNotifiedResponseLoading === loading) {
|
||||
return;
|
||||
}
|
||||
lastNotifiedResponseLoading = loading;
|
||||
onResponseLoadingChange?.(loading);
|
||||
};
|
||||
const draftSessionId = sessionId;
|
||||
let targetSessionId = sessionId;
|
||||
let sessionChat = getSessionChat(targetSessionId);
|
||||
sessionChat.setBackgroundWorking(false);
|
||||
|
||||
// Read model from store at call time to avoid stale closure
|
||||
const model = inputModel ?? useChatBoxStore.getState().model;
|
||||
const model = inputModel ?? chatBoxModel.model;
|
||||
|
||||
// [AI_DEBUG] request
|
||||
aiDebugLogger.log(
|
||||
@@ -768,7 +780,7 @@ export const useChatMessageActions = () => {
|
||||
attachments: index === 0 ? attachments : undefined,
|
||||
workContext: index === 0 ? parsedWorkContext : undefined,
|
||||
}));
|
||||
if (lastRenderedMessage?.type === 'conversation-group' && !isEditingMessage) {
|
||||
if (lastRenderedMessage?.type === 'conversation-group' && !chatSenderModel.isEditingMessage) {
|
||||
sessionChat.addSubAgentMessages(
|
||||
lastRenderedMessage.key,
|
||||
sendMsgs.map((msg, index) => ({
|
||||
@@ -801,6 +813,7 @@ export const useChatMessageActions = () => {
|
||||
aiEmployee,
|
||||
systemMessage,
|
||||
skillSettings,
|
||||
scope,
|
||||
modelSettings: model
|
||||
? {
|
||||
llmService: model.llmService,
|
||||
@@ -815,15 +828,16 @@ export const useChatMessageActions = () => {
|
||||
targetSessionId = sessionId;
|
||||
chat.for(draftSessionId).migrateSessionState(sessionId);
|
||||
if (draftSessionId) {
|
||||
useChatToolCallStore.getState().migrateSessionState(draftSessionId, sessionId);
|
||||
chatToolCallModel.migrateSessionState(draftSessionId, sessionId);
|
||||
}
|
||||
sessionChat = getSessionChat(sessionId);
|
||||
onConversationCreate?.(sessionId);
|
||||
}
|
||||
sessionChat.setWebSearching(null);
|
||||
sessionChat.setResponseLoading(true);
|
||||
notifyResponseLoadingChange(true);
|
||||
|
||||
if (lastRenderedMessage?.type === 'conversation-group' && !isEditingMessage) {
|
||||
if (lastRenderedMessage?.type === 'conversation-group' && !chatSenderModel.isEditingMessage) {
|
||||
sessionChat.addSubAgentMessage(lastRenderedMessage.key, {
|
||||
key: randomId(),
|
||||
role: lastRenderedMessage.roleName,
|
||||
@@ -863,17 +877,20 @@ export const useChatMessageActions = () => {
|
||||
|
||||
if (!sendRes?.data) {
|
||||
sessionChat.setResponseLoading(false);
|
||||
notifyResponseLoadingChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await processStreamResponse(sendRes.data, sessionId, aiEmployee);
|
||||
await processStreamResponse(sendRes.data, sessionId, aiEmployee, notifyResponseLoadingChange);
|
||||
} catch (err) {
|
||||
if (getErrorName(err) === 'CanceledError') {
|
||||
sessionChat.setResponseLoading(false);
|
||||
notifyResponseLoadingChange(false);
|
||||
sessionChat.setWebSearching(null);
|
||||
return;
|
||||
}
|
||||
sessionChat.setResponseLoading(false);
|
||||
notifyResponseLoadingChange(false);
|
||||
throw err;
|
||||
} finally {
|
||||
sessionChat.setAbortController(null);
|
||||
@@ -901,7 +918,7 @@ export const useChatMessageActions = () => {
|
||||
|
||||
// Read model from store at call time to avoid stale closure.
|
||||
// If not ready yet, resolve it through shared model rules.
|
||||
let model = useChatBoxStore.getState().model;
|
||||
let model = chatBoxModel.model;
|
||||
if (!model) {
|
||||
model = await ensureModelFromStore(aiEmployee?.username);
|
||||
}
|
||||
@@ -913,7 +930,7 @@ export const useChatMessageActions = () => {
|
||||
url: 'aiConversations:resendMessages',
|
||||
method: 'POST',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
data: { sessionId, messageId, model, important, webSearch: currentWebSearch },
|
||||
data: { sessionId, messageId, model, important, webSearch: chatConversationModel.webSearch },
|
||||
responseType: 'stream',
|
||||
adapter: 'fetch',
|
||||
signal: controller?.signal,
|
||||
@@ -992,22 +1009,24 @@ export const useChatMessageActions = () => {
|
||||
);
|
||||
|
||||
const cancelRequest = useCallback(async () => {
|
||||
const controller = abortController;
|
||||
const activeConversation = chatConversationModel.currentConversation;
|
||||
const sessionChat = getSessionChat(activeConversation);
|
||||
const controller = sessionChat.abortController;
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
controller.abort();
|
||||
setAbortController(null);
|
||||
sessionChat.setAbortController(null);
|
||||
await api.resource('aiConversations').abort({
|
||||
values: {
|
||||
sessionId: currentConversation,
|
||||
sessionId: activeConversation,
|
||||
},
|
||||
});
|
||||
// sleep(500)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
loadMessages(currentConversation);
|
||||
setResponseLoading(false);
|
||||
}, [abortController, api, currentConversation, loadMessages, setAbortController, setResponseLoading]);
|
||||
await loadMessages(activeConversation);
|
||||
sessionChat.setResponseLoading(false);
|
||||
}, [api, chatConversationModel, getSessionChat, loadMessages]);
|
||||
|
||||
const resumeToolCall = useCallback(
|
||||
async ({
|
||||
@@ -1029,7 +1048,7 @@ export const useChatMessageActions = () => {
|
||||
sessionChat.setResponseLoading(true);
|
||||
// Read model from store at call time to avoid stale closure.
|
||||
// If not ready yet, resolve it through shared model rules.
|
||||
let model = useChatBoxStore.getState().model;
|
||||
let model = chatBoxModel.model;
|
||||
if (!model) {
|
||||
model = await ensureModelFromStore(aiEmployee?.username);
|
||||
}
|
||||
@@ -1040,7 +1059,14 @@ export const useChatMessageActions = () => {
|
||||
url: 'aiConversations:resumeToolCall',
|
||||
method: 'POST',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
data: { sessionId, messageId, toolCallIds, toolCallResults, model, webSearch: currentWebSearch },
|
||||
data: {
|
||||
sessionId,
|
||||
messageId,
|
||||
toolCallIds,
|
||||
toolCallResults,
|
||||
model,
|
||||
webSearch: chatConversationModel.webSearch,
|
||||
},
|
||||
responseType: 'stream',
|
||||
adapter: 'fetch',
|
||||
signal: controller?.signal,
|
||||
@@ -1064,7 +1090,7 @@ export const useChatMessageActions = () => {
|
||||
sessionChat.setAbortController(null);
|
||||
}
|
||||
},
|
||||
[api, currentWebSearch, ensureModelFromStore, getSessionChat, processStreamResponse],
|
||||
[api, chatBoxModel.model, chatConversationModel, ensureModelFromStore, getSessionChat, processStreamResponse],
|
||||
);
|
||||
|
||||
const loadMoreMessages = useCallback(async () => {
|
||||
@@ -1092,27 +1118,31 @@ export const useChatMessageActions = () => {
|
||||
|
||||
const startEditingMessage = useCallback(
|
||||
(msg: { messageId: string; attachments?: Attachment[]; workContext?: ContextItem[] }) => {
|
||||
const currentMessages = messages;
|
||||
const activeConversation = chatConversationModel.currentConversation;
|
||||
const sessionChat = getSessionChat(activeConversation);
|
||||
const currentMessages = sessionChat.messages;
|
||||
const index = currentMessages.findIndex((m) => m.key === msg.messageId);
|
||||
setIsEditingMessage(true);
|
||||
setEditingMessageId(msg.messageId);
|
||||
setMessages(currentMessages.slice(0, index));
|
||||
chatSenderModel.setIsEditingMessage(true);
|
||||
chatSenderModel.setEditingMessageId(msg.messageId);
|
||||
sessionChat.setMessages(currentMessages.slice(0, index));
|
||||
if (msg.attachments) {
|
||||
setAttachments(msg.attachments);
|
||||
sessionChat.setAttachments(msg.attachments);
|
||||
}
|
||||
if (msg.workContext) {
|
||||
setContextItems(msg.workContext);
|
||||
sessionChat.setContextItems(msg.workContext);
|
||||
}
|
||||
},
|
||||
[messages, setAttachments, setContextItems, setEditingMessageId, setIsEditingMessage, setMessages],
|
||||
[chatConversationModel, chatSenderModel, getSessionChat],
|
||||
);
|
||||
|
||||
const finishEditingMessage = useCallback(() => {
|
||||
setIsEditingMessage(false);
|
||||
setEditingMessageId(undefined);
|
||||
setAttachments([]);
|
||||
setContextItems([]);
|
||||
}, [setAttachments, setContextItems, setEditingMessageId, setIsEditingMessage]);
|
||||
const activeConversation = chatConversationModel.currentConversation;
|
||||
const sessionChat = getSessionChat(activeConversation);
|
||||
chatSenderModel.setIsEditingMessage(false);
|
||||
chatSenderModel.setEditingMessageId(undefined);
|
||||
sessionChat.setAttachments([]);
|
||||
sessionChat.setContextItems([]);
|
||||
}, [chatConversationModel, chatSenderModel, getSessionChat]);
|
||||
|
||||
return {
|
||||
syncContextAttachments,
|
||||
|
||||
+15
-15
@@ -8,24 +8,19 @@
|
||||
*/
|
||||
|
||||
import { DecisionActions, ToolCall, useApp } from '@nocobase/client-v2';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import { useChatMessageActions } from './useChatMessageActions';
|
||||
import { UserDecision } from '../../types';
|
||||
import { useChatToolCallStore } from '../stores/chat-tool-call';
|
||||
import { type ChatBoxRuntime, useResolvedChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
const getErrorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
export const useToolCallActions = ({ messageId }: { messageId: string }) => {
|
||||
export const useToolCallActions = ({ messageId, runtime }: { messageId: string; runtime?: ChatBoxRuntime }) => {
|
||||
const app = useApp();
|
||||
const api = app.apiClient;
|
||||
const sessionId = useChatConversationsStore.use.currentConversation();
|
||||
const aiEmployee = useChatBoxStore.use.currentEmployee();
|
||||
const updateToolCallInvokeStatus = useChatToolCallStore.use.updateToolCallInvokeStatus();
|
||||
const getInvokeStatus = useChatToolCallStore.use.getInvokeStatus();
|
||||
const isAllWaiting = useChatToolCallStore.use.isAllWaiting();
|
||||
const isInterrupted = useChatToolCallStore.use.isInterrupted();
|
||||
const { resumeToolCall } = useChatMessageActions();
|
||||
const resolvedRuntime = useResolvedChatBoxRuntime(runtime);
|
||||
const { chatBoxModel, chatConversationModel, chatToolCallModel } = resolvedRuntime;
|
||||
const sessionId = chatConversationModel.currentConversation;
|
||||
const { resumeToolCall } = useChatMessageActions(resolvedRuntime);
|
||||
|
||||
const { toolsManager } = app.aiManager;
|
||||
const toolsMap = toolsManager.useTools();
|
||||
@@ -35,8 +30,8 @@ export const useToolCallActions = ({ messageId }: { messageId: string }) => {
|
||||
throw new Error('sessionId is required to update tool call user decision');
|
||||
}
|
||||
|
||||
if (!isInterrupted(sessionId, messageId, toolCallId)) {
|
||||
const invokeStatus = getInvokeStatus(sessionId, messageId, toolCallId);
|
||||
if (!chatToolCallModel.isInterrupted(sessionId, messageId, toolCallId)) {
|
||||
const invokeStatus = chatToolCallModel.getInvokeStatus(sessionId, messageId, toolCallId);
|
||||
console.warn('tool call invokeStatus is not interrupted', {
|
||||
sessionId,
|
||||
messageId,
|
||||
@@ -53,8 +48,8 @@ export const useToolCallActions = ({ messageId }: { messageId: string }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
updateToolCallInvokeStatus(sessionId, messageId, toolCallId, 'waiting');
|
||||
if (!isAllWaiting(sessionId, messageId)) {
|
||||
chatToolCallModel.updateToolCallInvokeStatus(sessionId, messageId, toolCallId, 'waiting');
|
||||
if (!chatToolCallModel.isAllWaiting(sessionId, messageId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,6 +78,11 @@ export const useToolCallActions = ({ messageId }: { messageId: string }) => {
|
||||
toolCallIds.push(toolCall.id);
|
||||
}
|
||||
|
||||
const aiEmployee = chatBoxModel.currentEmployee;
|
||||
if (!aiEmployee) {
|
||||
return;
|
||||
}
|
||||
|
||||
await resumeToolCall({ sessionId, messageId, aiEmployee, toolCallIds, toolCallResults });
|
||||
};
|
||||
|
||||
|
||||
+31
-21
@@ -10,9 +10,9 @@
|
||||
import { useApp } from '@nocobase/client-v2';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { useChat } from '../hooks/useChat';
|
||||
import { useChatConversationsStore } from '../stores/chat-conversations';
|
||||
import type { Attachment } from '../../types';
|
||||
import { normalizeAIFileUploadAttachment } from '../utils';
|
||||
import { uploadAIFile } from '../upload';
|
||||
import { type ChatBoxRuntime, useResolvedChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
type StorageBasicInfo = {
|
||||
rules?: Record<string, unknown>;
|
||||
@@ -26,6 +26,7 @@ type UploadRequestOptions = {
|
||||
filename: string;
|
||||
headers?: Record<string, string>;
|
||||
onError: (error: Error) => void;
|
||||
onProgress?: (event: { percent: number }) => void;
|
||||
onSuccess: (body: unknown, file: Blob) => void;
|
||||
withCredentials?: boolean;
|
||||
};
|
||||
@@ -80,33 +81,42 @@ export function useUploadProps(props: Record<string, unknown>) {
|
||||
filename,
|
||||
headers,
|
||||
onError,
|
||||
onProgress,
|
||||
onSuccess,
|
||||
withCredentials,
|
||||
}: UploadRequestOptions) {
|
||||
const formData = new FormData();
|
||||
if (data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
formData.append(key, data[key]);
|
||||
const controller = new AbortController();
|
||||
uploadAIFile(app.apiClient, file, {
|
||||
action,
|
||||
data,
|
||||
fieldName: filename,
|
||||
headers,
|
||||
onProgress: (percent) => {
|
||||
onProgress?.({ percent });
|
||||
},
|
||||
signal: controller.signal,
|
||||
withCredentials,
|
||||
})
|
||||
.then((attachment) => {
|
||||
onSuccess({ data: attachment }, file);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
onError(error instanceof Error ? error : new Error('AI file upload failed.'));
|
||||
});
|
||||
}
|
||||
formData.append(filename, file);
|
||||
return app.apiClient.axios
|
||||
.post(action, formData, {
|
||||
withCredentials,
|
||||
headers,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
onSuccess(data, file);
|
||||
})
|
||||
.catch((e) => onError(new Error(e.message)));
|
||||
return {
|
||||
abort() {
|
||||
controller.abort();
|
||||
},
|
||||
};
|
||||
},
|
||||
...props,
|
||||
};
|
||||
}
|
||||
|
||||
export const useUploadFiles = () => {
|
||||
const currentConversation = useChatConversationsStore.use.currentConversation();
|
||||
const chat = useChat(currentConversation);
|
||||
export const useUploadFiles = (runtime?: ChatBoxRuntime) => {
|
||||
const resolvedRuntime = useResolvedChatBoxRuntime(runtime);
|
||||
const currentConversation = resolvedRuntime.chatConversationModel.currentConversation;
|
||||
const chat = useChat(currentConversation, resolvedRuntime);
|
||||
const setAttachments = chat.setAttachments;
|
||||
|
||||
const uploadProps = {
|
||||
@@ -118,7 +128,7 @@ export const useUploadFiles = () => {
|
||||
if (!file?.response?.data) {
|
||||
return file;
|
||||
}
|
||||
return normalizeAIFileUploadAttachment(file.response.data, file.status);
|
||||
return file.response.data;
|
||||
}
|
||||
return file;
|
||||
}),
|
||||
|
||||
+26
-33
@@ -10,9 +10,9 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useApp } from '@nocobase/client-v2';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { useWorkflowTasksStore, WorkflowTask, WorkflowTaskDetail } from '../stores/workflow-tasks';
|
||||
import { WorkflowTask, WorkflowTaskDetail } from '../stores/workflow-tasks';
|
||||
import { useLoadMoreObserver } from './useLoadMoreObserver';
|
||||
import { useChatBoxStore } from '../stores/chat-box';
|
||||
import { type ChatBoxRuntime, useResolvedChatBoxRuntime } from '../stores/runtime';
|
||||
|
||||
const JOB_STATUS = {
|
||||
PENDING: 0,
|
||||
@@ -28,24 +28,18 @@ const aiWorkflowTaskStatusMap: Record<number, string[]> = {
|
||||
[JOB_STATUS.ABORTED]: ['aborted'],
|
||||
};
|
||||
|
||||
export const useWorkflowTasks = () => {
|
||||
export const useWorkflowTasks = (runtime?: ChatBoxRuntime) => {
|
||||
const app = useApp();
|
||||
const api = app.apiClient;
|
||||
const workflowTasksResource = api.resource('aiWorkflowTasks');
|
||||
const { chatBoxModel, workflowTaskModel } = useResolvedChatBoxRuntime(runtime);
|
||||
|
||||
const workflowTasks = useWorkflowTasksStore.use.workflowTasks();
|
||||
const currentWorkflowTask = useWorkflowTasksStore.use.currentWorkflowTask();
|
||||
const unreadCount = useWorkflowTasksStore.use.unreadCount();
|
||||
const loading = useWorkflowTasksStore.use.loading();
|
||||
const keyword = useWorkflowTasksStore.use.keyword();
|
||||
const selectedJobStatus = useWorkflowTasksStore.use.selectedJobStatus();
|
||||
|
||||
const setWorkflowTasks = useWorkflowTasksStore.use.setWorkflowTasks();
|
||||
const setCurrentWorkflowTask = useWorkflowTasksStore.use.setCurrentWorkflowTask();
|
||||
const setLoading = useWorkflowTasksStore.use.setLoading();
|
||||
const setKeyword = useWorkflowTasksStore.use.setKeyword();
|
||||
const setSelectedJobStatus = useWorkflowTasksStore.use.setSelectedJobStatus();
|
||||
const markWorkflowTaskRead = useWorkflowTasksStore.use.markWorkflowTaskRead();
|
||||
const workflowTasks = workflowTaskModel.workflowTasks;
|
||||
const currentWorkflowTask = workflowTaskModel.currentWorkflowTask;
|
||||
const unreadCount = workflowTaskModel.unreadCount;
|
||||
const loading = workflowTaskModel.loading;
|
||||
const keyword = workflowTaskModel.keyword;
|
||||
const selectedJobStatus = workflowTaskModel.selectedJobStatus;
|
||||
const keywordRef = useRef(keyword);
|
||||
keywordRef.current = keyword;
|
||||
const selectedJobStatusRef = useRef(selectedJobStatus);
|
||||
@@ -75,7 +69,7 @@ export const useWorkflowTasks = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
workflowTaskModel.setLoading(true);
|
||||
const res = await workflowTasksResource.list({
|
||||
sort: ['-updatedAt'],
|
||||
page,
|
||||
@@ -86,7 +80,7 @@ export const useWorkflowTasks = () => {
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
workflowTaskModel.setLoading(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -96,11 +90,11 @@ export const useWorkflowTasks = () => {
|
||||
const nextData = data?.data || [];
|
||||
|
||||
if (page === 1) {
|
||||
setWorkflowTasks(nextData);
|
||||
workflowTaskModel.setWorkflowTasks(nextData);
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkflowTasks((prev) => {
|
||||
workflowTaskModel.setWorkflowTasks((prev) => {
|
||||
const prevSessionIds = new Set(prev.map((item) => item.sessionId));
|
||||
const appended = nextData.filter((item) => !prevSessionIds.has(item.sessionId));
|
||||
return [...prev, ...appended];
|
||||
@@ -114,10 +108,10 @@ export const useWorkflowTasks = () => {
|
||||
|
||||
const runSearch = useCallback(
|
||||
(nextKeyword = '') => {
|
||||
setKeyword(nextKeyword);
|
||||
workflowTaskModel.setKeyword(nextKeyword);
|
||||
workflowTasksService.run(1, nextKeyword, selectedJobStatus);
|
||||
},
|
||||
[selectedJobStatus, setKeyword, workflowTasksService],
|
||||
[selectedJobStatus, workflowTaskModel, workflowTasksService],
|
||||
);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
@@ -126,10 +120,10 @@ export const useWorkflowTasks = () => {
|
||||
|
||||
const runJobStatusFilter = useCallback(
|
||||
(nextJobStatus?: number) => {
|
||||
setSelectedJobStatus(nextJobStatus);
|
||||
workflowTaskModel.setSelectedJobStatus(nextJobStatus);
|
||||
workflowTasksService.run(1, keyword || '', nextJobStatus);
|
||||
},
|
||||
[keyword, setSelectedJobStatus, workflowTasksService],
|
||||
[keyword, workflowTaskModel, workflowTasksService],
|
||||
);
|
||||
|
||||
const loadMoreWorkflowTasks = useCallback(async () => {
|
||||
@@ -158,9 +152,9 @@ export const useWorkflowTasks = () => {
|
||||
},
|
||||
})
|
||||
.catch(() => undefined);
|
||||
markWorkflowTaskRead(sessionId);
|
||||
workflowTaskModel.markWorkflowTaskRead(sessionId);
|
||||
},
|
||||
[markWorkflowTaskRead, workflowTasksResource],
|
||||
[workflowTaskModel, workflowTasksResource],
|
||||
);
|
||||
|
||||
const getWorkflowTaskBySession = useCallback(
|
||||
@@ -171,20 +165,19 @@ export const useWorkflowTasks = () => {
|
||||
},
|
||||
});
|
||||
const task = (res?.data?.data ?? res?.data) as WorkflowTaskDetail | undefined;
|
||||
setCurrentWorkflowTask(task);
|
||||
workflowTaskModel.setCurrentWorkflowTask(task);
|
||||
return task;
|
||||
},
|
||||
[setCurrentWorkflowTask, workflowTasksResource],
|
||||
[workflowTaskModel, workflowTasksResource],
|
||||
);
|
||||
|
||||
const setReadonly = useChatBoxStore.use.setReadonly();
|
||||
const updateReadonly = useCallback(
|
||||
async (sessionId: string) => {
|
||||
await acceptWorkflowTask(sessionId);
|
||||
const task = await getWorkflowTaskBySession(sessionId);
|
||||
setReadonly(task?.readonly === true);
|
||||
chatBoxModel.setReadonly(task?.readonly === true);
|
||||
},
|
||||
[acceptWorkflowTask, getWorkflowTaskBySession, setReadonly],
|
||||
[acceptWorkflowTask, chatBoxModel, getWorkflowTaskBySession],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -202,7 +195,7 @@ export const useWorkflowTasks = () => {
|
||||
getWorkflowTaskBySession,
|
||||
updateReadonly,
|
||||
currentWorkflowTask,
|
||||
setCurrentWorkflowTask,
|
||||
setSelectedJobStatus,
|
||||
setCurrentWorkflowTask: workflowTaskModel.setCurrentWorkflowTask,
|
||||
setSelectedJobStatus: workflowTaskModel.setSelectedJobStatus,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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 { Conversations } from './components/Conversations';
|
||||
export { ChatBoxUnreadBadge } from './components/ChatBoxUnreadBadge';
|
||||
export { Messages } from './components/Messages';
|
||||
export { useChat } from './hooks/useChat';
|
||||
export { useChatBoxActions } from './hooks/useChatBoxActions';
|
||||
export { useChatConversationActions } from './hooks/useChatConversationActions';
|
||||
export { useChatMessageActions } from './hooks/useChatMessageActions';
|
||||
export { registerMountedChatBox } from './stores/mounted-chat-boxes';
|
||||
export { useChatBoxRuntime } from './stores/runtime';
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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 from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatButton } from '../../components/ChatButton';
|
||||
import { ChatBoxRuntimeProvider, createChatBoxRuntime, type ChatBoxRuntime } from '../runtime';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getAIEmployees: vi.fn(),
|
||||
setResponseLoading: vi.fn(),
|
||||
switchAIEmployee: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@nocobase/flow-engine', async () => {
|
||||
const actual = await vi.importActual<typeof import('@nocobase/flow-engine')>('@nocobase/flow-engine');
|
||||
return {
|
||||
...actual,
|
||||
useFlowContext: () => ({
|
||||
pageInfo: {
|
||||
version: 'v2',
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useLocation: () => ({
|
||||
pathname: '/admin',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../locale', () => ({
|
||||
useT: () => (text: string) => text,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../repositories/hooks/useAIConfigRepository', () => ({
|
||||
useAIConfigRepository: () => ({
|
||||
aiEmployees: [
|
||||
{
|
||||
username: 'atlas',
|
||||
nickname: 'Atlas',
|
||||
builtIn: true,
|
||||
},
|
||||
],
|
||||
getAIEmployees: mocks.getAIEmployees,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useChat', () => ({
|
||||
useChat: () => ({
|
||||
setResponseLoading: mocks.setResponseLoading,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useChatBoxActions', () => ({
|
||||
useChatBoxActions: () => ({
|
||||
switchAIEmployee: mocks.switchAIEmployee,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useChatConversationActions', () => ({
|
||||
useChatConversationActions: () => ({
|
||||
unreadCount: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useWorkflowTasks', () => ({
|
||||
useWorkflowTasks: () => ({
|
||||
unreadCount: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderWithRuntime = (runtime: ChatBoxRuntime) => {
|
||||
return render(
|
||||
<ChatBoxRuntimeProvider runtime={runtime}>
|
||||
<ChatButton />
|
||||
</ChatBoxRuntimeProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('global chatbox behavior', () => {
|
||||
beforeEach(() => {
|
||||
mocks.getAIEmployees.mockResolvedValue(undefined);
|
||||
mocks.setResponseLoading.mockClear();
|
||||
mocks.switchAIEmployee.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mocks.getAIEmployees.mockReset();
|
||||
});
|
||||
|
||||
it('opens the global runtime and selects the leader employee from ChatButton', () => {
|
||||
const runtime = createChatBoxRuntime();
|
||||
|
||||
renderWithRuntime(runtime);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open AI chat' }));
|
||||
|
||||
expect(runtime.chatBoxModel.open).toBe(true);
|
||||
expect(runtime.chatBoxModel.readonly).toBe(false);
|
||||
expect(mocks.setResponseLoading).toHaveBeenCalledWith(false);
|
||||
expect(mocks.switchAIEmployee).toHaveBeenCalledWith({
|
||||
username: 'atlas',
|
||||
nickname: 'Atlas',
|
||||
builtIn: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* 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 { autorun } from '@nocobase/flow-engine';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AIEmployee, ContextItem, Conversation, Message, ToolCall } from '../../../types';
|
||||
import { ChatBoxModel } from '../chat-box';
|
||||
import { ChatConversationModel } from '../chat-conversations';
|
||||
import { ChatMessageModel, CHAT_DEFAULT_SESSION_KEY } from '../chat-messages';
|
||||
import { ChatSenderModel } from '../chat-sender';
|
||||
import { ChatToolCallModel } from '../chat-tool-call';
|
||||
import { ChatToolModel } from '../chat-tools';
|
||||
import { WorkflowTaskModel, type WorkflowTask } from '../workflow-tasks';
|
||||
|
||||
const employee = (username: string): AIEmployee => ({
|
||||
username,
|
||||
nickname: username,
|
||||
});
|
||||
|
||||
const textMessage = (key: string, content = key): Message => ({
|
||||
key,
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
content,
|
||||
messageId: key,
|
||||
},
|
||||
});
|
||||
|
||||
const contextItem = (type: string, uid: string): ContextItem => ({
|
||||
type,
|
||||
uid,
|
||||
});
|
||||
|
||||
const conversation = (sessionId: string, read = false): Conversation => ({
|
||||
sessionId,
|
||||
title: sessionId,
|
||||
updatedAt: '2026-07-16T00:00:00.000Z',
|
||||
aiEmployee: employee('atlas'),
|
||||
read,
|
||||
});
|
||||
|
||||
const toolCall = (id: string, name: string): ToolCall<unknown> => ({
|
||||
id,
|
||||
type: 'function',
|
||||
name,
|
||||
invokeStatus: 'done',
|
||||
auto: false,
|
||||
args: {},
|
||||
});
|
||||
|
||||
const messageWithToolCalls = (messageId: string, toolCalls: ToolCall<unknown>[]): Message => ({
|
||||
key: messageId,
|
||||
role: 'assistant',
|
||||
content: {
|
||||
type: 'text',
|
||||
content: '',
|
||||
messageId,
|
||||
tool_calls: toolCalls,
|
||||
},
|
||||
});
|
||||
|
||||
const workflowTask = (sessionId: string, status = 'pending'): WorkflowTask => ({
|
||||
id: sessionId,
|
||||
sessionId,
|
||||
workflowTitle: 'Workflow',
|
||||
nodeTitle: 'Node',
|
||||
status,
|
||||
});
|
||||
|
||||
describe('chatbox runtime models', () => {
|
||||
it('updates ChatBoxModel UI instance state with existing action semantics', () => {
|
||||
const model = new ChatBoxModel();
|
||||
const setOpen = model.setOpen;
|
||||
|
||||
model.setCollapsed(true);
|
||||
model.setOpen(false);
|
||||
expect(model.open).toBe(false);
|
||||
expect(model.collapsed).toBe(false);
|
||||
|
||||
model.setCollapsed(true);
|
||||
model.setExpanded(true);
|
||||
expect(model.expanded).toBe(true);
|
||||
expect(model.collapsed).toBe(false);
|
||||
|
||||
model.setCurrentEmployee(employee('atlas'));
|
||||
model.setCurrentEmployee((previous) => ({
|
||||
...(previous ?? employee('atlas')),
|
||||
nickname: 'Atlas updated',
|
||||
}));
|
||||
model.setSenderValue('hello');
|
||||
model.setTaskVariables({ variables: { recordId: 1 } });
|
||||
model.addRole('atlas', { placement: 'start' });
|
||||
model.setModel({ llmService: 'openai', model: 'gpt' });
|
||||
model.setReadonly(true);
|
||||
|
||||
expect(model.setOpen).toBe(setOpen);
|
||||
expect(model.currentEmployee).toMatchObject({ username: 'atlas', nickname: 'Atlas updated' });
|
||||
expect(model.senderValue).toBe('hello');
|
||||
expect(model.taskVariables).toEqual({ variables: { recordId: 1 } });
|
||||
expect(model.roles).toHaveProperty('atlas');
|
||||
expect(model.model).toEqual({ llmService: 'openai', model: 'gpt' });
|
||||
expect(model.readonly).toBe(true);
|
||||
});
|
||||
|
||||
it('proxies legacy ChatBoxModel sender access to an attached ChatSenderModel', () => {
|
||||
const chatBoxModel = new ChatBoxModel();
|
||||
const chatSenderModel = new ChatSenderModel();
|
||||
|
||||
chatBoxModel.attachSenderModel(chatSenderModel);
|
||||
chatBoxModel.setSenderValue('proxied draft');
|
||||
chatBoxModel.setShowSenderHint(true);
|
||||
chatBoxModel.setIsEditingMessage(true);
|
||||
chatBoxModel.setEditingMessageId('message-a');
|
||||
|
||||
expect(chatSenderModel.senderValue).toBe('proxied draft');
|
||||
expect(chatSenderModel.isShowSenderHint).toBe(true);
|
||||
expect(chatSenderModel.isEditingMessage).toBe(true);
|
||||
expect(chatSenderModel.editingMessageId).toBe('message-a');
|
||||
|
||||
chatSenderModel.setSenderValue('sender draft');
|
||||
expect(chatBoxModel.senderValue).toBe('sender draft');
|
||||
});
|
||||
|
||||
it('updates ChatSenderModel composer state and resets transient values', () => {
|
||||
const model = new ChatSenderModel();
|
||||
|
||||
model.setSenderValue('draft');
|
||||
model.setSenderPlaceholder('Ask');
|
||||
model.setShowSenderHint(true);
|
||||
model.setIsEditingMessage(true);
|
||||
model.setEditingMessageId('message-a');
|
||||
model.setSystemMessage('system');
|
||||
model.setSkillSettings({ skills: ['skill-a'] });
|
||||
model.addAttachments([{ filename: 'a.txt' }, { filename: 'b.txt' }]);
|
||||
model.removeAttachment('a.txt');
|
||||
model.addContextItems([contextItem('record', '1'), contextItem('record', '1')]);
|
||||
model.addContextItems(contextItem('record', '2'));
|
||||
|
||||
expect(model.senderValue).toBe('draft');
|
||||
expect(model.senderPlaceholder).toBe('Ask');
|
||||
expect(model.isShowSenderHint).toBe(true);
|
||||
expect(model.isEditingMessage).toBe(true);
|
||||
expect(model.editingMessageId).toBe('message-a');
|
||||
expect(model.systemMessage).toBe('system');
|
||||
expect(model.skillSettings).toEqual({ skills: ['skill-a'] });
|
||||
expect(model.attachments).toEqual([{ filename: 'b.txt' }]);
|
||||
expect(model.contextItems).toEqual([contextItem('record', '1'), contextItem('record', '2')]);
|
||||
|
||||
model.reset();
|
||||
|
||||
expect(model.senderValue).toBe('');
|
||||
expect(model.senderPlaceholder).toBe('');
|
||||
expect(model.senderRef).toEqual({ current: null });
|
||||
expect(model.isShowSenderHint).toBe(false);
|
||||
expect(model.isEditingMessage).toBe(false);
|
||||
expect(model.editingMessageId).toBeUndefined();
|
||||
expect(model.attachments).toEqual([]);
|
||||
expect(model.contextItems).toEqual([]);
|
||||
expect(model.systemMessage).toBe('');
|
||||
expect(model.skillSettings).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps ChatSenderModel state isolated per instance', () => {
|
||||
const first = new ChatSenderModel();
|
||||
const second = new ChatSenderModel();
|
||||
|
||||
first.setSenderValue('first');
|
||||
first.addAttachments({ filename: 'first.txt' });
|
||||
first.addContextItems(contextItem('record', '1'));
|
||||
|
||||
expect(second.senderValue).toBe('');
|
||||
expect(second.attachments).toEqual([]);
|
||||
expect(second.contextItems).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps ChatMessageModel session state isolated and returns cloned snapshots', () => {
|
||||
const model = new ChatMessageModel();
|
||||
|
||||
model.addSessionMessage('session-a', textMessage('message-a'));
|
||||
model.addSessionMessage('session-b', textMessage('message-b'));
|
||||
model.setSessionAttachments('session-a', [{ filename: 'a.txt' }]);
|
||||
model.addSessionContextItems('session-a', [contextItem('record', '1'), contextItem('record', '1')]);
|
||||
|
||||
const snapshot = model.getSessionState('session-a');
|
||||
snapshot.messages.push(textMessage('mutated'));
|
||||
snapshot.attachments.push({ filename: 'mutated.txt' });
|
||||
|
||||
expect(model.getSessionState('session-a').messages.map((message) => message.key)).toEqual(['message-a']);
|
||||
expect(model.getSessionState('session-a').attachments).toEqual([{ filename: 'a.txt' }]);
|
||||
expect(model.getSessionState('session-a').contextItems).toEqual([contextItem('record', '1')]);
|
||||
expect(model.getSessionState('session-b').messages.map((message) => message.key)).toEqual(['message-b']);
|
||||
});
|
||||
|
||||
it('keeps ChatMessageModel session objects reactive and isolated while migrating draft state', () => {
|
||||
const model = new ChatMessageModel();
|
||||
const initialSessions = model.sessions;
|
||||
const initialDefaultSession = model.sessions[CHAT_DEFAULT_SESSION_KEY];
|
||||
let messageCount = -1;
|
||||
const dispose = autorun(() => {
|
||||
messageCount = model.sessions[CHAT_DEFAULT_SESSION_KEY].messages.length;
|
||||
});
|
||||
|
||||
model.addSessionMessage(undefined, textMessage('draft-message'));
|
||||
|
||||
expect(model.sessions).toBe(initialSessions);
|
||||
expect(model.sessions[CHAT_DEFAULT_SESSION_KEY]).toBe(initialDefaultSession);
|
||||
expect(messageCount).toBe(1);
|
||||
expect(model.getSessionState().messages.map((message) => message.key)).toEqual(['draft-message']);
|
||||
|
||||
model.migrateSessionState(undefined, 'created-session');
|
||||
dispose();
|
||||
|
||||
expect(model.getSessionState('created-session').messages.map((message) => message.key)).toEqual(['draft-message']);
|
||||
expect(model.getSessionState().messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not replace other ChatMessageModel sessions when one session field changes', () => {
|
||||
const model = new ChatMessageModel();
|
||||
model.addSessionMessage('session-a', textMessage('message-a'));
|
||||
model.addSessionMessage('session-b', textMessage('message-b'));
|
||||
|
||||
const sessions = model.sessions;
|
||||
const sessionA = model.sessions['session-a'];
|
||||
const sessionB = model.sessions['session-b'];
|
||||
|
||||
model.setSessionMessagesLoading('session-a', true);
|
||||
|
||||
expect(model.sessions).toBe(sessions);
|
||||
expect(model.sessions['session-a']).toBe(sessionA);
|
||||
expect(model.sessions['session-b']).toBe(sessionB);
|
||||
expect(model.sessions['session-a'].messagesLoading).toBe(true);
|
||||
expect(model.sessions['session-b'].messagesLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('updates ChatMessageModel sub-agent conversation state on the last message', () => {
|
||||
const model = new ChatMessageModel();
|
||||
|
||||
model.addSessionMessage('session-a', textMessage('assistant-message'));
|
||||
model.addSessionSubAgentMessages('session-a', 'sub-session-a', [textMessage('sub-message-a')]);
|
||||
model.updateSessionLastSubAgentMessage('session-a', 'sub-session-a', 'atlas', (message) => ({
|
||||
...message,
|
||||
content: {
|
||||
...message.content,
|
||||
content: 'updated',
|
||||
},
|
||||
}));
|
||||
model.updateSessionSubAgentConversationStatus('session-a', 'sub-session-a', 'completed');
|
||||
|
||||
const lastMessage = model.getSessionState('session-a').messages[0];
|
||||
expect(lastMessage.content.subAgentConversations).toEqual([
|
||||
{
|
||||
sessionId: 'sub-session-a',
|
||||
status: 'completed',
|
||||
messages: [
|
||||
{
|
||||
...textMessage('sub-message-a'),
|
||||
content: {
|
||||
...textMessage('sub-message-a').content,
|
||||
content: 'updated',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('tracks ChatToolCallModel invoke state per session and migrates it', () => {
|
||||
const model = new ChatToolCallModel();
|
||||
const draftSnapshot = model.getSessionState('draft-session');
|
||||
|
||||
model.updateToolCallInvokeStatus('draft-session', 'message-a', 'tool-a', 'interrupted');
|
||||
model.updateToolCallInvokeStatus('other-session', 'message-a', 'tool-a', 'waiting');
|
||||
const sessions = model.sessions;
|
||||
const draftSession = model.sessions['draft-session'];
|
||||
const otherSession = model.sessions['other-session'];
|
||||
|
||||
expect(draftSnapshot.toolCalls).toEqual({});
|
||||
expect(model.isInterrupted('draft-session', 'message-a', 'tool-a')).toBe(true);
|
||||
expect(model.isAllWaiting('draft-session', 'message-a')).toBe(false);
|
||||
|
||||
model.updateToolCallInvokeStatus('draft-session', 'message-a', 'tool-a', 'waiting');
|
||||
expect(model.sessions).toBe(sessions);
|
||||
expect(model.sessions['draft-session']).toBe(draftSession);
|
||||
expect(model.sessions['other-session']).toBe(otherSession);
|
||||
expect(model.isAllWaiting('draft-session', 'message-a')).toBe(true);
|
||||
|
||||
model.migrateSessionState('draft-session', 'created-session');
|
||||
|
||||
expect(model.getInvokeStatus('created-session', 'message-a', 'tool-a')).toBe('waiting');
|
||||
expect(model.getSessionState('draft-session').toolCalls).toEqual({});
|
||||
expect(model.getInvokeStatus('other-session', 'message-a', 'tool-a')).toBe('waiting');
|
||||
});
|
||||
|
||||
it('indexes ChatToolModel tool calls by name and message id', () => {
|
||||
const model = new ChatToolModel();
|
||||
|
||||
model.updateTools([
|
||||
messageWithToolCalls('message-a', [toolCall('tool-a', 'getSkill')]),
|
||||
messageWithToolCalls('message-b', [toolCall('tool-b', 'getSkill')]),
|
||||
]);
|
||||
model.setOpenToolModal(true);
|
||||
model.setActiveTool(toolCall('tool-b', 'getSkill'));
|
||||
model.setActiveMessageId('message-b');
|
||||
model.setAdjustArgs({ title: 'updated' });
|
||||
|
||||
expect(model.toolsByName.getSkill).toHaveLength(2);
|
||||
expect(model.toolsByName.getSkill[0]).toMatchObject({
|
||||
id: 'tool-a',
|
||||
messageId: 'message-a',
|
||||
});
|
||||
expect(model.toolsByMessageId['message-b']['tool-b']).toMatchObject({
|
||||
name: 'getSkill',
|
||||
version: 2,
|
||||
});
|
||||
expect(model.openToolModal).toBe(true);
|
||||
expect(model.activeTool).toMatchObject({ id: 'tool-b' });
|
||||
expect(model.activeMessageId).toBe('message-b');
|
||||
expect(model.adjustArgs).toEqual({ title: 'updated' });
|
||||
});
|
||||
|
||||
it('updates ChatConversationModel list UI state and unread counts', () => {
|
||||
const model = new ChatConversationModel();
|
||||
|
||||
model.setCurrentConversation('session-a');
|
||||
model.setKeyword('review');
|
||||
model.setWebSearch(true);
|
||||
model.setConversationSegmented('workflowTasks');
|
||||
model.setConversations([conversation('session-a'), conversation('session-b', true)]);
|
||||
model.setConversations((previous) => [...previous, conversation('session-c')]);
|
||||
model.setUnreadCount((previous) => previous + 2);
|
||||
model.markConversationRead('session-a');
|
||||
|
||||
expect(model.currentConversation).toBe('session-a');
|
||||
expect(model.keyword).toBe('review');
|
||||
expect(model.webSearch).toBe(true);
|
||||
expect(model.conversationSegmented).toBe('workflowTasks');
|
||||
expect(model.conversations.map((item) => item.sessionId)).toEqual(['session-a', 'session-b', 'session-c']);
|
||||
expect(model.conversations[0].read).toBe(true);
|
||||
expect(model.unreadCount).toBe(1);
|
||||
|
||||
model.setConversationRead('session-b', false);
|
||||
expect(model.conversations[1].read).toBe(false);
|
||||
expect(model.unreadCount).toBe(2);
|
||||
|
||||
model.markConversationRead('session-a');
|
||||
model.markConversationRead('missing-session');
|
||||
|
||||
expect(model.unreadCount).toBe(2);
|
||||
});
|
||||
|
||||
it('updates WorkflowTaskModel list UI state, normalization, and unread counts', () => {
|
||||
const model = new WorkflowTaskModel();
|
||||
|
||||
model.setWorkflowTasks([workflowTask('session-pending', 'pending_approval')]);
|
||||
model.setWorkflowTasks((previous) => [...previous, workflowTask('session-approved', 'approved')]);
|
||||
model.setCurrentWorkflowTask((previous) => ({
|
||||
...(previous ?? workflowTask('session-approved')),
|
||||
readonly: true,
|
||||
}));
|
||||
model.setUnreadCount((previous) => previous + 2);
|
||||
model.setLoading(true);
|
||||
model.setKeyword('approval');
|
||||
model.setSelectedJobStatus(1);
|
||||
model.markWorkflowTaskRead('session-pending');
|
||||
|
||||
expect(model.workflowTasks.map((item) => ({ sessionId: item.sessionId, jobStatus: item.jobStatus }))).toEqual([
|
||||
{ sessionId: 'session-pending', jobStatus: 0 },
|
||||
{ sessionId: 'session-approved', jobStatus: 1 },
|
||||
]);
|
||||
expect(model.currentWorkflowTask).toMatchObject({ sessionId: 'session-approved', readonly: true });
|
||||
expect(model.loading).toBe(true);
|
||||
expect(model.keyword).toBe('approval');
|
||||
expect(model.selectedJobStatus).toBe(1);
|
||||
expect(model.workflowTasks[0].read).toBe(true);
|
||||
expect(model.unreadCount).toBe(1);
|
||||
|
||||
model.markWorkflowTaskRead('session-pending');
|
||||
model.markWorkflowTaskRead('missing-session');
|
||||
|
||||
expect(model.unreadCount).toBe(1);
|
||||
});
|
||||
});
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 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 from 'react';
|
||||
import { act, render, renderHook, screen } from '@testing-library/react';
|
||||
import { observer } from '@nocobase/flow-engine';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatBoxLayout } from '../../components/ChatBoxLayout';
|
||||
import {
|
||||
ChatBoxRuntimeProvider,
|
||||
createChatBoxRuntime,
|
||||
getGlobalChatBoxRuntime,
|
||||
resolveChatBoxScope,
|
||||
useChatBoxRuntime,
|
||||
useResolvedChatBoxRuntime,
|
||||
} from '../runtime';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
eventBus: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
},
|
||||
getLLMServices: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@nocobase/client-v2', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@nocobase/client-v2')>()),
|
||||
useApp: () => ({
|
||||
eventBus: mocks.eventBus,
|
||||
pm: {
|
||||
get: vi.fn(() => ({
|
||||
aiManager: {
|
||||
handleTriggerTaskEvent: vi.fn(),
|
||||
onChatBoxMounted: vi.fn(),
|
||||
onChatBoxUnmounted: vi.fn(),
|
||||
},
|
||||
})),
|
||||
},
|
||||
apiClient: {
|
||||
resource: () => ({
|
||||
unreadCounts: async () => ({
|
||||
data: {
|
||||
data: {
|
||||
conversationUnreadCount: 0,
|
||||
workflowTaskUnreadCount: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../repositories/hooks/useAIConfigRepository', () => ({
|
||||
useAIConfigRepository: () => ({
|
||||
getLLMServices: mocks.getLLMServices,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/ChatButton', () => ({
|
||||
ChatButton: () => <div data-testid="chat-button" />,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/ChatBox', () => ({
|
||||
ChatBox: () => <div data-testid="chat-box" />,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/DebugPanel', () => ({
|
||||
DebugPanel: () => <div data-testid="debug-panel" />,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/ToolModal', () => ({
|
||||
ToolModal: () => <div data-testid="tool-modal" />,
|
||||
}));
|
||||
|
||||
vi.mock('../../../AISelection', () => ({
|
||||
AISelection: () => <div data-testid="ai-selection" />,
|
||||
}));
|
||||
|
||||
vi.mock('../../../AISelectionControl', () => ({
|
||||
AISelectionControl: () => <div data-testid="ai-selection-control" />,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
mocks.eventBus.addEventListener.mockClear();
|
||||
mocks.eventBus.removeEventListener.mockClear();
|
||||
const runtime = getGlobalChatBoxRuntime();
|
||||
runtime.chatBoxModel.setOpen(false);
|
||||
runtime.chatBoxModel.setExpanded(false);
|
||||
runtime.chatBoxModel.setShowDebugPanel(false);
|
||||
runtime.chatSenderModel.setSenderValue('');
|
||||
runtime.chatToolModel.setActiveTool(null);
|
||||
runtime.chatToolModel.setOpenToolModal(false);
|
||||
runtime.chatConversationModel.setCurrentConversation(undefined);
|
||||
runtime.chatConversationModel.setConversations([]);
|
||||
runtime.chatConversationModel.setUnreadCount(0);
|
||||
runtime.workflowTaskModel.setWorkflowTasks([]);
|
||||
runtime.workflowTaskModel.setUnreadCount(0);
|
||||
});
|
||||
|
||||
const RuntimeReader: React.FC = () => {
|
||||
const runtime = useChatBoxRuntime();
|
||||
return <div data-testid="runtime-reader">{runtime.chatSenderModel.senderValue || 'empty'}</div>;
|
||||
};
|
||||
|
||||
const ObservedRuntimeReader: React.FC = observer(() => {
|
||||
const { chatBoxModel, chatMessageModel, chatSenderModel, chatToolModel } = useChatBoxRuntime();
|
||||
const sessionState = chatMessageModel.getSessionState('session-a');
|
||||
return (
|
||||
<div data-testid="observed-runtime-reader">
|
||||
{[
|
||||
chatSenderModel.senderValue || 'empty',
|
||||
chatBoxModel.currentEmployee?.username || 'no-employee',
|
||||
chatBoxModel.model?.model || 'no-model',
|
||||
sessionState.messages.length,
|
||||
chatToolModel.openToolModal ? 'tool-open' : 'tool-closed',
|
||||
].join('|')}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
describe('chatbox runtime context', () => {
|
||||
it('returns the provided runtime from ChatBoxRuntimeProvider', () => {
|
||||
const runtime = createChatBoxRuntime();
|
||||
runtime.chatSenderModel.setSenderValue('provided');
|
||||
const wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||
<ChatBoxRuntimeProvider runtime={runtime}>{children}</ChatBoxRuntimeProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useChatBoxRuntime(), { wrapper });
|
||||
|
||||
expect(result.current).toBe(runtime);
|
||||
expect(result.current.chatSenderModel.senderValue).toBe('provided');
|
||||
expect(result.current.chatBoxModel.senderValue).toBe('provided');
|
||||
});
|
||||
|
||||
it('defaults new runtimes to global mode and supports block mode', () => {
|
||||
expect(createChatBoxRuntime().mode).toBe('global');
|
||||
expect(createChatBoxRuntime({ mode: 'block' }).mode).toBe('block');
|
||||
expect(getGlobalChatBoxRuntime().mode).toBe('global');
|
||||
});
|
||||
|
||||
it('resolves scope from block runtimes only', async () => {
|
||||
const getScope = vi.fn().mockResolvedValue('block-scope');
|
||||
|
||||
await expect(
|
||||
resolveChatBoxScope(createChatBoxRuntime({ mode: 'block', getScope }), { operation: 'list' }),
|
||||
).resolves.toBe('block-scope');
|
||||
expect(getScope).toHaveBeenCalledWith({ operation: 'list' });
|
||||
|
||||
await expect(
|
||||
resolveChatBoxScope(createChatBoxRuntime({ mode: 'global', getScope }), { operation: 'list' }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(getScope).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws a clear error when used without a provider', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
expect(() => renderHook(() => useChatBoxRuntime())).toThrow(
|
||||
'ChatBox runtime is missing. Wrap chatbox UI with ChatBoxRuntimeProvider.',
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves an explicit runtime without a provider', () => {
|
||||
const runtime = createChatBoxRuntime();
|
||||
|
||||
const { result } = renderHook(() => useResolvedChatBoxRuntime(runtime));
|
||||
|
||||
expect(result.current).toBe(runtime);
|
||||
});
|
||||
|
||||
it('resolves the context runtime when no runtime is passed', () => {
|
||||
const runtime = createChatBoxRuntime();
|
||||
const wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||
<ChatBoxRuntimeProvider runtime={runtime}>{children}</ChatBoxRuntimeProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useResolvedChatBoxRuntime(), { wrapper });
|
||||
|
||||
expect(result.current).toBe(runtime);
|
||||
});
|
||||
|
||||
it('keeps the global runtime stable across repeated access', () => {
|
||||
const firstRuntime = getGlobalChatBoxRuntime();
|
||||
const secondRuntime = getGlobalChatBoxRuntime();
|
||||
|
||||
expect(secondRuntime).toBe(firstRuntime);
|
||||
expect(secondRuntime.chatBoxModel).toBe(firstRuntime.chatBoxModel);
|
||||
expect(secondRuntime.chatSenderModel).toBe(firstRuntime.chatSenderModel);
|
||||
expect(secondRuntime.chatConversationModel).toBe(firstRuntime.chatConversationModel);
|
||||
expect(secondRuntime.chatMessageModel).toBe(firstRuntime.chatMessageModel);
|
||||
expect(secondRuntime.workflowTaskModel).toBe(firstRuntime.workflowTaskModel);
|
||||
});
|
||||
|
||||
it('creates isolated sender, conversation, and workflow models per runtime', () => {
|
||||
const firstRuntime = createChatBoxRuntime();
|
||||
const secondRuntime = createChatBoxRuntime();
|
||||
|
||||
firstRuntime.chatSenderModel.setSenderValue('first draft');
|
||||
firstRuntime.chatConversationModel.setCurrentConversation('session-a');
|
||||
firstRuntime.chatConversationModel.setUnreadCount(1);
|
||||
firstRuntime.workflowTaskModel.setWorkflowTasks([
|
||||
{
|
||||
id: 'task-a',
|
||||
sessionId: 'task-session-a',
|
||||
workflowTitle: 'Workflow',
|
||||
nodeTitle: 'Node',
|
||||
status: 'approved',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(secondRuntime.chatSenderModel.senderValue).toBe('');
|
||||
expect(secondRuntime.chatConversationModel.currentConversation).toBeUndefined();
|
||||
expect(secondRuntime.chatConversationModel.unreadCount).toBe(0);
|
||||
expect(secondRuntime.workflowTaskModel.workflowTasks).toEqual([]);
|
||||
expect(firstRuntime.workflowTaskModel.workflowTasks[0].jobStatus).toBe(1);
|
||||
});
|
||||
|
||||
it('provides the global runtime from ChatBoxLayout', () => {
|
||||
getGlobalChatBoxRuntime().chatSenderModel.setSenderValue('layout-runtime');
|
||||
|
||||
render(
|
||||
<ChatBoxLayout>
|
||||
<RuntimeReader />
|
||||
</ChatBoxLayout>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('runtime-reader').textContent).toBe('layout-runtime');
|
||||
expect(screen.getByTestId('chat-button')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders global floating surfaces from runtime state', () => {
|
||||
const runtime = getGlobalChatBoxRuntime();
|
||||
|
||||
render(<ChatBoxLayout />);
|
||||
|
||||
expect(screen.queryByTestId('chat-box')).toBeNull();
|
||||
expect(screen.queryByTestId('tool-modal')).toBeNull();
|
||||
expect(screen.queryByTestId('debug-panel')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
runtime.chatBoxModel.setOpen(true);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('chat-box')).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
runtime.chatBoxModel.setShowDebugPanel(true);
|
||||
runtime.chatToolModel.setActiveTool({
|
||||
id: 'tool-a',
|
||||
type: 'function',
|
||||
name: 'getSkill',
|
||||
invokeStatus: 'done',
|
||||
auto: false,
|
||||
args: {},
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('debug-panel')).toBeTruthy();
|
||||
expect(screen.getByTestId('tool-modal')).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
runtime.chatBoxModel.setExpanded(true);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('chat-box')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('tracks runtime model reads through observer rendering', () => {
|
||||
const runtime = createChatBoxRuntime();
|
||||
|
||||
render(
|
||||
<ChatBoxRuntimeProvider runtime={runtime}>
|
||||
<ObservedRuntimeReader />
|
||||
</ChatBoxRuntimeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('observed-runtime-reader').textContent).toBe('empty|no-employee|no-model|0|tool-closed');
|
||||
|
||||
act(() => {
|
||||
runtime.chatSenderModel.setSenderValue('draft');
|
||||
runtime.chatBoxModel.setCurrentEmployee({ username: 'atlas', nickname: 'Atlas' });
|
||||
runtime.chatBoxModel.setModel({ llmService: 'openai', model: 'gpt-4.1' });
|
||||
runtime.chatMessageModel.addSessionMessage('session-a', {
|
||||
key: 'message-a',
|
||||
role: 'user',
|
||||
content: { type: 'text', content: 'hello' },
|
||||
});
|
||||
runtime.chatToolModel.setOpenToolModal(true);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('observed-runtime-reader').textContent).toBe('draft|atlas|gpt-4.1|1|tool-open');
|
||||
});
|
||||
});
|
||||
-257
@@ -1,257 +0,0 @@
|
||||
/**
|
||||
* 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 { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { Conversation, Message } from '../../../types';
|
||||
import { useChatConversationsStore } from '../chat-conversations';
|
||||
import { useChatToolCallStore } from '../chat-tool-call';
|
||||
import { useChatToolsStore } from '../chat-tools';
|
||||
import { useWorkflowTasksStore, type WorkflowTask } from '../workflow-tasks';
|
||||
|
||||
const resetStores = () => {
|
||||
useChatConversationsStore.setState({
|
||||
currentConversation: undefined,
|
||||
conversations: [],
|
||||
keyword: '',
|
||||
webSearch: false,
|
||||
conversationSegmented: 'conversations',
|
||||
unreadCount: 0,
|
||||
});
|
||||
useChatToolCallStore.setState({ sessions: {} });
|
||||
useChatToolsStore.setState({
|
||||
toolsByName: {},
|
||||
toolsByMessageId: {},
|
||||
openToolModal: false,
|
||||
activeTool: null,
|
||||
activeMessageId: '',
|
||||
adjustArgs: {},
|
||||
});
|
||||
useWorkflowTasksStore.setState({
|
||||
workflowTasks: [],
|
||||
currentWorkflowTask: undefined,
|
||||
unreadCount: 0,
|
||||
loading: false,
|
||||
keyword: '',
|
||||
selectedJobStatus: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const messageWithToolCalls = (messageId: string, toolCalls: Message['content']['tool_calls']): Message => ({
|
||||
key: messageId,
|
||||
role: 'assistant',
|
||||
content: {
|
||||
type: 'text',
|
||||
content: '',
|
||||
messageId,
|
||||
tool_calls: toolCalls,
|
||||
},
|
||||
});
|
||||
|
||||
const workflowTask = (sessionId: string, status = 'pending'): WorkflowTask => ({
|
||||
id: sessionId,
|
||||
sessionId,
|
||||
workflowTitle: 'Approval',
|
||||
nodeTitle: 'Review',
|
||||
status,
|
||||
});
|
||||
|
||||
const conversation = (sessionId: string): Conversation => ({
|
||||
sessionId,
|
||||
title: sessionId,
|
||||
aiEmployee: {
|
||||
username: 'atlas',
|
||||
nickname: 'Atlas',
|
||||
},
|
||||
read: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetStores();
|
||||
});
|
||||
|
||||
describe('client-v2 chatbox stores', () => {
|
||||
it('marks conversations as read and decrements unread count once', () => {
|
||||
useChatConversationsStore.getState().setConversations([
|
||||
conversation('session-a'),
|
||||
{
|
||||
...conversation('session-b'),
|
||||
read: true,
|
||||
},
|
||||
]);
|
||||
useChatConversationsStore.getState().setUnreadCount(1);
|
||||
|
||||
useChatConversationsStore.getState().markConversationRead('session-a');
|
||||
expect(useChatConversationsStore.getState().conversations).toMatchObject([
|
||||
{
|
||||
sessionId: 'session-a',
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
sessionId: 'session-b',
|
||||
read: true,
|
||||
},
|
||||
]);
|
||||
expect(useChatConversationsStore.getState().unreadCount).toBe(0);
|
||||
|
||||
useChatConversationsStore.getState().markConversationRead('session-a');
|
||||
useChatConversationsStore.getState().markConversationRead('missing-session');
|
||||
expect(useChatConversationsStore.getState().unreadCount).toBe(0);
|
||||
});
|
||||
|
||||
it('tracks interrupted tool calls per session and migrates session state', () => {
|
||||
const draftSnapshot = useChatToolCallStore.getState().getSessionState('draft-session');
|
||||
|
||||
useChatToolCallStore.getState().updateToolCallInvokeStatus('draft-session', 'message-a', 'tool-a', 'interrupted');
|
||||
useChatToolCallStore.getState().updateToolCallInvokeStatus('other-session', 'message-a', 'tool-a', 'waiting');
|
||||
|
||||
expect(draftSnapshot.toolCalls).toEqual({});
|
||||
expect(useChatToolCallStore.getState().isInterrupted('draft-session', 'message-a', 'tool-a')).toBe(true);
|
||||
expect(useChatToolCallStore.getState().isAllWaiting('draft-session', 'message-a')).toBe(false);
|
||||
|
||||
useChatToolCallStore.getState().updateToolCallInvokeStatus('draft-session', 'message-a', 'tool-a', 'waiting');
|
||||
expect(useChatToolCallStore.getState().isAllWaiting('draft-session', 'message-a')).toBe(true);
|
||||
|
||||
useChatToolCallStore.getState().migrateSessionState('draft-session', 'created-session');
|
||||
|
||||
expect(useChatToolCallStore.getState().getInvokeStatus('created-session', 'message-a', 'tool-a')).toBe('waiting');
|
||||
expect(useChatToolCallStore.getState().getSessionState('draft-session').toolCalls).toEqual({});
|
||||
expect(useChatToolCallStore.getState().getInvokeStatus('other-session', 'message-a', 'tool-a')).toBe('waiting');
|
||||
});
|
||||
|
||||
it('indexes tool calls by name and message id with stable versions', () => {
|
||||
useChatToolsStore.getState().updateTools([
|
||||
messageWithToolCalls('message-a', [
|
||||
{
|
||||
id: 'tool-a',
|
||||
type: 'function',
|
||||
name: 'getSkill',
|
||||
invokeStatus: 'done',
|
||||
auto: false,
|
||||
args: { skill: 'alpha' },
|
||||
},
|
||||
]),
|
||||
messageWithToolCalls('message-b', [
|
||||
{
|
||||
id: 'tool-b',
|
||||
type: 'function',
|
||||
name: 'getSkill',
|
||||
invokeStatus: 'done',
|
||||
auto: true,
|
||||
args: { skill: 'beta' },
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(useChatToolsStore.getState().toolsByName.getSkill).toHaveLength(2);
|
||||
expect(useChatToolsStore.getState().toolsByName.getSkill[0]).toMatchObject({
|
||||
id: 'tool-a',
|
||||
messageId: 'message-a',
|
||||
});
|
||||
expect(useChatToolsStore.getState().toolsByMessageId['message-b']['tool-b']).toMatchObject({
|
||||
name: 'getSkill',
|
||||
version: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates workflow task state through direct and functional setters', () => {
|
||||
useWorkflowTasksStore.getState().setWorkflowTasks([workflowTask('session-a')]);
|
||||
useWorkflowTasksStore.getState().setWorkflowTasks((previous) => [...previous, workflowTask('session-b')]);
|
||||
useWorkflowTasksStore.getState().setUnreadCount((previous) => previous + 2);
|
||||
useWorkflowTasksStore.getState().setCurrentWorkflowTask((previous) => ({
|
||||
...(previous ?? workflowTask('session-b')),
|
||||
readonly: true,
|
||||
}));
|
||||
useWorkflowTasksStore.getState().setLoading(true);
|
||||
useWorkflowTasksStore.getState().setKeyword('review');
|
||||
useWorkflowTasksStore.getState().setSelectedJobStatus(1);
|
||||
|
||||
expect(useWorkflowTasksStore.getState().workflowTasks.map((item) => item.sessionId)).toEqual([
|
||||
'session-a',
|
||||
'session-b',
|
||||
]);
|
||||
expect(useWorkflowTasksStore.getState()).toMatchObject({
|
||||
unreadCount: 2,
|
||||
loading: true,
|
||||
keyword: 'review',
|
||||
selectedJobStatus: 1,
|
||||
currentWorkflowTask: {
|
||||
sessionId: 'session-b',
|
||||
readonly: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes workflow task job status when the list response omits it', () => {
|
||||
useWorkflowTasksStore.getState().setWorkflowTasks([
|
||||
workflowTask('session-pending', 'pending_approval'),
|
||||
workflowTask('session-resolved', 'approved'),
|
||||
workflowTask('session-failed', 'failed'),
|
||||
workflowTask('session-error', 'error'),
|
||||
workflowTask('session-rejected', 'rejected'),
|
||||
workflowTask('session-aborted', 'aborted'),
|
||||
workflowTask('session-canceled', 'canceled'),
|
||||
workflowTask('session-retry-needed', 'retry_needed'),
|
||||
workflowTask('session-unknown', 'unknown'),
|
||||
{
|
||||
...workflowTask('session-explicit', 'approved'),
|
||||
jobStatus: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
useWorkflowTasksStore.getState().workflowTasks.map((item) => ({
|
||||
sessionId: item.sessionId,
|
||||
jobStatus: item.jobStatus,
|
||||
})),
|
||||
).toEqual([
|
||||
{ sessionId: 'session-pending', jobStatus: 0 },
|
||||
{ sessionId: 'session-resolved', jobStatus: 1 },
|
||||
{ sessionId: 'session-failed', jobStatus: -1 },
|
||||
{ sessionId: 'session-error', jobStatus: -2 },
|
||||
{ sessionId: 'session-rejected', jobStatus: -5 },
|
||||
{ sessionId: 'session-aborted', jobStatus: -3 },
|
||||
{ sessionId: 'session-canceled', jobStatus: -4 },
|
||||
{ sessionId: 'session-retry-needed', jobStatus: -6 },
|
||||
{ sessionId: 'session-unknown', jobStatus: 0 },
|
||||
{ sessionId: 'session-explicit', jobStatus: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks workflow tasks as read and decrements unread count once', () => {
|
||||
useWorkflowTasksStore.getState().setWorkflowTasks([
|
||||
{
|
||||
...workflowTask('session-a'),
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
...workflowTask('session-b'),
|
||||
read: true,
|
||||
},
|
||||
]);
|
||||
useWorkflowTasksStore.getState().setUnreadCount(1);
|
||||
|
||||
useWorkflowTasksStore.getState().markWorkflowTaskRead('session-a');
|
||||
expect(useWorkflowTasksStore.getState().workflowTasks).toMatchObject([
|
||||
{
|
||||
sessionId: 'session-a',
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
sessionId: 'session-b',
|
||||
read: true,
|
||||
},
|
||||
]);
|
||||
expect(useWorkflowTasksStore.getState().unreadCount).toBe(0);
|
||||
|
||||
useWorkflowTasksStore.getState().markWorkflowTaskRead('session-a');
|
||||
useWorkflowTasksStore.getState().markWorkflowTaskRead('missing-session');
|
||||
expect(useWorkflowTasksStore.getState().unreadCount).toBe(0);
|
||||
});
|
||||
});
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { clearMountedChatBoxes, getMountedChatBox, registerMountedChatBox } from '../mounted-chat-boxes';
|
||||
import { createChatBoxRuntime } from '../runtime';
|
||||
|
||||
describe('mounted chat box registry', () => {
|
||||
afterEach(() => {
|
||||
clearMountedChatBoxes();
|
||||
});
|
||||
|
||||
it('registers and cleans up mounted chat boxes without removing newer entries', () => {
|
||||
const first = {
|
||||
uid: 'chat-box-1',
|
||||
runtime: createChatBoxRuntime({ mode: 'block' }),
|
||||
triggerTask: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
syncContextItems: vi.fn(),
|
||||
};
|
||||
const second = {
|
||||
uid: 'chat-box-1',
|
||||
runtime: createChatBoxRuntime({ mode: 'block' }),
|
||||
triggerTask: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
syncContextItems: vi.fn(),
|
||||
};
|
||||
|
||||
const cleanupFirst = registerMountedChatBox(first);
|
||||
expect(getMountedChatBox('chat-box-1')).toBe(first);
|
||||
|
||||
const cleanupSecond = registerMountedChatBox(second);
|
||||
expect(getMountedChatBox('chat-box-1')).toBe(second);
|
||||
|
||||
cleanupFirst();
|
||||
expect(getMountedChatBox('chat-box-1')).toBe(second);
|
||||
|
||||
cleanupSecond();
|
||||
expect(getMountedChatBox('chat-box-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* 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 { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { useChatBoxStore } from '../chat-box';
|
||||
import { useChatConversationsStore } from '../chat-conversations';
|
||||
import { CHAT_DEFAULT_SESSION_KEY, CHAT_EMPTY_SESSION_STATE, useChatMessagesStore } from '../chat-messages';
|
||||
|
||||
const resetStores = () => {
|
||||
useChatBoxStore.setState({
|
||||
open: false,
|
||||
expanded: false,
|
||||
collapsed: false,
|
||||
showConversations: false,
|
||||
minimize: false,
|
||||
currentEmployee: null,
|
||||
senderValue: '',
|
||||
senderPlaceholder: '',
|
||||
taskVariables: {},
|
||||
roles: {},
|
||||
isEditingMessage: false,
|
||||
editingMessageId: null,
|
||||
chatBoxRef: { current: null },
|
||||
senderRef: { current: null },
|
||||
showCodeHistory: false,
|
||||
model: null,
|
||||
showDebugPanel: false,
|
||||
readonly: false,
|
||||
isShowSenderHint: false,
|
||||
});
|
||||
useChatConversationsStore.setState({
|
||||
currentConversation: undefined,
|
||||
conversations: [],
|
||||
keyword: '',
|
||||
webSearch: false,
|
||||
conversationSegmented: 'conversations',
|
||||
unreadCount: 0,
|
||||
});
|
||||
useChatMessagesStore.setState({
|
||||
sessions: {
|
||||
[CHAT_DEFAULT_SESSION_KEY]: { ...CHAT_EMPTY_SESSION_STATE, messages: [], attachments: [], contextItems: [] },
|
||||
},
|
||||
editorRef: {},
|
||||
currentEditorRefUid: null,
|
||||
flowContext: null,
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
resetStores();
|
||||
});
|
||||
|
||||
describe('observable chatbox store facade', () => {
|
||||
it('keeps action references stable when state changes', () => {
|
||||
const setOpen = useChatBoxStore.getState().setOpen;
|
||||
const setConversations = useChatConversationsStore.getState().setConversations;
|
||||
const addSessionMessage = useChatMessagesStore.getState().addSessionMessage;
|
||||
|
||||
useChatBoxStore.getState().setOpen(true);
|
||||
useChatConversationsStore.getState().setConversations([]);
|
||||
useChatMessagesStore.getState().setSessionMessages(undefined, []);
|
||||
|
||||
expect(useChatBoxStore.getState().setOpen).toBe(setOpen);
|
||||
expect(useChatConversationsStore.getState().setConversations).toBe(setConversations);
|
||||
expect(useChatMessagesStore.getState().addSessionMessage).toBe(addSessionMessage);
|
||||
});
|
||||
|
||||
it('rerenders selector hooks after action updates', () => {
|
||||
const { result } = renderHook(() => useChatBoxStore.use.open());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
useChatBoxStore.getState().setOpen(true);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('restores a previous getState snapshot with replace setState', () => {
|
||||
const previousState = useChatMessagesStore.getState();
|
||||
|
||||
useChatMessagesStore.getState().setSessionMessages(undefined, [
|
||||
{
|
||||
key: 'message-1',
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
content: 'hello',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(useChatMessagesStore.getState().getSessionState().messages).toHaveLength(1);
|
||||
|
||||
useChatMessagesStore.setState(previousState, true);
|
||||
|
||||
expect(useChatMessagesStore.getState().getSessionState().messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
+246
-117
@@ -7,135 +7,264 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import type { Bubble, Sender } from '@ant-design/x';
|
||||
import type { GetProp, GetRef } from 'antd';
|
||||
import type { Sender } from '@ant-design/x';
|
||||
import type { GetRef } from 'antd';
|
||||
import { action, define, observable } from '@nocobase/flow-engine';
|
||||
import type { AIEmployee } from '../../types';
|
||||
import { getOrCreateGlobalStore } from '../../stores/global-store';
|
||||
import { createObservableStore } from './create-selectors';
|
||||
import type { ChatBubbleRole } from '../components/MessageRenderers';
|
||||
import type { ChatSenderModel } from './chat-sender';
|
||||
|
||||
type RolesType = GetProp<typeof Bubble.List, 'roles'>;
|
||||
type RoleMap = Record<string, ChatBubbleRole>;
|
||||
type ChatBoxTaskVariables = {
|
||||
variables?: Record<string, unknown>;
|
||||
localVariables?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface ModelRef {
|
||||
llmService: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface ChatBoxState {
|
||||
open: boolean;
|
||||
expanded: boolean;
|
||||
collapsed: boolean;
|
||||
showConversations: boolean;
|
||||
minimize: boolean;
|
||||
export class ChatBoxModel {
|
||||
private senderModel?: ChatSenderModel;
|
||||
legacySenderValue = '';
|
||||
legacySenderPlaceholder = '';
|
||||
legacyIsEditingMessage = false;
|
||||
legacyEditingMessageId: string | null | undefined = null;
|
||||
legacySenderRef: React.MutableRefObject<GetRef<typeof Sender> | null> | null = {
|
||||
current: null,
|
||||
};
|
||||
legacyIsShowSenderHint = false;
|
||||
|
||||
currentEmployee?: AIEmployee;
|
||||
senderValue: string;
|
||||
senderPlaceholder: string;
|
||||
roles: GetProp<typeof Bubble.List, 'roles'>;
|
||||
taskVariables: {
|
||||
variables?: Record<string, unknown>;
|
||||
localVariables?: Record<string, unknown>;
|
||||
open = false;
|
||||
expanded = false;
|
||||
collapsed = false;
|
||||
showConversations = false;
|
||||
minimize = false;
|
||||
|
||||
currentEmployee: AIEmployee | null | undefined = null;
|
||||
roles: RoleMap = observable.shallow({});
|
||||
taskVariables: ChatBoxTaskVariables = observable.shallow({});
|
||||
|
||||
chatBoxRef: React.MutableRefObject<HTMLDivElement | null> | null = {
|
||||
current: null,
|
||||
};
|
||||
showCodeHistory = false;
|
||||
|
||||
model: ModelRef | null | undefined = null;
|
||||
|
||||
showDebugPanel = false;
|
||||
readonly = false;
|
||||
|
||||
constructor() {
|
||||
define(this, {
|
||||
open: observable.ref,
|
||||
expanded: observable.ref,
|
||||
collapsed: observable.ref,
|
||||
showConversations: observable.ref,
|
||||
minimize: observable.ref,
|
||||
currentEmployee: observable.ref,
|
||||
legacySenderValue: observable.ref,
|
||||
legacySenderPlaceholder: observable.ref,
|
||||
roles: observable.shallow,
|
||||
taskVariables: observable.shallow,
|
||||
legacyIsEditingMessage: observable.ref,
|
||||
legacyEditingMessageId: observable.ref,
|
||||
chatBoxRef: observable.ref,
|
||||
legacySenderRef: observable.ref,
|
||||
showCodeHistory: observable.ref,
|
||||
model: observable.ref,
|
||||
showDebugPanel: observable.ref,
|
||||
readonly: observable.ref,
|
||||
legacyIsShowSenderHint: observable.ref,
|
||||
attachSenderModel: action,
|
||||
setOpen: action,
|
||||
setExpanded: action,
|
||||
setCollapsed: action,
|
||||
setShowConversations: action,
|
||||
setMinimize: action,
|
||||
setCurrentEmployee: action,
|
||||
setSenderValue: action,
|
||||
setSenderPlaceholder: action,
|
||||
setTaskVariables: action,
|
||||
setRoles: action,
|
||||
addRole: action,
|
||||
setIsEditingMessage: action,
|
||||
setEditingMessageId: action,
|
||||
setChatBoxRef: action,
|
||||
setSenderRef: action,
|
||||
setShowCodeHistory: action,
|
||||
setModel: action,
|
||||
setShowDebugPanel: action,
|
||||
setReadonly: action,
|
||||
setShowSenderHint: action,
|
||||
});
|
||||
}
|
||||
|
||||
get senderValue() {
|
||||
return this.senderModel?.senderValue ?? this.legacySenderValue;
|
||||
}
|
||||
|
||||
set senderValue(value: string) {
|
||||
if (this.senderModel) {
|
||||
this.senderModel.setSenderValue(value);
|
||||
return;
|
||||
}
|
||||
this.legacySenderValue = value;
|
||||
}
|
||||
|
||||
get senderPlaceholder() {
|
||||
return this.senderModel?.senderPlaceholder ?? this.legacySenderPlaceholder;
|
||||
}
|
||||
|
||||
set senderPlaceholder(placeholder: string) {
|
||||
if (this.senderModel) {
|
||||
this.senderModel.setSenderPlaceholder(placeholder);
|
||||
return;
|
||||
}
|
||||
this.legacySenderPlaceholder = placeholder;
|
||||
}
|
||||
|
||||
get isEditingMessage() {
|
||||
return this.senderModel?.isEditingMessage ?? this.legacyIsEditingMessage;
|
||||
}
|
||||
|
||||
set isEditingMessage(isEditing: boolean) {
|
||||
if (this.senderModel) {
|
||||
this.senderModel.setIsEditingMessage(isEditing);
|
||||
return;
|
||||
}
|
||||
this.legacyIsEditingMessage = isEditing;
|
||||
}
|
||||
|
||||
get editingMessageId() {
|
||||
return this.senderModel?.editingMessageId ?? this.legacyEditingMessageId;
|
||||
}
|
||||
|
||||
set editingMessageId(id: string | null | undefined) {
|
||||
if (this.senderModel) {
|
||||
this.senderModel.setEditingMessageId(id);
|
||||
return;
|
||||
}
|
||||
this.legacyEditingMessageId = id;
|
||||
}
|
||||
|
||||
get senderRef() {
|
||||
return this.senderModel?.senderRef ?? this.legacySenderRef;
|
||||
}
|
||||
|
||||
set senderRef(ref: React.MutableRefObject<GetRef<typeof Sender> | null> | null) {
|
||||
if (this.senderModel) {
|
||||
this.senderModel.setSenderRef(ref);
|
||||
return;
|
||||
}
|
||||
this.legacySenderRef = ref;
|
||||
}
|
||||
|
||||
get isShowSenderHint() {
|
||||
return this.senderModel?.isShowSenderHint ?? this.legacyIsShowSenderHint;
|
||||
}
|
||||
|
||||
set isShowSenderHint(isShowSenderHint: boolean) {
|
||||
if (this.senderModel) {
|
||||
this.senderModel.setShowSenderHint(isShowSenderHint);
|
||||
return;
|
||||
}
|
||||
this.legacyIsShowSenderHint = isShowSenderHint;
|
||||
}
|
||||
|
||||
attachSenderModel = (senderModel: ChatSenderModel) => {
|
||||
this.senderModel = senderModel;
|
||||
};
|
||||
|
||||
isEditingMessage: boolean;
|
||||
editingMessageId?: string;
|
||||
setOpen = (open: boolean) => {
|
||||
this.open = open;
|
||||
if (!open) {
|
||||
this.collapsed = false;
|
||||
}
|
||||
};
|
||||
|
||||
chatBoxRef: React.MutableRefObject<HTMLDivElement> | null;
|
||||
senderRef: React.MutableRefObject<GetRef<typeof Sender>> | null;
|
||||
showCodeHistory: boolean;
|
||||
setExpanded = (expanded: boolean) => {
|
||||
this.expanded = expanded;
|
||||
if (expanded) {
|
||||
this.collapsed = false;
|
||||
}
|
||||
};
|
||||
|
||||
model?: ModelRef | null;
|
||||
setCollapsed = (collapsed: boolean) => {
|
||||
this.collapsed = collapsed;
|
||||
};
|
||||
|
||||
showDebugPanel: boolean;
|
||||
readonly: boolean;
|
||||
isShowSenderHint: boolean;
|
||||
setShowConversations = (show: boolean) => {
|
||||
this.showConversations = show;
|
||||
};
|
||||
|
||||
setMinimize = (minimize: boolean) => {
|
||||
this.minimize = minimize;
|
||||
};
|
||||
|
||||
setCurrentEmployee = (
|
||||
employee?: AIEmployee | null | ((prev: AIEmployee | null | undefined) => AIEmployee | null | undefined),
|
||||
) => {
|
||||
this.currentEmployee = typeof employee === 'function' ? employee(this.currentEmployee) : employee;
|
||||
};
|
||||
|
||||
setSenderValue = (value: string) => {
|
||||
this.senderValue = value;
|
||||
};
|
||||
|
||||
setSenderPlaceholder = (placeholder: string) => {
|
||||
this.senderPlaceholder = placeholder;
|
||||
};
|
||||
|
||||
setTaskVariables = (variables: ChatBoxTaskVariables) => {
|
||||
this.taskVariables = variables;
|
||||
};
|
||||
|
||||
setRoles = (roles: RoleMap | ((prev: RoleMap) => RoleMap)) => {
|
||||
this.roles = typeof roles === 'function' ? roles(this.roles) : roles;
|
||||
};
|
||||
|
||||
addRole = (name: string, role: RoleMap[string]) => {
|
||||
this.roles = {
|
||||
...(this.roles ?? {}),
|
||||
[name]: role,
|
||||
};
|
||||
};
|
||||
|
||||
setIsEditingMessage = (isEditing: boolean) => {
|
||||
this.isEditingMessage = isEditing;
|
||||
};
|
||||
|
||||
setEditingMessageId = (id?: string | null) => {
|
||||
this.editingMessageId = id;
|
||||
};
|
||||
|
||||
setChatBoxRef = (ref: React.MutableRefObject<HTMLDivElement | null> | null) => {
|
||||
this.chatBoxRef = ref;
|
||||
};
|
||||
|
||||
setSenderRef = (ref: React.MutableRefObject<GetRef<typeof Sender> | null> | null) => {
|
||||
this.senderRef = ref;
|
||||
};
|
||||
|
||||
setShowCodeHistory = (show: boolean) => {
|
||||
this.showCodeHistory = show;
|
||||
};
|
||||
|
||||
setModel = (model: ModelRef | null | undefined) => {
|
||||
this.model = model;
|
||||
};
|
||||
|
||||
setShowDebugPanel = (show: boolean) => {
|
||||
this.showDebugPanel = show;
|
||||
};
|
||||
|
||||
setReadonly = (readonly: boolean) => {
|
||||
this.readonly = readonly;
|
||||
};
|
||||
|
||||
setShowSenderHint = (isShowSenderHint: boolean) => {
|
||||
this.isShowSenderHint = isShowSenderHint;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChatBoxActions {
|
||||
setOpen: (open: boolean) => void;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
setShowConversations: (show: boolean) => void;
|
||||
setMinimize: (minus: boolean) => void;
|
||||
|
||||
setCurrentEmployee: (aiEmployee?: AIEmployee | ((prev: AIEmployee) => AIEmployee)) => void;
|
||||
setSenderValue: (value: string) => void;
|
||||
setSenderPlaceholder: (placeholder: string) => void;
|
||||
setTaskVariables: (variables: ChatBoxState['taskVariables']) => void;
|
||||
setRoles: (roles: RolesType | ((prev: RolesType) => RolesType)) => void;
|
||||
addRole: (name: string, role: unknown) => void;
|
||||
|
||||
setIsEditingMessage: (isEditing: boolean) => void;
|
||||
setEditingMessageId: (id?: string) => void;
|
||||
|
||||
setChatBoxRef: (ref: React.MutableRefObject<HTMLDivElement> | null) => void;
|
||||
setSenderRef: (ref: React.MutableRefObject<GetRef<typeof Sender>> | null) => void;
|
||||
setShowCodeHistory: (show: boolean) => void;
|
||||
|
||||
setModel: (model: ModelRef | null) => void;
|
||||
|
||||
setShowDebugPanel: (show: boolean) => void;
|
||||
setReadonly: (readonly: boolean) => void;
|
||||
setShowSenderHint: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export const useChatBoxStore = getOrCreateGlobalStore('@nocobase/plugin-ai/chat-box-store', () =>
|
||||
createObservableStore<ChatBoxState & ChatBoxActions>((set) => ({
|
||||
open: false,
|
||||
expanded: false,
|
||||
collapsed: false,
|
||||
showConversations: false,
|
||||
minimize: false,
|
||||
|
||||
currentEmployee: null,
|
||||
senderValue: '',
|
||||
senderPlaceholder: '',
|
||||
taskVariables: {},
|
||||
roles: {},
|
||||
|
||||
isEditingMessage: false,
|
||||
editingMessageId: null,
|
||||
|
||||
chatBoxRef: {
|
||||
current: null,
|
||||
},
|
||||
senderRef: {
|
||||
current: null,
|
||||
},
|
||||
showCodeHistory: false,
|
||||
model: null,
|
||||
showDebugPanel: false,
|
||||
readonly: false,
|
||||
isShowSenderHint: false,
|
||||
|
||||
setOpen: (open) => set({ open, ...(open ? {} : { collapsed: false }) }),
|
||||
setExpanded: (expanded) => set({ expanded, ...(expanded ? { collapsed: false } : {}) }),
|
||||
setCollapsed: (collapsed) => set({ collapsed }),
|
||||
setShowConversations: (show) => set({ showConversations: show }),
|
||||
setMinimize: (minus) => set({ minimize: minus }),
|
||||
|
||||
setCurrentEmployee: (employee: AIEmployee | ((prev: AIEmployee) => AIEmployee)) =>
|
||||
set((state) => ({
|
||||
currentEmployee: typeof employee === 'function' ? employee(state.currentEmployee) : employee,
|
||||
})),
|
||||
setSenderValue: (val) => set({ senderValue: val }),
|
||||
setSenderPlaceholder: (val) => set({ senderPlaceholder: val }),
|
||||
setTaskVariables: (vars) => set({ taskVariables: vars }),
|
||||
|
||||
setRoles: (roles: RolesType | ((prev: RolesType) => RolesType)) =>
|
||||
set((state) => ({
|
||||
roles: typeof roles === 'function' ? (roles as (prev: RolesType) => RolesType)(state.roles) : roles,
|
||||
})),
|
||||
addRole: (name, role) => set((state) => ({ roles: { ...state.roles, [name]: role } })),
|
||||
|
||||
setIsEditingMessage: (isEditing) => set({ isEditingMessage: isEditing }),
|
||||
setEditingMessageId: (id) => set({ editingMessageId: id }),
|
||||
|
||||
setChatBoxRef: (ref) => set({ chatBoxRef: ref }),
|
||||
setSenderRef: (ref) => set({ senderRef: ref }),
|
||||
setShowCodeHistory: (show) => set({ showCodeHistory: show }),
|
||||
setModel: (model) => set({ model }),
|
||||
setShowDebugPanel: (show) => set({ showDebugPanel: show }),
|
||||
setReadonly: (readonly) => set({ readonly }),
|
||||
setShowSenderHint: (isShowSenderHint) => set({ isShowSenderHint }),
|
||||
})),
|
||||
);
|
||||
|
||||
+74
-64
@@ -8,69 +8,79 @@
|
||||
*/
|
||||
|
||||
import type { Conversation } from '../../types';
|
||||
import { getOrCreateGlobalStore } from '../../stores/global-store';
|
||||
import { createObservableStore } from './create-selectors';
|
||||
import { action, define, observable } from '@nocobase/flow-engine';
|
||||
|
||||
interface ChatConversationsState {
|
||||
currentConversation?: string;
|
||||
conversations: Conversation[];
|
||||
keyword: string;
|
||||
webSearch: boolean;
|
||||
conversationSegmented: string;
|
||||
unreadCount: number;
|
||||
type ChatConversationStateUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
export class ChatConversationModel {
|
||||
currentConversation: string | undefined = undefined;
|
||||
conversations: Conversation[] = observable.shallow([]);
|
||||
keyword = '';
|
||||
webSearch = false;
|
||||
conversationSegmented = 'conversations';
|
||||
unreadCount = 0;
|
||||
|
||||
constructor() {
|
||||
define(this, {
|
||||
currentConversation: observable.ref,
|
||||
conversations: observable.shallow,
|
||||
keyword: observable.ref,
|
||||
webSearch: observable.ref,
|
||||
conversationSegmented: observable.ref,
|
||||
unreadCount: observable.ref,
|
||||
setCurrentConversation: action,
|
||||
setKeyword: action,
|
||||
setConversations: action,
|
||||
setConversationRead: action,
|
||||
markConversationRead: action,
|
||||
setWebSearch: action,
|
||||
setConversationSegmented: action,
|
||||
setUnreadCount: action,
|
||||
});
|
||||
}
|
||||
|
||||
setCurrentConversation = (id: string | undefined) => {
|
||||
this.currentConversation = id;
|
||||
};
|
||||
|
||||
setKeyword = (keyword: string) => {
|
||||
this.keyword = keyword;
|
||||
};
|
||||
|
||||
setConversations = (conversations: ChatConversationStateUpdater<Conversation[]>) => {
|
||||
this.conversations = typeof conversations === 'function' ? conversations(this.conversations) : conversations;
|
||||
};
|
||||
|
||||
setConversationRead = (sessionId: string, read: boolean) => {
|
||||
const target = this.conversations.find((item) => item.sessionId === sessionId);
|
||||
if (!target || target.read === read) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.conversations = this.conversations.map((item) =>
|
||||
item.sessionId === sessionId
|
||||
? {
|
||||
...item,
|
||||
read,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
this.unreadCount = read ? Math.max(0, this.unreadCount - 1) : this.unreadCount + 1;
|
||||
};
|
||||
|
||||
markConversationRead = (sessionId: string) => {
|
||||
this.setConversationRead(sessionId, true);
|
||||
};
|
||||
|
||||
setWebSearch = (webSearch: boolean) => {
|
||||
this.webSearch = webSearch;
|
||||
};
|
||||
|
||||
setConversationSegmented = (conversationSegmented: string) => {
|
||||
this.conversationSegmented = conversationSegmented;
|
||||
};
|
||||
|
||||
setUnreadCount = (unreadCount: ChatConversationStateUpdater<number>) => {
|
||||
this.unreadCount = typeof unreadCount === 'function' ? unreadCount(this.unreadCount) : unreadCount;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChatConversationsActions {
|
||||
setCurrentConversation: (id: string | undefined) => void;
|
||||
setKeyword: (keyword: string) => void;
|
||||
setConversations: (conversations: Conversation[] | ((prev: Conversation[]) => Conversation[])) => void;
|
||||
markConversationRead: (sessionId: string) => void;
|
||||
setWebSearch: (webSearch: boolean) => void;
|
||||
setConversationSegmented: (conversationSegmented: string) => void;
|
||||
setUnreadCount: (unreadCount: number | ((prev: number) => number)) => void;
|
||||
}
|
||||
|
||||
export const useChatConversationsStore = getOrCreateGlobalStore('@nocobase/plugin-ai/chat-conversations-store', () =>
|
||||
createObservableStore<ChatConversationsState & ChatConversationsActions>((set) => ({
|
||||
currentConversation: undefined,
|
||||
conversations: [],
|
||||
keyword: '',
|
||||
webSearch: false,
|
||||
conversationSegmented: 'conversations',
|
||||
unreadCount: 0,
|
||||
|
||||
setCurrentConversation: (id) => set({ currentConversation: id }),
|
||||
setKeyword: (keyword) => set({ keyword }),
|
||||
setConversations: (conversations) =>
|
||||
set((state) => ({
|
||||
conversations: typeof conversations === 'function' ? conversations(state.conversations) : conversations,
|
||||
})),
|
||||
markConversationRead: (sessionId) =>
|
||||
set((state) => {
|
||||
const target = state.conversations.find((item) => item.sessionId === sessionId);
|
||||
if (!target || target.read) {
|
||||
return {
|
||||
conversations: state.conversations,
|
||||
unreadCount: state.unreadCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
conversations: state.conversations.map((item) =>
|
||||
item.sessionId === sessionId
|
||||
? {
|
||||
...item,
|
||||
read: true,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
unreadCount: Math.max(0, state.unreadCount - 1),
|
||||
};
|
||||
}),
|
||||
setWebSearch: (webSearch) => set({ webSearch }),
|
||||
setConversationSegmented: (conversationSegmented) => set({ conversationSegmented }),
|
||||
setUnreadCount: (unreadCount) =>
|
||||
set((state) => ({
|
||||
unreadCount: typeof unreadCount === 'function' ? unreadCount(state.unreadCount) : unreadCount,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user