V4.13.2 features (#5792)
Some checks are pending
Document deploy / sync-images (push) Waiting to run
Document deploy / generate-timestamp (push) Blocked by required conditions
Document deploy / build-images (map[domain:https://fastgpt.cn suffix:cn]) (push) Blocked by required conditions
Document deploy / build-images (map[domain:https://fastgpt.io suffix:io]) (push) Blocked by required conditions
Document deploy / update-images (map[deployment:fastgpt-docs domain:https://fastgpt.cn kube_config:KUBE_CONFIG_CN suffix:cn]) (push) Blocked by required conditions
Document deploy / update-images (map[deployment:fastgpt-docs domain:https://fastgpt.io kube_config:KUBE_CONFIG_IO suffix:io]) (push) Blocked by required conditions
Build FastGPT images in Personal warehouse / get-vars (push) Waiting to run
Build FastGPT images in Personal warehouse / build-fastgpt-images (map[arch:amd64 runs-on:ubuntu-24.04]) (push) Blocked by required conditions
Build FastGPT images in Personal warehouse / build-fastgpt-images (map[arch:arm64 runs-on:ubuntu-24.04-arm]) (push) Blocked by required conditions
Build FastGPT images in Personal warehouse / release-fastgpt-images (push) Blocked by required conditions

* add manual create http toolset (#5743)

* add manual create http toolset

* optimize code

* optimize

* fix

* fix

* rename filename

* feat: integrate ts-rest (#5741)

* feat: integrate ts-rest

* chore: classify core contract and pro contract

* chore: update lockfile

* chore: tweak dir structure

* chore: tweak dir structure

* update tsrest code (#5755)

* doc

* update tsrest code

* fix http toolset (#5753)

* fix http toolset

* fix

* perf: http toolset

* fix: toolresponse result (#5760)

* doc

* fix: toolresponse result

* fix: mongo watch

* remove log

* feat: integrated to minio (#5748)

* feat: migrate to minio

* feat: migrate apps' and dataset's avatar to minio

* feat: migrate more avatars to minio

* fix: lock file

* feat: migrate copyright settings' logo to minio

* feat: integrate minio

* chore: improve code

* chore: rename variables

* refactor: s3 class

* fix: s3 and mongo operations

* chore: add session for avatar source

* fix: init s3 buckets

* fix: bugbot issues

* expired time code

* perf: avatar code

* union type

* export favouriteContract

* empty bucket check

---------

Co-authored-by: archer <545436317@qq.com>

* refactor: zod schema to generate OpenAPI instead (#5771)

* doc

* fix: text split code (#5773)

* fix: toolresponse result

* remove log

* stream remove

* fix: text split code

* fix: workflow (#5779)

* fix: toolresponse result

* remove log

* fix: value check

* fix: workflow

* openapi doc

* perf: bucket delete cron

* doc

* feat: apikey health

* feat: export variables

* api code move

* perf: workflow performance (#5783)

* perf: reactflow context

* perf: workflow context split

* perf: nodeList computed map

* perf: nodes dependen

* perf: workflow performance

* workflow performance

* removel og

* lock

* version

* loop drag

* reactflow size

* reactflow size

* fix: s3init (#5784)

* doc

* fix: s3init

* perf: dynamic import

* remove moongose dep

* worker build

* worker code

* perf: worker build

* fix: error throw

* doc

* doc

* fix: build

* fix: dockerfile

* nextjs config

* fix: worker

* fix: build (#5791)

* fix: build

* vector cache code

* fix: app info modal avatar upload method replace (#5787)

* fix: app info modal avatar upload method replace

* chore: replace all useSelectFile with useUploadAvatar

* remove invalid code

* add size

* Update projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/CommonInputForm.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: heheer <heheer@sealos.io>
Co-authored-by: 伍闲犬 <whoeverimf5@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Archer 2025-10-20 19:08:21 +08:00 committed by GitHub
parent ca3053f04d
commit 44e9299d5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
253 changed files with 18343 additions and 9623 deletions

1137
.claude/agents/dataset.md Normal file

File diff suppressed because it is too large Load Diff

604
.claude/agents/workflow.md Normal file
View File

@ -0,0 +1,604 @@
---
name: workflow-agent
description: 当用户需要开发工作流代码时候,可调用此 Agent。
model: inherit
color: green
---
# FastGPT 工作流系统架构文档
## 概述
FastGPT 工作流系统是一个基于 Node.js/TypeScript 的可视化工作流引擎,支持拖拽式节点编排、实时执行、并发控制和交互式调试。系统采用队列式执行架构,通过有向图模型实现复杂的业务逻辑编排。
## 核心架构
### 1. 项目结构
```
FastGPT/
├── packages/
│ ├── global/core/workflow/ # 全局工作流类型和常量
│ │ ├── constants.ts # 工作流常量定义
│ │ ├── node/ # 节点类型定义
│ │ │ └── constant.ts # 节点枚举和配置
│ │ ├── runtime/ # 运行时类型和工具
│ │ │ ├── constants.ts # 运行时常量
│ │ │ ├── type.d.ts # 运行时类型定义
│ │ │ └── utils.ts # 运行时工具函数
│ │ ├── template/ # 节点模板定义
│ │ │ └── system/ # 系统节点模板
│ │ └── type/ # 类型定义
│ │ ├── node.d.ts # 节点类型
│ │ ├── edge.d.ts # 边类型
│ │ └── io.d.ts # 输入输出类型
│ └── service/core/workflow/ # 工作流服务层
│ ├── constants.ts # 服务常量
│ ├── dispatch/ # 调度器核心
│ │ ├── index.ts # 工作流执行引擎 ⭐
│ │ ├── constants.ts # 节点调度映射表
│ │ ├── type.d.ts # 调度器类型
│ │ ├── ai/ # AI相关节点
│ │ ├── tools/ # 工具节点
│ │ ├── dataset/ # 数据集节点
│ │ ├── interactive/ # 交互节点
│ │ ├── loop/ # 循环节点
│ │ └── plugin/ # 插件节点
│ └── utils.ts # 工作流工具函数
└── projects/app/src/
├── pages/api/v1/chat/completions.ts # 聊天API入口
└── pages/api/core/workflow/debug.ts # 工作流调试API
```
### 2. 执行引擎核心 (dispatch/index.ts)
#### 核心类WorkflowQueue
工作流执行引擎采用队列式架构,主要特点:
- **并发控制**: 支持最大并发数量限制默认10个
- **状态管理**: 维护节点执行状态waiting/active/skipped
- **错误处理**: 支持节点级错误捕获和跳过机制
- **交互支持**: 支持用户交互节点暂停和恢复
#### 执行流程
```typescript
1. 初始化 WorkflowQueue 实例
2. 识别入口节点isEntry=true
3. 将入口节点加入 activeRunQueue
4. 循环处理活跃节点队列:
- 检查节点执行条件
- 执行节点或跳过节点
- 更新边状态
- 将后续节点加入队列
5. 处理跳过节点队列
6. 返回执行结果
```
### 3. 节点系统
#### 节点类型枚举 (FlowNodeTypeEnum)
```typescript
enum FlowNodeTypeEnum {
// 基础节点
workflowStart: 'workflowStart', // 工作流开始
chatNode: 'chatNode', // AI对话
answerNode: 'answerNode', // 回答节点
// 数据集相关
datasetSearchNode: 'datasetSearchNode', // 数据集搜索
datasetConcatNode: 'datasetConcatNode', // 数据集拼接
// 控制流节点
ifElseNode: 'ifElseNode', // 条件判断
loop: 'loop', // 循环
loopStart: 'loopStart', // 循环开始
loopEnd: 'loopEnd', // 循环结束
// 交互节点
userSelect: 'userSelect', // 用户选择
formInput: 'formInput', // 表单输入
// 工具节点
httpRequest468: 'httpRequest468', // HTTP请求
code: 'code', // 代码执行
readFiles: 'readFiles', // 文件读取
variableUpdate: 'variableUpdate', // 变量更新
// AI相关
classifyQuestion: 'classifyQuestion', // 问题分类
contentExtract: 'contentExtract', // 内容提取
agent: 'tools', // 智能体
queryExtension: 'cfr', // 查询扩展
// 插件系统
pluginModule: 'pluginModule', // 插件模块
appModule: 'appModule', // 应用模块
tool: 'tool', // 工具调用
// 系统节点
systemConfig: 'userGuide', // 系统配置
globalVariable: 'globalVariable', // 全局变量
comment: 'comment' // 注释节点
}
```
#### 节点调度映射 (callbackMap)
每个节点类型都有对应的调度函数:
```typescript
export const callbackMap: Record<FlowNodeTypeEnum, Function> = {
[FlowNodeTypeEnum.workflowStart]: dispatchWorkflowStart,
[FlowNodeTypeEnum.chatNode]: dispatchChatCompletion,
[FlowNodeTypeEnum.datasetSearchNode]: dispatchDatasetSearch,
[FlowNodeTypeEnum.httpRequest468]: dispatchHttp468Request,
[FlowNodeTypeEnum.ifElseNode]: dispatchIfElse,
[FlowNodeTypeEnum.agent]: dispatchRunTools,
// ... 更多节点调度函数
};
```
### 4. 数据流系统
#### 输入输出类型 (WorkflowIOValueTypeEnum)
```typescript
enum WorkflowIOValueTypeEnum {
string: 'string',
number: 'number',
boolean: 'boolean',
object: 'object',
arrayString: 'arrayString',
arrayNumber: 'arrayNumber',
arrayBoolean: 'arrayBoolean',
arrayObject: 'arrayObject',
chatHistory: 'chatHistory', // 聊天历史
datasetQuote: 'datasetQuote', // 数据集引用
dynamic: 'dynamic', // 动态类型
any: 'any'
}
```
#### 变量系统
- **系统变量**: userId, appId, chatId, cTime等
- **用户变量**: 通过variables参数传入的全局变量
- **节点变量**: 节点间传递的引用变量
- **动态变量**: 支持{{$variable}}语法引用
### 5. 状态管理
#### 运行时状态
```typescript
interface RuntimeNodeItemType {
nodeId: string;
name: string;
flowNodeType: FlowNodeTypeEnum;
inputs: FlowNodeInputItemType[];
outputs: FlowNodeOutputItemType[];
isEntry?: boolean;
catchError?: boolean;
}
interface RuntimeEdgeItemType {
source: string;
target: string;
sourceHandle: string;
targetHandle: string;
status: 'waiting' | 'active' | 'skipped';
}
```
#### 执行状态
```typescript
enum RuntimeEdgeStatusEnum {
waiting: 'waiting', // 等待执行
active: 'active', // 活跃状态
skipped: 'skipped' // 已跳过
}
```
### 6. API接口设计
#### 主要API端点
1. **工作流调试**: `/api/core/workflow/debug`
- POST方法支持工作流测试和调试
- 返回详细的执行结果和状态信息
2. **聊天完成**: `/api/v1/chat/completions`
- OpenAI兼容的聊天API
- 集成工作流执行引擎
3. **优化代码**: `/api/core/workflow/optimizeCode`
- 工作流代码优化功能
#### 请求/响应类型
```typescript
interface DispatchFlowResponse {
flowResponses: ChatHistoryItemResType[];
flowUsages: ChatNodeUsageType[];
debugResponse: WorkflowDebugResponse;
workflowInteractiveResponse?: WorkflowInteractiveResponseType;
toolResponses: ToolRunResponseItemType;
assistantResponses: AIChatItemValueItemType[];
runTimes: number;
newVariables: Record<string, string>;
durationSeconds: number;
}
```
## 核心特性
### 1. 并发控制
- 支持最大并发节点数限制
- 队列式调度避免资源竞争
- 节点级执行状态管理
### 2. 错误处理
- 节点级错误捕获
- catchError配置控制错误传播
- 错误跳过和继续执行机制
### 3. 交互式执行
- 支持用户交互节点userSelect, formInput
- 工作流暂停和恢复
- 交互状态持久化
### 4. 调试支持
- Debug模式提供详细执行信息
- 节点执行状态可视化
- 变量值追踪和检查
### 5. 扩展性
- 插件系统支持自定义节点
- 模块化架构便于扩展
- 工具集成HTTP, 代码执行等)
## 开发指南
### 添加新节点类型
1. 在 `FlowNodeTypeEnum` 中添加新类型
2. 在 `callbackMap` 中注册调度函数
3. 在 `dispatch/` 目录下实现节点逻辑
4. 在 `template/system/` 中定义节点模板
### 自定义工具集成
1. 实现工具调度函数
2. 定义工具输入输出类型
3. 注册到callbackMap
4. 添加前端配置界面
### 调试和测试
1. 使用 `/api/core/workflow/debug` 进行测试
2. 启用debug模式查看详细执行信息
3. 检查节点执行状态和数据流
4. 使用skipNodeQueue控制执行路径
## 性能优化
1. **并发控制**: 合理设置maxConcurrency避免资源过载
2. **缓存机制**: 利用节点输出缓存减少重复计算
3. **流式响应**: 支持SSE实时返回执行状态
4. **资源管理**: 及时清理临时数据和状态
---
## 前端架构设计
### 1. 前端项目结构
```
projects/app/src/
├── pageComponents/app/detail/ # 应用详情页面
│ ├── Workflow/ # 工作流主页面
│ │ ├── Header.tsx # 工作流头部
│ │ └── index.tsx # 工作流入口
│ ├── WorkflowComponents/ # 工作流核心组件
│ │ ├── context/ # 状态管理上下文
│ │ │ ├── index.tsx # 主上下文提供者 ⭐
│ │ │ ├── workflowInitContext.tsx # 初始化上下文
│ │ │ ├── workflowEventContext.tsx # 事件上下文
│ │ │ └── workflowStatusContext.tsx # 状态上下文
│ │ ├── Flow/ # ReactFlow核心组件
│ │ │ ├── index.tsx # 工作流画布 ⭐
│ │ │ ├── components/ # 工作流UI组件
│ │ │ ├── hooks/ # 工作流逻辑钩子
│ │ │ └── nodes/ # 节点渲染组件
│ │ ├── constants.tsx # 常量定义
│ │ └── utils.ts # 工具函数
│ └── HTTPTools/ # HTTP工具页面
│ └── Edit.tsx # HTTP工具编辑器
├── web/core/workflow/ # 工作流核心逻辑
│ ├── api.ts # API调用 ⭐
│ ├── adapt.ts # 数据适配
│ ├── type.d.ts # 类型定义
│ └── utils.ts # 工具函数
└── global/core/workflow/ # 全局工作流定义
└── api.d.ts # API类型定义
```
### 2. 核心状态管理架构
#### Context分层设计
前端采用分层Context架构实现状态的高效管理和组件间通信
```typescript
// 1. ReactFlowCustomProvider - 最外层提供者
ReactFlowProvider → WorkflowInitContextProvider →
WorkflowContextProvider → WorkflowEventContextProvider →
WorkflowStatusContextProvider → children
// 2. 四层核心Context
- WorkflowInitContext: 节点数据和基础状态
- WorkflowDataContext: 节点/边操作和状态
- WorkflowEventContext: 事件处理和UI控制
- WorkflowStatusContext: 保存状态和父节点管理
```
#### 主Context功能 (context/index.tsx)
```typescript
interface WorkflowContextType {
// 节点管理
nodeList: FlowNodeItemType[];
onChangeNode: (props: FlowNodeChangeProps) => void;
onUpdateNodeError: (nodeId: string, isError: boolean) => void;
getNodeDynamicInputs: (nodeId: string) => FlowNodeInputItemType[];
// 边管理
onDelEdge: (edgeProps: EdgeDeleteProps) => void;
// 版本控制
past: WorkflowSnapshotsType[];
future: WorkflowSnapshotsType[];
undo: () => void;
redo: () => void;
pushPastSnapshot: (snapshot: SnapshotProps) => boolean;
// 调试功能
workflowDebugData?: DebugDataType;
onNextNodeDebug: (debugData: DebugDataType) => Promise<void>;
onStartNodeDebug: (debugProps: DebugStartProps) => Promise<void>;
onStopNodeDebug: () => void;
// 数据转换
flowData2StoreData: () => StoreWorkflowType;
splitToolInputs: (inputs, nodeId) => ToolInputsResult;
}
```
### 3. ReactFlow集成
#### 节点类型映射 (Flow/index.tsx)
```typescript
const nodeTypes: Record<FlowNodeTypeEnum, React.ComponentType> = {
[FlowNodeTypeEnum.workflowStart]: NodeWorkflowStart,
[FlowNodeTypeEnum.chatNode]: NodeSimple,
[FlowNodeTypeEnum.datasetSearchNode]: NodeSimple,
[FlowNodeTypeEnum.httpRequest468]: NodeHttp,
[FlowNodeTypeEnum.ifElseNode]: NodeIfElse,
[FlowNodeTypeEnum.agent]: NodeAgent,
[FlowNodeTypeEnum.code]: NodeCode,
[FlowNodeTypeEnum.loop]: NodeLoop,
[FlowNodeTypeEnum.userSelect]: NodeUserSelect,
[FlowNodeTypeEnum.formInput]: NodeFormInput,
// ... 40+ 种节点类型
};
```
#### 工作流核心功能
- **拖拽编排**: 基于ReactFlow的可视化节点编辑
- **实时连接**: 节点间的动态连接和断开
- **缩放控制**: 支持画布缩放和平移
- **选择操作**: 多选、批量操作支持
- **辅助线**: 节点对齐和位置吸附
### 4. 节点组件系统
#### 节点渲染架构
```
nodes/
├── NodeSimple.tsx # 通用简单节点
├── NodeWorkflowStart.tsx # 工作流开始节点
├── NodeAgent.tsx # AI智能体节点
├── NodeHttp/ # HTTP请求节点
├── NodeCode/ # 代码执行节点
├── Loop/ # 循环节点组
├── NodeFormInput/ # 表单输入节点
├── NodePluginIO/ # 插件IO节点
├── NodeToolParams/ # 工具参数节点
└── render/ # 渲染组件库
├── NodeCard.tsx # 节点卡片容器
├── RenderInput/ # 输入渲染器
├── RenderOutput/ # 输出渲染器
└── templates/ # 输入模板组件
```
#### 动态输入系统
```typescript
// 支持多种输入类型
const inputTemplates = {
reference: ReferenceTemplate, // 引用其他节点
input: TextInput, // 文本输入
textarea: TextareaInput, // 多行文本
selectApp: AppSelector, // 应用选择器
selectDataset: DatasetSelector, // 数据集选择
settingLLMModel: LLMModelConfig, // AI模型配置
// ... 更多模板类型
};
```
### 5. 调试和测试系统
#### 调试功能
```typescript
interface DebugDataType {
runtimeNodes: RuntimeNodeItemType[];
runtimeEdges: RuntimeEdgeItemType[];
entryNodeIds: string[];
variables: Record<string, any>;
history?: ChatItemType[];
query?: UserChatItemValueItemType[];
workflowInteractiveResponse?: WorkflowInteractiveResponseType;
}
```
- **单步调试**: 支持逐个节点执行调试
- **断点设置**: 在任意节点设置断点
- **状态查看**: 实时查看节点执行状态
- **变量追踪**: 监控变量在节点间的传递
- **错误定位**: 精确定位执行错误节点
#### 聊天测试
```typescript
// ChatTest组件提供实时工作流测试
<ChatTest
isOpen={isOpenTest}
nodes={workflowTestData?.nodes}
edges={workflowTestData?.edges}
onClose={onCloseTest}
chatId={chatId}
/>
```
### 6. API集成层
#### 工作流API (web/core/workflow/api.ts)
```typescript
// 工作流调试API
export const postWorkflowDebug = (data: PostWorkflowDebugProps) =>
POST<PostWorkflowDebugResponse>(
'/core/workflow/debug',
{ ...data, mode: 'debug' },
{ timeout: 300000 }
);
// 支持的API操作
- 工作流调试和测试
- 节点模板获取
- 插件配置管理
- 版本控制操作
```
#### 数据适配器
```typescript
// 数据转换适配
- storeNode2FlowNode: 存储节点 → Flow节点
- storeEdge2RenderEdge: 存储边 → 渲染边
- uiWorkflow2StoreWorkflow: UI工作流 → 存储格式
- adaptCatchError: 错误处理适配
```
### 7. 交互逻辑设计
#### 键盘快捷键 (hooks/useKeyboard.tsx)
```typescript
const keyboardShortcuts = {
'Ctrl+Z': undo, // 撤销
'Ctrl+Y': redo, // 重做
'Ctrl+S': saveWorkflow, // 保存工作流
'Delete': deleteSelectedNodes, // 删除选中节点
'Escape': cancelCurrentOperation, // 取消当前操作
};
```
#### 节点操作
- **拖拽创建**: 从模板拖拽创建节点
- **连线操作**: 节点间的连接管理
- **批量操作**: 多选节点的批量编辑
- **右键菜单**: 上下文操作菜单
- **搜索定位**: 节点搜索和快速定位
#### 版本控制
```typescript
// 快照系统
interface WorkflowSnapshotsType {
nodes: Node[];
edges: Edge[];
chatConfig: AppChatConfigType;
title: string;
isSaved?: boolean;
}
```
- **自动快照**: 节点变更时自动保存快照
- **版本历史**: 支持多版本切换
- **云端同步**: 与服务端版本同步
- **协作支持**: 团队协作版本管理
### 8. 性能优化策略
#### 渲染优化
```typescript
// 动态加载节点组件
const nodeTypes: Record<FlowNodeTypeEnum, any> = {
[FlowNodeTypeEnum.workflowStart]: dynamic(() => import('./nodes/NodeWorkflowStart')),
[FlowNodeTypeEnum.httpRequest468]: dynamic(() => import('./nodes/NodeHttp')),
// ... 按需加载
};
```
- **懒加载**: 节点组件按需动态加载
- **虚拟化**: 大型工作流的虚拟渲染
- **防抖操作**: 频繁操作的性能优化
- **缓存策略**: 模板和数据的缓存机制
#### 状态优化
- **Context分割**: 避免不必要的重渲染
- **useMemo/useCallback**: 优化计算和函数创建
- **选择器模式**: 精确订阅状态变化
- **批量更新**: 合并多个状态更新
### 9. 扩展性设计
#### 插件系统
```typescript
// 节点模板扩展
interface NodeTemplateListItemType {
id: string;
flowNodeType: FlowNodeTypeEnum;
templateType: string;
avatar?: string;
name: string;
intro?: string;
isTool?: boolean;
pluginId?: string;
}
```
- **自定义节点**: 支持第三方节点开发
- **模板市场**: 节点模板的共享和分发
- **插件生态**: 丰富的节点插件生态
- **开放API**: 标准化的节点开发接口
#### 主题定制
- **节点样式**: 可定制的节点外观
- **连线样式**: 自定义连线类型和颜色
- **布局配置**: 多种布局算法支持
- **国际化**: 多语言界面支持

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +0,0 @@
# 服务端资源版本 ID 缓存方案
## 背景
FastGPT 会采用多节点部署方式,有部分数据缓存会存储在内存里。当需要使用这部分数据时(不管是通过 API 获取,还是后端服务自己获取),都是直接拉取内存数据,这可能会导致数据不一致问题,尤其是用户通过 API 更新数据后再获取,就容易获取未修改数据的节点。
## 解决方案
1. 给每一个缓存数据加上一个版本 ID。
2. 获取该数据时候,不直接引用该数据,而是通过一个 function 获取,该 function 可选的传入一个 versionId。
3. 获取数据时,先检查该 versionId 与 redis 中资源版本id 与传入的 versionId 是否一致。
4. 如果数据一致,则直接返回数据。
5. 如果数据不一致,则重新获取数据,并返回最新的 versionId。调用方则需要更新其缓存的 versionId。
## 代码方案
* 获取和更新缓存的代码,直接复用 FastGPT/packages/service/common/redis/cache.ts
* 每个资源,自己维护一个 cacheKey
* 每次更新资源/触发拉取最新资源时,都需要更新 cacheKey 的值。
## 涉及的业务
* [ ] FastGPT/projects/app/src/pages/api/common/system/getInitData.ts获取初始数据

View File

@ -1,15 +0,0 @@
# 背景
一个通用表格多选 hook/component, 它可以实现表格每一行数据的选择,并且在触发一次选择后,会有特殊的按键进行批量操作。
# 具体描述
当有一行被选中时,底部会出现悬浮层,可以进行批量操作(具体有哪些批量操作由外部决定)
![alt text](./image-1.png)
# 预期封装
1. 选中的值存储在 hook 里,便于判断是否触发底部悬浮层
2. 悬浮层外层 Box 在 hook 里child 由调用组件实现
3. FastGPT/packages/web/hooks/useTableMultipleSelect.tsx 在这个文件下实现

File diff suppressed because it is too large Load Diff

View File

@ -10,3 +10,4 @@ README.md
yalc.lock
testApi/
*.local.*
*.local

2
.gitignore vendored
View File

@ -38,3 +38,5 @@ files/helm/fastgpt/charts/*.tgz
tmp/
coverage
document/.source
projects/app/worker/

179
CLAUDE.md
View File

@ -1,116 +1,121 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指导说明。
## Project Overview
## 输出要求
FastGPT is an AI Agent construction platform providing out-of-the-box data processing, model invocation capabilities, and visual workflow orchestration through Flow. This is a full-stack TypeScript application built on NextJS with MongoDB/PostgreSQL backends.
1. 输出语言:中文
2. 输出的设计文档位置:.claude/design以 Markdown 文件为主。
3. 输出 Plan 时,均需写入 .claude/plan 目录下,以 Markdown 文件为主。
**Tech Stack**: NextJS + TypeScript + ChakraUI + MongoDB + PostgreSQL (PG Vector)/Milvus
## 项目概述
## Architecture
FastGPT 是一个 AI Agent 构建平台,通过 Flow 提供开箱即用的数据处理、模型调用能力和可视化工作流编排。这是一个基于 NextJS 构建的全栈 TypeScript 应用,后端使用 MongoDB/PostgreSQL。
This is a monorepo using pnpm workspaces with the following key structure:
**技术栈**: NextJS + TypeScript + ChakraUI + MongoDB + PostgreSQL (PG Vector)/Milvus
### Packages (Library Code)
- `packages/global/` - Shared types, constants, utilities used across all projects
- `packages/service/` - Backend services, database schemas, API controllers, workflow engine
- `packages/web/` - Shared frontend components, hooks, styles, i18n
- `packages/templates/` - Application templates for the template market
## 架构
### Projects (Applications)
- `projects/app/` - Main NextJS web application (frontend + API routes)
- `projects/sandbox/` - NestJS code execution sandbox service
- `projects/mcp_server/` - Model Context Protocol server implementation
这是一个使用 pnpm workspaces 的 monorepo,主要结构如下:
### Key Directories
- `document/` - Documentation site (NextJS app with content)
- `plugins/` - External plugins (models, crawlers, etc.)
- `deploy/` - Docker and Helm deployment configurations
- `test/` - Centralized test files and utilities
### Packages (库代码)
- `packages/global/` - 所有项目共享的类型、常量、工具函数
- `packages/service/` - 后端服务、数据库模型、API 控制器、工作流引擎
- `packages/web/` - 共享的前端组件、hooks、样式、国际化
- `packages/templates/` - 模板市场的应用模板
## Development Commands
### Projects (应用程序)
- `projects/app/` - 主 NextJS Web 应用(前端 + API 路由)
- `projects/sandbox/` - NestJS 代码执行沙箱服务
- `projects/mcp_server/` - Model Context Protocol 服务器实现
### Main Commands (run from project root)
- `pnpm dev` - Start development for all projects (uses package.json workspace scripts)
- `pnpm build` - Build all projects
- `pnpm test` - Run tests using Vitest
- `pnpm test:workflow` - Run workflow-specific tests
- `pnpm lint` - Run ESLint across all TypeScript files with auto-fix
- `pnpm format-code` - Format code using Prettier
### 关键目录
- `document/` - 文档站点(NextJS 应用及内容)
- `plugins/` - 外部插件(模型、爬虫等)
- `deploy/` - Docker 和 Helm 部署配置
- `test/` - 集中的测试文件和工具
### Project-Specific Commands
**Main App (projects/app/)**:
- `cd projects/app && pnpm dev` - Start NextJS dev server
- `cd projects/app && pnpm build` - Build NextJS app
- `cd projects/app && pnpm start` - Start production server
## 开发命令
**Sandbox (projects/sandbox/)**:
- `cd projects/sandbox && pnpm dev` - Start NestJS dev server with watch mode
- `cd projects/sandbox && pnpm build` - Build NestJS app
- `cd projects/sandbox && pnpm test` - Run Jest tests
### 主要命令(从项目根目录运行)
- `pnpm dev` - 启动所有项目的开发环境(使用 package.json 的 workspace 脚本)
- `pnpm build` - 构建所有项目
- `pnpm test` - 使用 Vitest 运行测试
- `pnpm test:workflow` - 运行工作流相关测试
- `pnpm lint` - 对所有 TypeScript 文件运行 ESLint 并自动修复
- `pnpm format-code` - 使用 Prettier 格式化代码
**MCP Server (projects/mcp_server/)**:
- `cd projects/mcp_server && bun dev` - Start with Bun in watch mode
- `cd projects/mcp_server && bun build` - Build MCP server
- `cd projects/mcp_server && bun start` - Start MCP server
### 项目专用命令
**主应用 (projects/app/)**:
- `cd projects/app && pnpm dev` - 启动 NextJS 开发服务器
- `cd projects/app && pnpm build` - 构建 NextJS 应用
- `cd projects/app && pnpm start` - 启动生产服务器
### Utility Commands
- `pnpm create:i18n` - Generate i18n translation files
- `pnpm api:gen` - Generate OpenAPI documentation
- `pnpm initIcon` - Initialize icon assets
- `pnpm gen:theme-typings` - Generate Chakra UI theme typings
**沙箱 (projects/sandbox/)**:
- `cd projects/sandbox && pnpm dev` - 以监视模式启动 NestJS 开发服务器
- `cd projects/sandbox && pnpm build` - 构建 NestJS 应用
- `cd projects/sandbox && pnpm test` - 运行 Jest 测试
## Testing
**MCP 服务器 (projects/mcp_server/)**:
- `cd projects/mcp_server && bun dev` - 使用 Bun 以监视模式启动
- `cd projects/mcp_server && bun build` - 构建 MCP 服务器
- `cd projects/mcp_server && bun start` - 启动 MCP 服务器
The project uses Vitest for testing with coverage reporting. Key test commands:
- `pnpm test` - Run all tests
- `pnpm test:workflow` - Run workflow tests specifically
- Test files are located in `test/` directory and `projects/app/test/`
- Coverage reports are generated in `coverage/` directory
### 工具命令
- `pnpm create:i18n` - 生成国际化翻译文件
- `pnpm api:gen` - 生成 OpenAPI 文档
- `pnpm initIcon` - 初始化图标资源
- `pnpm gen:theme-typings` - 生成 Chakra UI 主题类型定义
## Code Organization Patterns
## 测试
### Monorepo Structure
- Shared code lives in `packages/` and is imported using workspace references
- Each project in `projects/` is a standalone application
- Use `@fastgpt/global`, `@fastgpt/service`, `@fastgpt/web` imports for shared packages
项目使用 Vitest 进行测试并生成覆盖率报告。主要测试命令:
- `pnpm test` - 运行所有测试
- `pnpm test:workflow` - 专门运行工作流测试
- 测试文件位于 `test/` 目录和 `projects/app/test/`
- 覆盖率报告生成在 `coverage/` 目录
### API Structure
- NextJS API routes in `projects/app/src/pages/api/`
- Core business logic in `packages/service/core/`
- Database schemas in `packages/service/` with MongoDB/Mongoose
## 代码组织模式
### Frontend Architecture
- React components in `projects/app/src/components/` and `packages/web/components/`
- Chakra UI for styling with custom theme in `packages/web/styles/theme.ts`
- i18n support with files in `packages/web/i18n/`
- State management using React Context and Zustand
### Monorepo 结构
- 共享代码存放在 `packages/` 中,通过 workspace 引用导入
- `projects/` 中的每个项目都是独立的应用程序
- 使用 `@fastgpt/global`、`@fastgpt/service`、`@fastgpt/web` 导入共享包
### Workflow System
- Visual workflow editor using ReactFlow
- Workflow engine in `packages/service/core/workflow/`
- Node definitions in `packages/global/core/workflow/template/`
- Dispatch system for executing workflow nodes
### API 结构
- NextJS API 路由在 `projects/app/src/pages/api/`
- API 路由合约定义在`packages/global/openapi/`, 对应的
- 通用服务端业务逻辑在 `packages/service/`和`projects/app/src/service`
- 数据库模型在 `packages/service/` 中,使用 MongoDB/Mongoose
## Development Notes
### 前端架构
- React 组件在 `projects/app/src/components/``packages/web/components/`
- 使用 Chakra UI 进行样式设计,自定义主题在 `packages/web/styles/theme.ts`
- 国际化支持文件在 `packages/web/i18n/`
- 使用 React Context 和 Zustand 进行状态管理
- **Package Manager**: Uses pnpm with workspace configuration
- **Node Version**: Requires Node.js >=18.16.0, pnpm >=9.0.0
- **Database**: Supports MongoDB, PostgreSQL with pgvector, or Milvus for vector storage
- **AI Integration**: Supports multiple AI providers through unified interface
- **Internationalization**: Full i18n support for Chinese, English, and Japanese
## 开发注意事项
## Key File Patterns
- **包管理器**: 使用 pnpm 及 workspace 配置
- **Node 版本**: 需要 Node.js >=18.16.0, pnpm >=9.0.0
- **数据库**: 支持 MongoDB、带 pgvector 的 PostgreSQL 或 Milvus 向量存储
- **AI 集成**: 通过统一接口支持多个 AI 提供商
- **国际化**: 完整支持中文、英文和日文
- `.ts` and `.tsx` files use TypeScript throughout
- Database schemas use Mongoose with TypeScript
- API routes follow NextJS conventions
- Component files use React functional components with hooks
- Shared types defined in `packages/global/` with `.d.ts` files
## 关键文件模式
## Environment Configuration
- `.ts``.tsx` 文件全部使用 TypeScript
- 数据库模型使用 Mongoose 配合 TypeScript
- API 路由遵循 NextJS 约定
- 组件文件使用 React 函数式组件和 hooks
- 共享类型定义在 `packages/global/``.d.ts` 文件中
- Configuration files in `projects/app/data/config.json`
- Environment-specific configs supported
- Model configurations in `packages/service/core/ai/config/`
## 环境配置
- 配置文件在 `projects/app/data/config.json`
- 支持特定环境配置
- 模型配置在 `packages/service/core/ai/config/`
## 代码规范
- 尽可能使用 type 进行类型声明,而不是 interface。

View File

@ -21,5 +21,5 @@ ifeq ($(proxy), taobao)
else ifeq ($(proxy), clash)
docker build -f $(filePath) -t $(image) . --network host --build-arg HTTP_PROXY=http://127.0.0.1:7890 --build-arg HTTPS_PROXY=http://127.0.0.1:7890
else
docker build -f $(filePath) -t $(image) .
docker build --progress=plain -f $(filePath) -t $(image) .
endif

View File

@ -3,18 +3,47 @@ title: 'V4.13.2(进行中)'
description: 'FastGPT V4.13.2 更新说明'
---
# 更新指南
## 增加 FastGPT/FastGPT-pro 环境变量
```
S3_PUBLIC_BUCKET=S3公开桶名称公开读私有写
```
## 🚀 新增内容
1. HTTP 工具集支持手动创建模式。
2. 项目 OpenAPI 框架引入。
3. APIKey 有效性检测接口。
4. 导出对话日志,末尾跟随当前版本全局变量。
## ⚙️ 优化
1. 非管理员无法看到团队审计日志。
2. 引入 S3 用于存储应用头像。
3. 工作流画布性能。
## 🐛 修复
1. LLM 模型默认支持图片,导致请求错误。
2. Mongo 多副本切换时候watch 未重新触发。
3. 文本分块,所有策略用完后,未处理 LastText 数据。
4. 变量输入框number=0 时,无法通过校验。
5. 工作流复杂循环并行判断异常。
## 🔨 插件更新
1. Perplexity search 工具。
1. 新增Perplexity search 工具。
2. 新增Base64转文件工具。
3. 新增MiniMax TTS 文件生成工具。
4. 新增Openrouter nano banana 绘图工具。
5. 新增Redis 缓存操作工具。
6. 新增Tavily search 工具。
7. 新增:硅基流动 qwen-image 和 qwen-image-edit 工具。
8. 新增:飞书多维表格操作套件。
9. 新增Youtube 字幕提取。
10. 新增:阿里百炼 qwen image edit。
11. 新增Markdown 转 PPT 工具。
12. 新增whisper 语音转文字工具。
13. 系统工具支持配置是否需要在 Worker 中运行。

View File

@ -113,7 +113,7 @@
"document/content/docs/upgrading/4-12/4124.mdx": "2025-09-17T22:29:56+08:00",
"document/content/docs/upgrading/4-13/4130.mdx": "2025-09-30T16:00:10+08:00",
"document/content/docs/upgrading/4-13/4131.mdx": "2025-09-30T15:47:06+08:00",
"document/content/docs/upgrading/4-13/4132.mdx": "2025-10-09T15:10:19+08:00",
"document/content/docs/upgrading/4-13/4132.mdx": "2025-10-17T21:40:12+08:00",
"document/content/docs/upgrading/4-8/40.mdx": "2025-08-02T19:38:37+08:00",
"document/content/docs/upgrading/4-8/41.mdx": "2025-08-02T19:38:37+08:00",
"document/content/docs/upgrading/4-8/42.mdx": "2025-08-02T19:38:37+08:00",

View File

@ -4,9 +4,6 @@ export type preUploadImgProps = OutLinkChatAuthProps & {
// expiredTime?: Date;
metadata?: Record<string, any>;
};
export type UploadImgProps = preUploadImgProps & {
base64Img: string;
};
export type UrlFetchParams = {
urlList: string[];

View File

@ -0,0 +1,6 @@
export type S3TtlSchemaType = {
_id: string;
bucketName: string;
minioKey: string;
expiredTime: Date;
};

View File

@ -295,17 +295,21 @@ const commonSplit = (props: SplitProps): SplitResponse => {
const isMarkdownStep = checkIsMarkdownSplit(step);
const isCustomStep = checkIsCustomStep(step);
const forbidConcat = isCustomStep; // forbid=true时候lastText肯定为空
const textLength = getTextValidLength(text);
// Over step
if (step >= stepReges.length) {
if (textLength < maxSize) {
return [text];
// Merge lastText with current text to prevent data loss
const combinedText = lastText + text;
const combinedLength = getTextValidLength(combinedText);
if (combinedLength < maxSize) {
return [combinedText];
}
// use slice-chunkSize to split text
// Note: Use combinedText.length for slicing, not combinedLength
const chunks: string[] = [];
for (let i = 0; i < textLength; i += chunkSize - overlapLen) {
chunks.push(text.slice(i, i + chunkSize));
for (let i = 0; i < combinedText.length; i += chunkSize - overlapLen) {
chunks.push(combinedText.slice(i, i + chunkSize));
}
return chunks;
}

View File

@ -1,3 +1,7 @@
export const getTextValidLength = (chunk: string) => {
return chunk.replaceAll(/[\s\n]/g, '').length;
};
export const isObjectId = (str: string) => {
return /^[0-9a-fA-F]{24}$/.test(str);
};

View File

@ -0,0 +1,6 @@
import { z } from 'zod';
export const ObjectIdSchema = z
.string()
.regex(/^[0-9a-fA-F]{24}$/)
.meta({ example: '68ee0bd23d17260b7829b137', description: 'ObjectId' });

View File

@ -13,9 +13,9 @@ import { i18nT } from '../../../../web/i18n/utils';
export const getHTTPToolSetRuntimeNode = ({
name,
avatar,
baseUrl = '',
customHeaders = '',
apiSchemaStr = '',
baseUrl,
customHeaders,
apiSchemaStr,
toolList = [],
headerSecret
}: {
@ -34,12 +34,11 @@ export const getHTTPToolSetRuntimeNode = ({
intro: 'HTTP Tools',
toolConfig: {
httpToolSet: {
baseUrl,
toolList,
headerSecret,
customHeaders,
apiSchemaStr,
toolId: ''
...(baseUrl !== undefined && { baseUrl }),
...(apiSchemaStr !== undefined && { apiSchemaStr }),
...(customHeaders !== undefined && { customHeaders }),
...(headerSecret !== undefined && { headerSecret })
}
},
inputs: [],

View File

@ -5,6 +5,7 @@ import SwaggerParser from '@apidevtools/swagger-parser';
import yaml from 'js-yaml';
import type { OpenAPIV3 } from 'openapi-types';
import type { OpenApiJsonSchema } from './httpTools/type';
import { i18nT } from '../../../web/i18n/utils';
type SchemaInputValueType = 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object';
export type JsonSchemaPropertiesItemType = {
@ -180,7 +181,7 @@ export const str2OpenApiSchema = async (yamlStr = ''): Promise<OpenApiJsonSchema
.filter(Boolean) as OpenApiJsonSchema['pathData'];
return { pathData, serverPath };
} catch (err) {
throw new Error('Invalid Schema');
return Promise.reject(i18nT('common:plugin.Invalid Schema'));
}
};
export const getSchemaValueType = (schema: { type: string; items?: { type: string } }) => {

View File

@ -2,6 +2,7 @@ import type { FlowNodeTemplateType, StoreNodeItemType } from '../workflow/type/n
import type { AppTypeEnum } from './constants';
import { PermissionTypeEnum } from '../../support/permission/constant';
import type {
ContentTypes,
NodeInputKeyEnum,
VariableInputEnum,
WorkflowIOValueTypeEnum
@ -127,6 +128,16 @@ export type HttpToolConfigType = {
outputSchema: JSONSchemaOutputType;
path: string;
method: string;
// manual
staticParams?: Array<{ key: string; value: string }>;
staticHeaders?: Array<{ key: string; value: string }>;
staticBody?: {
type: ContentTypes;
content?: string;
formData?: Array<{ key: string; value: string }>;
};
headerSecret?: StoreSecretValueType;
};
/* app chat config type */

View File

@ -1,18 +0,0 @@
export type ChatFavouriteAppSchema = {
_id: string;
teamId: string;
appId: string;
favouriteTags: string[]; // tag id list
order: number;
};
export type ChatFavouriteAppUpdateParams = {
appId: string;
order: number;
};
export type ChatFavouriteApp = ChatFavouriteAppSchema & {
name: string;
avatar: string;
intro: string;
};

View File

@ -0,0 +1,30 @@
import { z } from 'zod';
import { ObjectIdSchema } from '../../../common/type/mongo';
export const ChatFavouriteTagSchema = z.object({
id: z.string().meta({ example: 'ptqn6v4I', description: '精选应用标签 ID' }),
name: z.string().meta({ example: '效率', description: '精选应用标签名称' })
});
export type ChatFavouriteTagType = z.infer<typeof ChatFavouriteTagSchema>;
export const ChatFavouriteAppModelSchema = z.object({
_id: ObjectIdSchema,
teamId: ObjectIdSchema,
appId: ObjectIdSchema,
favouriteTags: z
.array(z.string())
.meta({ example: ['ptqn6v4I', 'jHLWiqff'], description: '精选应用标签' }),
order: z.number().meta({ example: 1, description: '排序' })
});
export type ChatFavouriteAppModelType = z.infer<typeof ChatFavouriteAppModelSchema>;
export const ChatFavouriteAppSchema = z.object({
...ChatFavouriteAppModelSchema.shape,
name: z.string().meta({ example: 'Jina 网页阅读', description: '精选应用名称' }),
intro: z.string().optional().meta({ example: '', description: '精选应用简介' }),
avatar: z.string().optional().meta({
example: '/api/system/img/avatar/68ad85a7463006c963799a05/79183cf9face95d336816f492409ed29',
description: '精选应用头像'
})
});
export type ChatFavouriteAppType = z.infer<typeof ChatFavouriteAppSchema>;

View File

@ -1,37 +0,0 @@
export type ChatSettingSchema = {
_id: string;
appId: string;
teamId: string;
slogan: string;
dialogTips: string;
enableHome: boolean;
homeTabTitle: string;
wideLogoUrl?: string;
squareLogoUrl?: string;
selectedTools: {
pluginId: string;
inputs?: Record<`${NodeInputKeyEnum}` | string, any>;
}[];
quickAppIds: string[];
favouriteTags: {
id: string;
name: string;
}[];
};
export type ChatSettingUpdateParams = Partial<Omit<ChatSettingSchema, '_id' | 'appId' | 'teamId'>>;
export type QuickAppType = { _id: string; name: string; avatar: string };
export type ChatFavouriteTagType = ChatSettingSchema['favouriteTags'][number];
export type SelectedToolType = ChatSettingSchema['selectedTools'][number] & {
name: string;
avatar: string;
};
export type ChatSettingReturnType =
| (Omit<ChatSettingSchema, 'quickAppIds' | 'selectedTools'> & {
quickAppList: QuickAppType[];
selectedTools: SelectedToolType[];
})
| undefined;

View File

@ -0,0 +1,77 @@
import { ObjectIdSchema } from '../../../common/type/mongo';
import { z } from 'zod';
import { ChatFavouriteTagSchema } from '../favouriteApp/type';
export const ChatSelectedToolSchema = z.object({
pluginId: ObjectIdSchema,
inputs: z.record(z.string(), z.any()).meta({ example: null, description: '工具输入参数' }),
name: z.string().meta({ example: '测试应用', description: '工具名称' }),
avatar: z.string().meta({ example: '测试应用', description: '工具头像' })
});
export type ChatSelectedToolType = z.infer<typeof ChatSelectedToolSchema>;
export const ChatQuickAppSchema = z.object({
_id: ObjectIdSchema,
name: z.string().meta({ example: '测试应用', description: '快捷应用名称' }),
avatar: z.string().meta({ example: '测试应用', description: '快捷应用头像' })
});
export type ChatQuickAppType = z.infer<typeof ChatQuickAppSchema>;
export const ChatSettingModelSchema = z.object({
_id: ObjectIdSchema,
appId: ObjectIdSchema,
teamId: ObjectIdSchema,
slogan: z
.string()
.optional()
.meta({ example: '你好👋,我是 FastGPT ! 请问有什么可以帮你?', description: 'Slogan' }),
dialogTips: z
.string()
.optional()
.meta({ example: '你可以问我任何问题', description: '对话提示' }),
enableHome: z.boolean().optional().meta({ example: true, description: '是否启用首页' }),
homeTabTitle: z.string().optional().meta({ example: 'FastGPT', description: '首页标签' }),
wideLogoUrl: z.string().optional().meta({
example: '/api/system/img/avatar/68ad85a7463006c963799a05/79183cf9face95d336816f492409ed29',
description: '宽 LOGO'
}),
squareLogoUrl: z.string().optional().meta({
example: '/api/system/img/avatar/68ad85a7463006c963799a05/79183cf9face95d336816f492409ed29',
description: '方 LOGO'
}),
quickAppIds: z
.array(ObjectIdSchema)
.meta({ example: ['68ad85a7463006c963799a05'], description: '快捷应用 ID 列表' }),
selectedTools: z.array(ChatSelectedToolSchema.pick({ pluginId: true, inputs: true })).meta({
example: [{ pluginId: '68ad85a7463006c963799a05', inputs: {} }],
description: '已选工具列表'
}),
favouriteTags: z.array(ChatFavouriteTagSchema).meta({
example: [
{ id: 'ptqn6v4I', name: '效率' },
{ id: 'jHLWiqff', name: '学习' }
],
description: '精选应用标签列表'
})
});
export type ChatSettingModelType = z.infer<typeof ChatSettingModelSchema>;
export const ChatSettingSchema = z.object({
...ChatSettingModelSchema.omit({ quickAppIds: true }).shape,
quickAppList: z.array(ChatQuickAppSchema).meta({
example: [{ _id: '68ad85a7463006c963799a05', name: '测试应用', avatar: '测试应用' }],
description: '快捷应用列表'
}),
selectedTools: z.array(ChatSelectedToolSchema).meta({
example: [
{
pluginId: '68ad85a7463006c963799a05',
inputs: {},
name: '获取当前应用',
avatar: '/icon/logo.svg'
}
],
description: '已选工具列表'
})
});
export type ChatSettingType = z.infer<typeof ChatSettingSchema>;

View File

@ -477,6 +477,19 @@ export enum ContentTypes {
raw = 'raw-text'
}
export const contentTypeMap = {
[ContentTypes.none]: '',
[ContentTypes.formData]: '',
[ContentTypes.xWwwFormUrlencoded]: 'application/x-www-form-urlencoded',
[ContentTypes.json]: 'application/json',
[ContentTypes.xml]: 'application/xml',
[ContentTypes.raw]: 'text/plain'
};
// http request methods
export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const;
export type HttpMethod = (typeof HTTP_METHODS)[number];
export const ArrayTypeMap: Record<WorkflowIOValueTypeEnum, WorkflowIOValueTypeEnum> = {
[WorkflowIOValueTypeEnum.string]: WorkflowIOValueTypeEnum.arrayString,
[WorkflowIOValueTypeEnum.number]: WorkflowIOValueTypeEnum.arrayNumber,

View File

@ -21,17 +21,6 @@ import { isValidReferenceValueFormat } from '../utils';
import type { RuntimeEdgeItemType, RuntimeNodeItemType } from './type';
import { isSecretValue } from '../../../common/secret/utils';
export const checkIsBranchNode = (node: RuntimeNodeItemType) => {
if (node.catchError) return true;
const map: Record<any, boolean> = {
[FlowNodeTypeEnum.classifyQuestion]: true,
[FlowNodeTypeEnum.userSelect]: true,
[FlowNodeTypeEnum.ifElseNode]: true
};
return !!map[node.flowNodeType];
};
export const extractDeepestInteractive = (
interactive: WorkflowInteractiveResponseType
): WorkflowInteractiveResponseType => {
@ -306,45 +295,50 @@ export const checkNodeRunStatus = ({
}) => {
const filterRuntimeEdges = filterWorkflowEdges(runtimeEdges);
const isStartNode = (nodeType: string) => {
const map: Record<any, boolean> = {
[FlowNodeTypeEnum.workflowStart]: true,
[FlowNodeTypeEnum.pluginInput]: true
};
return !!map[nodeType];
};
const splitNodeEdges = (targetNode: RuntimeNodeItemType) => {
const commonEdges: RuntimeEdgeItemType[] = [];
const recursiveEdgeGroupsMap = new Map<string, RuntimeEdgeItemType[]>();
const getEdgeLastBranchHandle = ({
startEdge,
targetNodeId
}: {
startEdge: RuntimeEdgeItemType;
targetNodeId: string;
}): string | '' | undefined => {
const sourceEdges = filterRuntimeEdges.filter((item) => item.target === targetNode.nodeId);
sourceEdges.forEach((sourceEdge) => {
const stack: Array<{
edge: RuntimeEdgeItemType;
visited: Set<string>;
lasestBranchHandle?: string;
}> = [
{
edge: startEdge,
visited: new Set([targetNodeId])
edge: sourceEdge,
visited: new Set([targetNode.nodeId])
}
];
const MAX_DEPTH = 3000;
let iterations = 0;
while (stack.length > 0 && iterations < MAX_DEPTH) {
iterations++;
const { edge, visited, lasestBranchHandle } = stack.pop()!;
const { edge, visited } = stack.pop()!;
// Circle
// Start node
const sourceNode = nodesMap.get(edge.source);
if (!sourceNode) continue;
if (isStartNode(sourceNode.flowNodeType)) {
commonEdges.push(sourceEdge);
continue;
}
// Circle detected
if (edge.source === targetNode.nodeId) {
// 检查自身是否为分支节点
const node = nodesMap.get(edge.source);
if (!node) return '';
const isBranch = checkIsBranchNode(node);
if (isBranch) return edge.sourceHandle;
// 检测到环,并且环中包含当前节点. 空字符代表是一个无分支循环,属于死循环,则忽略这个边。
return lasestBranchHandle ?? '';
recursiveEdgeGroupsMap.set(edge.target, [
...(recursiveEdgeGroupsMap.get(edge.target) || []),
sourceEdge
]);
continue;
}
if (visited.has(edge.source)) {
@ -357,42 +351,12 @@ export const checkNodeRunStatus = ({
// 查找目标节点的 source edges 并加入栈中
const nextEdges = filterRuntimeEdges.filter((item) => item.target === edge.source);
for (const nextEdge of nextEdges) {
const node = nodesMap.get(nextEdge.target);
if (!node) continue;
const isBranch = checkIsBranchNode(node);
stack.push({
edge: nextEdge,
visited: newVisited,
lasestBranchHandle: isBranch ? edge.sourceHandle : lasestBranchHandle
visited: newVisited
});
}
}
return;
};
const sourceEdges = filterRuntimeEdges.filter((item) => item.target === targetNode.nodeId);
sourceEdges.forEach((edge) => {
const lastBranchHandle = getEdgeLastBranchHandle({
startEdge: edge,
targetNodeId: targetNode.nodeId
});
// 无效的循环,这条边则忽略
if (lastBranchHandle === '') return;
// 有效循环,则加入递归组
if (lastBranchHandle) {
recursiveEdgeGroupsMap.set(lastBranchHandle, [
...(recursiveEdgeGroupsMap.get(lastBranchHandle) || []),
edge
]);
}
// 无循环的连线,则加入普通组
else {
commonEdges.push(edge);
}
});
return { commonEdges, recursiveEdgeGroups: Array.from(recursiveEdgeGroupsMap.values()) };

View File

@ -52,11 +52,10 @@ export type NodeToolConfigType = {
}[];
};
httpToolSet?: {
toolId: string;
baseUrl: string;
toolList: HttpToolConfigType[];
apiSchemaStr: string;
customHeaders: string;
baseUrl?: string;
apiSchemaStr?: string;
customHeaders?: string;
headerSecret?: StoreSecretValueType;
};
httpTool?: {

View File

@ -69,8 +69,8 @@ export const checkInputIsReference = (input: FlowNodeInputItemType) => {
};
/* node */
export const getGuideModule = (modules: StoreNodeItemType[]) =>
modules.find(
export const getGuideModule = (nodes: StoreNodeItemType[]) =>
nodes.find(
(item) =>
item.flowNodeType === FlowNodeTypeEnum.systemConfig ||
// @ts-ignore (adapt v1)

View File

@ -0,0 +1,19 @@
import { z } from 'zod';
import { ObjectIdSchema } from '../../../../common/type/mongo';
export const GetChatFavouriteListParamsSchema = z.object({
name: z.string().optional().meta({ example: '测试应用', description: '精选应用名称' }),
tag: z.string().optional().meta({ example: '效率', description: '精选应用标签' })
});
export type GetChatFavouriteListParamsType = z.infer<typeof GetChatFavouriteListParamsSchema>;
export const UpdateFavouriteAppTagsParamsSchema = z.object({
id: ObjectIdSchema.meta({ example: '68ad85a7463006c963799a05', description: '精选应用 ID' }),
tags: z.array(z.string()).meta({ example: ['效率', '工具'], description: '精选应用标签' })
});
export const UpdateFavouriteAppParamsSchema = z.object({
appId: ObjectIdSchema.meta({ example: '68ad85a7463006c963799a05', description: '精选应用 ID' }),
order: z.number().meta({ example: 1, description: '排序' })
});
export type UpdateFavouriteAppParamsType = z.infer<typeof UpdateFavouriteAppParamsSchema>;

View File

@ -0,0 +1,134 @@
import { z } from 'zod';
import type { OpenAPIPath } from '../../../type';
import { ChatFavouriteAppSchema } from '../../../../core/chat/favouriteApp/type';
import {
GetChatFavouriteListParamsSchema,
UpdateFavouriteAppParamsSchema,
UpdateFavouriteAppTagsParamsSchema
} from './api';
import { ObjectIdSchema } from '../../../../common/type/mongo';
export const ChatFavouriteAppPath: OpenAPIPath = {
'/proApi/core/chat/setting/favourite/list': {
get: {
summary: '获取精选应用列表',
description: '获取团队配置的精选应用列表,支持按名称和标签筛选',
tags: ['对话页配置'],
requestParams: {
query: GetChatFavouriteListParamsSchema
},
responses: {
200: {
description: '成功返回精选应用列表',
content: {
'application/json': {
schema: z.array(ChatFavouriteAppSchema)
}
}
}
}
}
},
'/proApi/core/chat/setting/favourite/update': {
post: {
summary: '更新精选应用',
description: '批量创建或更新精选应用配置,包括应用 ID、标签和排序信息',
tags: ['对话页配置'],
requestBody: {
content: {
'application/json': {
schema: z.array(UpdateFavouriteAppParamsSchema)
}
}
},
responses: {
200: {
description: '成功更新精选应用',
content: {
'application/json': {
schema: z.array(ChatFavouriteAppSchema)
}
}
}
}
}
},
'/proApi/core/chat/setting/favourite/order': {
put: {
summary: '更新精选应用排序',
description: '批量更新精选应用的显示顺序',
tags: ['对话页配置'],
requestBody: {
content: {
'application/json': {
schema: z.array(
z.object({
id: ObjectIdSchema.meta({
example: '68ad85a7463006c963799a05',
description: '精选应用 ID'
}),
order: z.number().meta({ example: 1, description: '排序' })
})
)
}
}
},
responses: {
200: {
description: '成功更新精选应用排序',
content: {
'application/json': {
schema: z.null()
}
}
}
}
}
},
'/proApi/core/chat/setting/favourite/tags': {
put: {
summary: '更新精选应用标签',
description: '批量更新精选应用的标签分类',
tags: ['对话页配置'],
requestBody: {
content: {
'application/json': {
schema: z.array(UpdateFavouriteAppTagsParamsSchema)
}
}
},
responses: {
200: {
description: '成功更新精选应用标签',
content: {
'application/json': {
schema: z.null()
}
}
}
}
}
},
'/proApi/core/chat/setting/favourite/delete': {
delete: {
summary: '删除精选应用',
description: '根据 ID 删除指定的精选应用配置',
tags: ['对话页配置'],
requestParams: {
query: z.object({
id: ObjectIdSchema
})
},
responses: {
200: {
description: '成功删除精选应用',
content: {
'application/json': {
schema: z.null()
}
}
}
}
}
}
};

View File

@ -0,0 +1,7 @@
import { ChatSettingPath } from './setting';
import { ChatFavouriteAppPath } from './favourite/index';
export const ChatPath = {
...ChatSettingPath,
...ChatFavouriteAppPath
};

View File

@ -0,0 +1,48 @@
import type { OpenAPIPath } from '../../../type';
import { ChatSettingSchema, ChatSettingModelSchema } from '../../../../core/chat/setting/type';
export const ChatSettingPath: OpenAPIPath = {
'/proApi/core/chat/setting/detail': {
get: {
summary: '获取对话页设置',
description:
'获取当前团队的对话页设置,包括 slogan、对话提示、Logo、快捷应用、已选工具和精选应用标签等配置信息',
tags: ['对话页配置'],
responses: {
200: {
description: '成功返回对话页设置信息',
content: {
'application/json': {
schema: ChatSettingSchema
}
}
}
}
}
},
'/proApi/core/chat/setting/update': {
post: {
summary: '更新对话页设置',
description:
'更新团队的对话页设置配置,包括 slogan、对话提示、Logo、快捷应用、已选工具和精选应用标签等信息',
tags: ['对话页配置'],
requestBody: {
content: {
'application/json': {
schema: ChatSettingModelSchema.partial()
}
}
},
responses: {
200: {
description: '成功更新对话页设置',
content: {
'application/json': {
schema: ChatSettingSchema
}
}
}
}
}
}
};

View File

@ -0,0 +1,17 @@
import { createDocument } from 'zod-openapi';
import { ChatPath } from './core/chat';
import { ApiKeyPath } from './support/openapi';
export const openAPIDocument = createDocument({
openapi: '3.1.0',
info: {
title: 'FastGPT API',
version: '0.1.0',
description: 'FastGPT API 文档'
},
paths: {
...ChatPath,
...ApiKeyPath
},
servers: [{ url: '/api' }]
});

View File

@ -0,0 +1,13 @@
import { z } from 'zod';
export const ApiKeyHealthParamsSchema = z.object({
apiKey: z.string().nonempty()
});
export type ApiKeyHealthParamsType = z.infer<typeof ApiKeyHealthParamsSchema>;
export const ApiKeyHealthResponseSchema = z.object({
appId: z.string().optional().meta({
description: '如果有关联的应用会返回应用ID'
})
});
export type ApiKeyHealthResponseType = z.infer<typeof ApiKeyHealthResponseSchema>;

View File

@ -0,0 +1,33 @@
import { z } from 'zod';
import { formatSuccessResponse, getErrorResponse, type OpenAPIPath } from '../../type';
import { ApiKeyHealthParamsSchema, ApiKeyHealthResponseSchema } from './api';
export const ApiKeyPath: OpenAPIPath = {
'/support/openapi/health': {
get: {
summary: '检查 API Key 是否健康',
tags: ['APIKey'],
requestParams: {
query: ApiKeyHealthParamsSchema
},
responses: {
200: {
description: 'API Key 可用',
content: {
'application/json': {
schema: ApiKeyHealthResponseSchema
}
}
},
500: {
description: 'ApiKey错误',
content: {
'application/json': {
schema: z.object({ message: z.literal('APIKey invalid') })
}
}
}
}
}
}
};

View File

@ -0,0 +1,29 @@
import { z } from 'zod';
import type { createDocument } from 'zod-openapi';
export type OpenAPIPath = Parameters<typeof createDocument>[0]['paths'];
export const getErrorResponse = ({
code = 500,
statusText = 'error',
message = ''
}: {
code?: number;
statusText?: string;
message?: string;
}) => {
return z.object({
code: z.literal(code),
statusText: z.literal(statusText),
message: z.literal(message),
data: z.null().optional().default(null)
});
};
export const formatSuccessResponse = <T>(data: T) => {
return z.object({
code: z.literal(200),
statusText: z.string().optional().default(''),
message: z.string().optional().default(''),
data
});
};

View File

@ -17,7 +17,9 @@
"openai": "4.61.0",
"openapi-types": "^12.1.3",
"timezones-list": "^3.0.2",
"lodash": "^4.17.21"
"lodash": "^4.17.21",
"zod": "^4.1.12",
"zod-openapi": "^5.4.3"
},
"devDependencies": {
"@types/lodash": "^4.14.191",

View File

@ -1,4 +1,4 @@
import { type UploadImgProps } from '@fastgpt/global/common/file/api';
import { type preUploadImgProps } from '@fastgpt/global/common/file/api';
import { imageBaseUrl } from '@fastgpt/global/common/file/image/constants';
import { MongoImage } from './schema';
import { type ClientSession, Types } from '../../../common/mongo';
@ -18,7 +18,8 @@ export async function uploadMongoImg({
metadata,
shareId,
forever = false
}: UploadImgProps & {
}: preUploadImgProps & {
base64Img: string;
teamId: string;
forever?: Boolean;
}) {
@ -109,23 +110,6 @@ const getIdFromPath = (path?: string) => {
return id;
};
// 删除旧的头像,新的头像去除过期时间
export const refreshSourceAvatar = async (
path?: string,
oldPath?: string,
session?: ClientSession
) => {
const newId = getIdFromPath(path);
const oldId = getIdFromPath(oldPath);
if (!newId || newId === oldId) return;
await MongoImage.updateOne({ _id: newId }, { $unset: { expiredTime: 1 } }, { session });
if (oldId) {
await MongoImage.deleteOne({ _id: oldId }, { session });
}
};
export const removeImageByPath = (path?: string, session?: ClientSession) => {
if (!path) return;

View File

@ -123,12 +123,19 @@ export const getMongoLogModel = <T>(name: string, schema: mongoose.Schema) => {
};
const syncMongoIndex = async (model: Model<any>) => {
if (process.env.SYNC_INDEX !== '0' && process.env.NODE_ENV !== 'test') {
try {
model.syncIndexes({ background: true });
} catch (error) {
addLog.error('Create index error', error);
}
if (
process.env.NODE_ENV === 'test' ||
process.env.SYNC_INDEX === '0' ||
process.env.NEXT_PHASE === 'phase-production-build' ||
!MONGO_URL
) {
return;
}
try {
await model.syncIndexes({ background: true });
} catch (error) {
addLog.error('Create index error', error);
}
};

View File

@ -7,7 +7,13 @@ const maxConnecting = Math.max(30, Number(process.env.DB_MAX_LINK || 20));
/**
* connect MongoDB and init data
*/
export async function connectMongo(db: Mongoose, url: string): Promise<Mongoose> {
export async function connectMongo(props: {
db: Mongoose;
url: string;
connectedCb?: () => void;
}): Promise<Mongoose> {
const { db, url, connectedCb } = props;
/* Connecting, connected will return */
if (db.connection.readyState !== 0) {
return db;
@ -31,7 +37,7 @@ export async function connectMongo(db: Mongoose, url: string): Promise<Mongoose>
RemoveListeners();
await db.disconnect();
await delay(1000);
await connectMongo(db, url);
await connectMongo(props);
}
} catch (error) {}
});
@ -42,7 +48,7 @@ export async function connectMongo(db: Mongoose, url: string): Promise<Mongoose>
RemoveListeners();
await db.disconnect();
await delay(1000);
await connectMongo(db, url);
await connectMongo(props);
}
} catch (error) {}
});
@ -60,9 +66,11 @@ export async function connectMongo(db: Mongoose, url: string): Promise<Mongoose>
retryReads: true
};
db.connect(url, options);
await db.connect(url, options);
console.log('mongo connected');
connectedCb?.();
return db;
} catch (error) {
addLog.error('Mongo connect error', error);
@ -70,6 +78,6 @@ export async function connectMongo(db: Mongoose, url: string): Promise<Mongoose>
await db.disconnect();
await delay(1000);
return connectMongo(db, url);
return connectMongo(props);
}
}

View File

@ -12,6 +12,72 @@ export interface ResponseType<T = any> {
data: T;
}
export interface ProcessedError {
code: number;
statusText: string;
message: string;
data?: any;
shouldClearCookie: boolean;
}
/**
*
* @param params - URL和默认状态码的参数
* @returns
*/
export function processError(params: {
error: any;
url?: string;
defaultCode?: number;
}): ProcessedError {
const { error, url, defaultCode = 500 } = params;
const errResponseKey = typeof error === 'string' ? error : error?.message;
// 1. 处理特定的业务错误ERROR_RESPONSE
if (ERROR_RESPONSE[errResponseKey]) {
const shouldClearCookie = errResponseKey === ERROR_ENUM.unAuthorization;
// 记录业务侧错误日志
addLog.info(`Api response error: ${url}`, ERROR_RESPONSE[errResponseKey]);
return {
code: ERROR_RESPONSE[errResponseKey].code || defaultCode,
statusText: ERROR_RESPONSE[errResponseKey].statusText || 'error',
message: ERROR_RESPONSE[errResponseKey].message,
data: ERROR_RESPONSE[errResponseKey].data,
shouldClearCookie
};
}
// 2. 提取通用错误消息
let msg = error?.response?.statusText || error?.message || '请求错误';
if (typeof error === 'string') {
msg = error;
} else if (proxyError[error?.code]) {
msg = '网络连接异常';
} else if (error?.response?.data?.error?.message) {
msg = error?.response?.data?.error?.message;
} else if (error?.error?.message) {
msg = error?.error?.message;
}
// 3. 根据错误类型记录不同级别的日志
if (error instanceof UserError) {
addLog.info(`Request error: ${url}, ${msg}`);
} else {
addLog.error(`System unexpected error: ${url}, ${msg}`, error);
}
// 4. 返回处理后的错误信息
return {
code: defaultCode,
statusText: 'error',
message: replaceSensitiveText(msg),
shouldClearCookie: false
};
}
export const jsonRes = <T = any>(
res: NextApiResponse,
props?: {
@ -24,53 +90,30 @@ export const jsonRes = <T = any>(
) => {
const { code = 200, message = '', data = null, error, url } = props || {};
const errResponseKey = typeof error === 'string' ? error : error?.message;
// Specified error
if (ERROR_RESPONSE[errResponseKey]) {
// login is expired
if (errResponseKey === ERROR_ENUM.unAuthorization) {
// 如果有错误,使用统一的错误处理逻辑
if (error) {
const processedError = processError({ error, url, defaultCode: code });
// 如果需要清除 cookie
if (processedError.shouldClearCookie) {
clearCookie(res);
}
// Bussiness Side Error
addLog.info(`Api response error: ${url}`, ERROR_RESPONSE[errResponseKey]);
res.status(code);
if (message) {
res.send(message);
} else {
res.json(ERROR_RESPONSE[errResponseKey]);
}
res.status(500).json({
code: processedError.code,
statusText: processedError.statusText,
message: message || processedError.message,
data: processedError.data !== undefined ? processedError.data : null
});
return;
}
// another error
let msg = '';
if ((code < 200 || code >= 400) && !message) {
msg = error?.response?.statusText || error?.message || '请求错误';
if (typeof error === 'string') {
msg = error;
} else if (proxyError[error?.code]) {
msg = '网络连接异常';
} else if (error?.response?.data?.error?.message) {
msg = error?.response?.data?.error?.message;
} else if (error?.error?.message) {
msg = error?.error?.message;
}
if (error instanceof UserError) {
addLog.info(`Request error: ${url}, ${msg}`);
} else {
addLog.error(`System unexpected error: ${url}, ${msg}`, error);
}
}
// 成功响应
res.status(code).json({
code,
statusText: '',
message: replaceSensitiveText(message || msg),
message: replaceSensitiveText(message),
data: data !== undefined ? data : null
});
};

View File

@ -0,0 +1,137 @@
import { Client, type RemoveOptions, type CopyConditions, type LifecycleConfig } from 'minio';
import {
type ExtensionType,
type CreatePostPresignedUrlOptions,
type CreatePostPresignedUrlParams,
type CreatePostPresignedUrlResult,
type S3OptionsType
} from '../type';
import { defaultS3Options, Mimes } from '../constants';
import path from 'node:path';
import { MongoS3TTL } from '../schema';
import { UserError } from '@fastgpt/global/common/error/utils';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { addHours } from 'date-fns';
export class S3BaseBucket {
private _client: Client;
private _externalClient: Client | undefined;
/**
*
* @param bucketName the bucket you want to operate
* @param options the options for the s3 client
*/
constructor(
private readonly bucketName: string,
public options: Partial<S3OptionsType> = defaultS3Options
) {
options = { ...defaultS3Options, ...options };
this.options = options;
this._client = new Client(options as S3OptionsType);
if (this.options.externalBaseURL) {
const externalBaseURL = new URL(this.options.externalBaseURL);
const endpoint = externalBaseURL.hostname;
const useSSL = externalBaseURL.protocol === 'https:';
const externalPort = externalBaseURL.port
? parseInt(externalBaseURL.port)
: useSSL
? 443
: undefined; // https 默认 443其他情况让 MinIO 客户端使用默认端口
this._externalClient = new Client({
useSSL: useSSL,
endPoint: endpoint,
port: externalPort,
accessKey: options.accessKey,
secretKey: options.secretKey,
transportAgent: options.transportAgent
});
}
const init = async () => {
if (!(await this.exist())) {
await this.client.makeBucket(this.bucketName);
}
await this.options.afterInit?.();
};
init();
}
get name(): string {
return this.bucketName;
}
protected get client(): Client {
return this._externalClient ?? this._client;
}
move(src: string, dst: string, options?: CopyConditions): Promise<void> {
const bucket = this.name;
this.client.copyObject(bucket, dst, `/${bucket}/${src}`, options);
return this.delete(src);
}
copy(src: string, dst: string, options?: CopyConditions): ReturnType<Client['copyObject']> {
return this.client.copyObject(this.name, src, dst, options);
}
exist(): Promise<boolean> {
return this.client.bucketExists(this.name);
}
delete(objectKey: string, options?: RemoveOptions): Promise<void> {
return this.client.removeObject(this.name, objectKey, options);
}
async createPostPresignedUrl(
params: CreatePostPresignedUrlParams,
options: CreatePostPresignedUrlOptions = {}
): Promise<CreatePostPresignedUrlResult> {
try {
const { expiredHours } = options;
const filename = params.filename;
const ext = path.extname(filename).toLowerCase() as ExtensionType;
const contentType = Mimes[ext] ?? 'application/octet-stream';
const maxFileSize = this.options.maxFileSize as number;
const key = (() => {
if ('rawKey' in params) return params.rawKey;
return `${params.source}/${params.teamId}/${getNanoid(6)}-${filename}`;
})();
const policy = this.client.newPostPolicy();
policy.setKey(key);
policy.setBucket(this.name);
policy.setContentType(contentType);
policy.setContentLengthRange(1, maxFileSize);
policy.setExpires(new Date(Date.now() + 10 * 60 * 1000));
policy.setUserMetaData({
'content-type': contentType,
'content-disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
'origin-filename': encodeURIComponent(filename),
'upload-time': new Date().toISOString()
});
const { formData, postURL } = await this.client.presignedPostPolicy(policy);
if (expiredHours) {
await MongoS3TTL.create({
minioKey: key,
bucketName: this.name,
expiredTime: addHours(new Date(), expiredHours)
});
}
return {
url: postURL,
fields: formData
};
} catch (error) {
return Promise.reject(error);
}
}
}

View File

@ -0,0 +1,9 @@
import { S3BaseBucket } from './base';
import { S3Buckets } from '../constants';
import { type S3OptionsType } from '../type';
export class S3PrivateBucket extends S3BaseBucket {
constructor(options?: Partial<S3OptionsType>) {
super(S3Buckets.private, options);
}
}

View File

@ -0,0 +1,51 @@
import { S3BaseBucket } from './base';
import { S3Buckets } from '../constants';
import { type S3OptionsType } from '../type';
export class S3PublicBucket extends S3BaseBucket {
constructor(options?: Partial<S3OptionsType>) {
super(S3Buckets.public, {
...options,
afterInit: async () => {
const bucket = this.name;
const policy = JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: '*',
Action: 's3:GetObject',
Resource: `arn:aws:s3:::${bucket}/*`
}
]
});
try {
await this.client.setBucketPolicy(bucket, policy);
} catch (error) {
// NOTE: maybe it was a cloud S3 that doesn't allow us to set the policy, so that cause the error,
// maybe we can ignore the error, or we have other plan to handle this.
console.error('Failed to set bucket policy:', error);
}
}
});
}
createPublicUrl(objectKey: string): string {
const protocol = this.options.useSSL ? 'https' : 'http';
const hostname = this.options.endPoint;
const port = this.options.port;
const bucket = this.name;
const url = new URL(`${protocol}://${hostname}:${port}/${bucket}/${objectKey}`);
if (this.options.externalBaseURL) {
const externalBaseURL = new URL(this.options.externalBaseURL);
url.port = externalBaseURL.port;
url.hostname = externalBaseURL.hostname;
url.protocol = externalBaseURL.protocol;
}
return url.toString();
}
}

View File

@ -1,10 +0,0 @@
import type { S3ServiceConfig } from './type';
export const defualtS3Config: Omit<S3ServiceConfig, 'bucket'> = {
endPoint: process.env.S3_ENDPOINT || 'localhost',
port: process.env.S3_PORT ? parseInt(process.env.S3_PORT) : 9000,
useSSL: process.env.S3_USE_SSL === 'true',
accessKey: process.env.S3_ACCESS_KEY || 'minioadmin',
secretKey: process.env.S3_SECRET_KEY || 'minioadmin',
externalBaseURL: process.env.S3_EXTERNAL_BASE_URL
};

View File

@ -1,20 +0,0 @@
export const mimeMap: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.pdf': 'application/pdf',
'.txt': 'text/plain',
'.json': 'application/json',
'.csv': 'text/csv',
'.zip': 'application/zip',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.doc': 'application/msword',
'.xls': 'application/vnd.ms-excel',
'.ppt': 'application/vnd.ms-powerpoint',
'.js': 'application/javascript'
};

View File

@ -0,0 +1,53 @@
import type { S3PrivateBucket } from './buckets/private';
import type { S3PublicBucket } from './buckets/public';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import type { ClientOptions } from 'minio';
export const Mimes = {
'.gif': 'image/gif',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.csv': 'text/csv',
'.txt': 'text/plain',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.json': 'application/json',
'.doc': 'application/msword',
'.js': 'application/javascript',
'.xls': 'application/vnd.ms-excel',
'.ppt': 'application/vnd.ms-powerpoint',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
} as const;
export const defaultS3Options: {
externalBaseURL?: string;
maxFileSize?: number;
afterInit?: () => Promise<void> | void;
} & ClientOptions = {
maxFileSize: 1024 ** 3, // 1GB
useSSL: process.env.S3_USE_SSL === 'true',
endPoint: process.env.S3_ENDPOINT || 'localhost',
externalBaseURL: process.env.S3_EXTERNAL_BASE_URL,
accessKey: process.env.S3_ACCESS_KEY || 'minioadmin',
secretKey: process.env.S3_SECRET_KEY || 'minioadmin',
port: process.env.S3_PORT ? parseInt(process.env.S3_PORT) : 9000,
transportAgent: process.env.HTTP_PROXY
? new HttpProxyAgent(process.env.HTTP_PROXY)
: process.env.HTTPS_PROXY
? new HttpsProxyAgent(process.env.HTTPS_PROXY)
: undefined
};
export const S3Buckets = {
public: process.env.S3_PUBLIC_BUCKET || 'fastgpt-public',
private: process.env.S3_PRIVATE_BUCKET || 'fastgpt-private'
} as const;

View File

@ -1,168 +1,62 @@
import { Client } from 'minio';
import {
type FileMetadataType,
type PresignedUrlInput as UploadPresignedURLProps,
type UploadPresignedURLResponse,
type S3ServiceConfig
} from './type';
import { defualtS3Config } from './config';
import { randomBytes } from 'crypto';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { extname } from 'path';
import { addLog } from '../../common/system/log';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { mimeMap } from './const';
import { MongoS3TTL } from './schema';
import { addLog } from '../system/log';
import { setCron } from '../system/cron';
import { checkTimerLock } from '../system/timerLock/utils';
import { TimerIdEnum } from '../system/timerLock/constants';
export class S3Service {
private client: Client;
private config: S3ServiceConfig;
private initialized: boolean = false;
initFunction?: () => Promise<any>;
export async function clearExpiredMinioFiles() {
try {
const expiredFiles = await MongoS3TTL.find({
expiredTime: { $lte: new Date() }
}).lean();
if (expiredFiles.length === 0) {
addLog.info('No expired minio files to clean');
return;
}
constructor(config?: Partial<S3ServiceConfig>) {
this.config = { ...defualtS3Config, ...config } as S3ServiceConfig;
addLog.info(`Found ${expiredFiles.length} expired minio files to clean`);
this.client = new Client({
endPoint: this.config.endPoint,
port: this.config.port,
useSSL: this.config.useSSL,
accessKey: this.config.accessKey,
secretKey: this.config.secretKey,
transportAgent: process.env.HTTP_PROXY
? new HttpProxyAgent(process.env.HTTP_PROXY)
: process.env.HTTPS_PROXY
? new HttpsProxyAgent(process.env.HTTPS_PROXY)
: undefined
});
let success = 0;
let fail = 0;
this.initFunction = config?.initFunction;
}
for (const file of expiredFiles) {
try {
const bucketName = file.bucketName;
const bucket = global.s3BucketMap[bucketName];
public async init() {
if (!this.initialized) {
if (!(await this.client.bucketExists(this.config.bucket))) {
addLog.debug(`Creating bucket: ${this.config.bucket}`);
await this.client.makeBucket(this.config.bucket);
if (bucket) {
await bucket.delete(file.minioKey);
await MongoS3TTL.deleteOne({ _id: file._id });
success++;
addLog.info(
`Deleted expired minio file: ${file.minioKey} from bucket: ${file.bucketName}`
);
} else {
addLog.warn(`Bucket not found: ${file.bucketName}`);
}
} catch (error) {
fail++;
addLog.error(`Failed to delete minio file: ${file.minioKey}`, error);
}
await this.initFunction?.();
this.initialized = true;
}
addLog.info(`Minio TTL cleanup completed. Success: ${success}, Failed: ${fail}`);
} catch (error) {
addLog.error('Error in clearExpiredMinioFiles', error);
}
private generateFileId(): string {
return randomBytes(16).toString('hex');
}
private generateAccessUrl(filename: string): string {
const protocol = this.config.useSSL ? 'https' : 'http';
const port =
this.config.port && this.config.port !== (this.config.useSSL ? 443 : 80)
? `:${this.config.port}`
: '';
const externalBaseURL = this.config.externalBaseURL;
return externalBaseURL
? `${externalBaseURL}/${this.config.bucket}/${encodeURIComponent(filename)}`
: `${protocol}://${this.config.endPoint}${port}/${this.config.bucket}/${encodeURIComponent(filename)}`;
}
uploadFile = async (fileBuffer: Buffer, originalFilename: string): Promise<FileMetadataType> => {
await this.init();
const inferContentType = (filename: string) => {
const ext = extname(filename).toLowerCase();
return mimeMap[ext] || 'application/octet-stream';
};
if (this.config.maxFileSize && fileBuffer.length > this.config.maxFileSize) {
return Promise.reject(
`File size ${fileBuffer.length} exceeds limit ${this.config.maxFileSize}`
);
}
const fileId = this.generateFileId();
const objectName = `${fileId}-${originalFilename}`;
const uploadTime = new Date();
const contentType = inferContentType(originalFilename);
await this.client.putObject(this.config.bucket, objectName, fileBuffer, fileBuffer.length, {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(originalFilename)}"`,
'x-amz-meta-original-filename': encodeURIComponent(originalFilename),
'x-amz-meta-upload-time': uploadTime.toISOString()
});
const metadata: FileMetadataType = {
fileId,
originalFilename,
contentType,
size: fileBuffer.length,
uploadTime,
accessUrl: this.generateAccessUrl(objectName)
};
return metadata;
};
generateUploadPresignedURL = async ({
filepath,
contentType,
metadata,
filename
}: UploadPresignedURLProps): Promise<UploadPresignedURLResponse> => {
await this.init();
const objectName = `${filepath}/${filename}`;
try {
const policy = this.client.newPostPolicy();
policy.setBucket(this.config.bucket);
policy.setKey(objectName);
if (contentType) {
policy.setContentType(contentType);
}
if (this.config.maxFileSize) {
policy.setContentLengthRange(1, this.config.maxFileSize);
}
policy.setExpires(new Date(Date.now() + 10 * 60 * 1000)); // 10 mins
policy.setUserMetaData({
'original-filename': encodeURIComponent(filename),
'upload-time': new Date().toISOString(),
...metadata
});
const { postURL, formData } = await this.client.presignedPostPolicy(policy);
const response: UploadPresignedURLResponse = {
objectName,
uploadUrl: postURL,
formData
};
return response;
} catch (error) {
addLog.error('Failed to generate Upload Presigned URL', error);
return Promise.reject(`Failed to generate Upload Presigned URL: ${getErrText(error)}`);
}
};
generateDownloadUrl = (objectName: string): string => {
const pathParts = objectName.split('/');
const encodedParts = pathParts.map((part) => encodeURIComponent(part));
const encodedObjectName = encodedParts.join('/');
return `${this.config.bucket}/${encodedObjectName}`;
};
getFile = async (objectName: string): Promise<string> => {
const stat = await this.client.statObject(this.config.bucket, objectName);
if (stat.size > 0) {
const accessUrl = this.generateDownloadUrl(objectName);
return accessUrl;
}
return Promise.reject(`File ${objectName} not found`);
};
}
export function clearExpiredS3FilesCron() {
// 每小时执行一次
setCron('0 */1 * * *', async () => {
if (
await checkTimerLock({
timerId: TimerIdEnum.clearExpiredMinioFiles,
lockMinuted: 59
})
) {
await clearExpiredMinioFiles();
}
});
}

View File

@ -1,16 +1,12 @@
import { S3Service } from './controller';
import { S3PublicBucket } from './buckets/public';
import { S3PrivateBucket } from './buckets/private';
export const PluginS3Service = (() => {
if (!global.pluginS3Service) {
global.pluginS3Service = new S3Service({
bucket: process.env.S3_PLUGIN_BUCKET || 'fastgpt-plugin',
maxFileSize: 50 * 1024 * 1024 // 50MB
});
}
export function initS3Buckets() {
const publicBucket = new S3PublicBucket();
const privateBucket = new S3PrivateBucket();
return global.pluginS3Service;
})();
declare global {
var pluginS3Service: S3Service;
global.s3BucketMap = {
[publicBucket.name]: publicBucket,
[privateBucket.name]: privateBucket
};
}

View File

@ -0,0 +1,24 @@
import { Schema, getMongoModel } from '../mongo';
import { type S3TtlSchemaType } from '@fastgpt/global/common/file/s3TTL/type';
const collectionName = 's3_ttls';
const S3TTLSchema = new Schema({
bucketName: {
type: String,
required: true
},
minioKey: {
type: String,
required: true
},
expiredTime: {
type: Date,
required: true
}
});
S3TTLSchema.index({ expiredTime: 1 });
S3TTLSchema.index({ bucketName: 1, minioKey: 1 });
export const MongoS3TTL = getMongoModel<S3TtlSchemaType>(collectionName, S3TTLSchema);

View File

@ -0,0 +1,70 @@
import { S3Sources } from '../type';
import { MongoS3TTL } from '../schema';
import { S3PublicBucket } from '../buckets/public';
import { imageBaseUrl } from '@fastgpt/global/common/file/image/constants';
import type { ClientSession } from 'mongoose';
class S3AvatarSource {
private bucket: S3PublicBucket;
private static instance: S3AvatarSource;
constructor() {
this.bucket = new S3PublicBucket();
}
static getInstance() {
return (this.instance ??= new S3AvatarSource());
}
get prefix(): string {
return imageBaseUrl;
}
async createUploadAvatarURL({
filename,
teamId,
autoExpired = true
}: {
filename: string;
teamId: string;
autoExpired?: boolean;
}) {
return this.bucket.createPostPresignedUrl(
{ filename, teamId, source: S3Sources.avatar },
{ expiredHours: autoExpired ? 1 : undefined } // 1 Hourse
);
}
createPublicUrl(objectKey: string): string {
return this.bucket.createPublicUrl(objectKey);
}
async removeAvatarTTL(avatar: string, session?: ClientSession): Promise<void> {
const key = avatar.slice(this.prefix.length);
await MongoS3TTL.deleteOne({ minioKey: key, bucketName: this.bucket.name }, session);
}
async deleteAvatar(avatar: string, session?: ClientSession): Promise<void> {
const key = avatar.slice(this.prefix.length);
await MongoS3TTL.deleteOne({ minioKey: key, bucketName: this.bucket.name }, session);
await this.bucket.delete(key);
}
async refreshAvatar(newAvatar?: string, oldAvatar?: string, session?: ClientSession) {
if (!newAvatar || newAvatar === oldAvatar) return;
// remove the TTL for the new avatar
await this.removeAvatarTTL(newAvatar, session);
if (oldAvatar) {
// delete the old avatar
// 1. delete the TTL record if it exists
// 2. delete the avatar in S3
await this.deleteAvatar(oldAvatar, session);
}
}
}
export function getS3AvatarSource() {
return S3AvatarSource.getInstance();
}

View File

@ -1,49 +1,54 @@
import type { ClientOptions } from 'minio';
import { z } from 'zod';
import type { defaultS3Options, Mimes } from './constants';
import type { S3BaseBucket } from './buckets/base';
export type S3ServiceConfig = {
bucket: string;
externalBaseURL?: string;
/**
* Unit: Byte
*/
maxFileSize?: number;
/**
* for executing some init function for the s3 service
*/
initFunction?: () => Promise<any>;
} & ClientOptions;
export const S3MetadataSchema = z.object({
filename: z.string(),
uploadedAt: z.date(),
accessUrl: z.string(),
contentType: z.string(),
id: z.string().length(32),
size: z.number().positive()
});
export type S3Metadata = z.infer<typeof S3MetadataSchema>;
export type FileMetadataType = {
fileId: string;
originalFilename: string;
contentType: string;
size: number;
uploadTime: Date;
accessUrl: string;
};
export type ContentType = (typeof Mimes)[keyof typeof Mimes];
export type ExtensionType = keyof typeof Mimes;
export type PresignedUrlInput = {
filepath: string;
filename: string;
contentType?: string;
metadata?: Record<string, string>;
};
export type S3OptionsType = typeof defaultS3Options;
export type UploadPresignedURLResponse = {
objectName: string;
uploadUrl: string;
formData: Record<string, string>;
};
export const S3SourcesSchema = z.enum(['avatar']);
export const S3Sources = S3SourcesSchema.enum;
export type S3SourceType = z.infer<typeof S3SourcesSchema>;
export type FileUploadInput = {
buffer: Buffer;
filename: string;
};
export const CreatePostPresignedUrlParamsSchema = z.union([
// Option 1: Only rawKey
z.object({
filename: z.string().min(1),
rawKey: z.string().min(1)
}),
// Option 2: filename with optional source and teamId
z.object({
filename: z.string().min(1),
source: S3SourcesSchema.optional(),
teamId: z.string().length(16).optional()
})
]);
export type CreatePostPresignedUrlParams = z.infer<typeof CreatePostPresignedUrlParamsSchema>;
export enum PluginTypeEnum {
tool = 'tool'
export const CreatePostPresignedUrlOptionsSchema = z.object({
expiredHours: z.number().positive().optional() // TTL in Hours, default 7 * 24
});
export type CreatePostPresignedUrlOptions = z.infer<typeof CreatePostPresignedUrlOptionsSchema>;
export const CreatePostPresignedUrlResultSchema = z.object({
url: z.string().min(1),
fields: z.record(z.string(), z.string())
});
export type CreatePostPresignedUrlResult = z.infer<typeof CreatePostPresignedUrlResultSchema>;
declare global {
var s3BucketMap: {
[key: string]: S3BaseBucket;
};
}
export const PluginFilePath = {
[PluginTypeEnum.tool]: 'plugin/tools'
};

View File

@ -8,7 +8,8 @@ export enum TimerIdEnum {
notification = 'notification',
clearExpiredRawTextBuffer = 'clearExpiredRawTextBuffer',
clearExpiredDatasetImage = 'clearExpiredDatasetImage'
clearExpiredDatasetImage = 'clearExpiredDatasetImage',
clearExpiredMinioFiles = 'clearExpiredMinioFiles'
}
export enum LockNotificationEnum {

View File

@ -26,12 +26,36 @@ const getVectorObj = () => {
return new PgVectorCtrl();
};
const getChcheKey = (teamId: string) => `${CacheKeyEnum.team_vector_count}:${teamId}`;
const onDelCache = throttle((teamId: string) => delRedisCache(getChcheKey(teamId)), 30000, {
leading: true,
trailing: true
});
const onIncrCache = (teamId: string) => incrValueToCache(getChcheKey(teamId), 1);
const teamVectorCache = {
getKey: function (teamId: string) {
return `${CacheKeyEnum.team_vector_count}:${teamId}`;
},
get: async function (teamId: string) {
const countStr = await getRedisCache(teamVectorCache.getKey(teamId));
if (countStr) {
return Number(countStr);
}
return undefined;
},
set: function ({ teamId, count }: { teamId: string; count: number }) {
retryFn(() =>
setRedisCache(teamVectorCache.getKey(teamId), count, CacheKeyEnumTime.team_vector_count)
).catch();
},
delete: throttle(
function (teamId: string) {
return retryFn(() => delRedisCache(teamVectorCache.getKey(teamId))).catch();
},
30000,
{
leading: true,
trailing: true
}
),
incr: function (teamId: string, count: number) {
retryFn(() => incrValueToCache(teamVectorCache.getKey(teamId), count)).catch();
}
};
const Vector = getVectorObj();
@ -41,16 +65,17 @@ export const recallFromVectorStore = (props: EmbeddingRecallCtrlProps) =>
export const getVectorDataByTime = Vector.getVectorDataByTime;
export const getVectorCountByTeamId = async (teamId: string) => {
const key = getChcheKey(teamId);
const countStr = await getRedisCache(key);
if (countStr) {
return Number(countStr);
const cacheCount = await teamVectorCache.get(teamId);
if (cacheCount !== undefined) {
return cacheCount;
}
const count = await Vector.getVectorCountByTeamId(teamId);
await setRedisCache(key, count, CacheKeyEnumTime.team_vector_count);
teamVectorCache.set({
teamId,
count
});
return count;
};
@ -78,7 +103,7 @@ export const insertDatasetDataVector = async ({
})
);
onIncrCache(props.teamId);
teamVectorCache.incr(props.teamId, insertIds.length);
return {
tokens,
@ -88,6 +113,6 @@ export const insertDatasetDataVector = async ({
export const deleteDatasetDataVector = async (props: DelDatasetVectorCtrlProps) => {
const result = await retryFn(() => Vector.delete(props));
onDelCache(props.teamId);
teamVectorCache.delete(props.teamId);
return result;
};

View File

@ -80,7 +80,12 @@ export const loadSystemModels = async (init = false, language = 'en') => {
if (!init && global.systemModelList) return;
await preloadModelProviders();
try {
await preloadModelProviders();
} catch (error) {
console.log('Load systen model error, please check fastgpt-plugin', error);
return Promise.reject(error);
}
global.systemModelList = [];
global.systemActiveModelList = [];
@ -236,7 +241,7 @@ export const getSystemModelConfig = async (model: string): Promise<SystemModelIt
export const watchSystemModelUpdate = () => {
const changeStream = MongoSystemModel.watch();
changeStream.on(
return changeStream.on(
'change',
debounce(async () => {
try {

View File

@ -3,6 +3,9 @@ import { getSecretValue } from '../../common/secret/utils';
import axios from 'axios';
import { getErrText } from '@fastgpt/global/common/error/utils';
import type { RequireOnlyOne } from '@fastgpt/global/common/type/utils';
import type { HttpToolConfigType } from '@fastgpt/global/core/app/type';
import { contentTypeMap, ContentTypes } from '@fastgpt/global/core/workflow/constants';
import { replaceEditorVariable } from '@fastgpt/global/core/workflow/runtime/utils';
export type RunHTTPToolParams = {
baseUrl: string;
@ -11,6 +14,9 @@ export type RunHTTPToolParams = {
params: Record<string, any>;
headerSecret?: StoreSecretValueType;
customHeaders?: Record<string, string>;
staticParams?: HttpToolConfigType['staticParams'];
staticHeaders?: HttpToolConfigType['staticHeaders'];
staticBody?: HttpToolConfigType['staticBody'];
};
export type RunHTTPToolResult = RequireOnlyOne<{
@ -18,41 +24,143 @@ export type RunHTTPToolResult = RequireOnlyOne<{
errorMsg?: string;
}>;
export async function runHTTPTool({
const buildHttpRequest = ({
method,
params,
headerSecret,
customHeaders,
staticParams,
staticHeaders,
staticBody
}: Omit<RunHTTPToolParams, 'baseUrl' | 'toolPath'>) => {
const replaceVariables = (text: string) => {
return replaceEditorVariable({
text,
nodes: [],
variables: params
});
};
const body = (() => {
if (!staticBody || staticBody.type === ContentTypes.none) {
return ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase()) ? {} : undefined;
}
if (staticBody.type === ContentTypes.json) {
const contentWithReplacedVars = staticBody.content
? replaceVariables(staticBody.content)
: '{}';
const staticContent = JSON.parse(contentWithReplacedVars);
return { ...staticContent };
}
if (staticBody.type === ContentTypes.formData) {
const formData = new (require('form-data'))();
staticBody.formData?.forEach(({ key, value }) => {
const replacedKey = replaceVariables(key);
const replacedValue = replaceVariables(value);
formData.append(replacedKey, replacedValue);
});
return formData;
}
if (staticBody.type === ContentTypes.xWwwFormUrlencoded) {
const urlencoded = new URLSearchParams();
staticBody.formData?.forEach(({ key, value }) => {
const replacedKey = replaceVariables(key);
const replacedValue = replaceVariables(value);
urlencoded.append(replacedKey, replacedValue);
});
return urlencoded.toString();
}
if (staticBody.type === ContentTypes.xml || staticBody.type === ContentTypes.raw) {
return replaceVariables(staticBody.content || '');
}
return undefined;
})();
const contentType = contentTypeMap[staticBody?.type || ContentTypes.none];
const headers = {
...(contentType && { 'Content-Type': contentType }),
...(customHeaders || {}),
...(headerSecret ? getSecretValue({ storeSecret: headerSecret }) : {}),
...(staticHeaders?.reduce(
(acc, { key, value }) => {
const replacedKey = replaceVariables(key);
const replacedValue = replaceVariables(value);
acc[replacedKey] = replacedValue;
return acc;
},
{} as Record<string, string>
) || {})
};
const queryParams = (() => {
const staticParamsObj =
staticParams?.reduce(
(acc, { key, value }) => {
const replacedKey = replaceVariables(key);
const replacedValue = replaceVariables(value);
acc[replacedKey] = replacedValue;
return acc;
},
{} as Record<string, any>
) || {};
const mergedParams =
method.toUpperCase() === 'GET' || staticParams
? { ...staticParamsObj, ...params }
: staticParamsObj;
return Object.keys(mergedParams).length > 0 ? mergedParams : undefined;
})();
return {
headers,
body,
queryParams
};
};
export const runHTTPTool = async ({
baseUrl,
toolPath,
method = 'POST',
params,
headerSecret,
customHeaders
}: RunHTTPToolParams): Promise<RunHTTPToolResult> {
customHeaders,
staticParams,
staticHeaders,
staticBody
}: RunHTTPToolParams): Promise<RunHTTPToolResult> => {
try {
const headers = {
'Content-Type': 'application/json',
...(customHeaders || {}),
...(headerSecret ? getSecretValue({ storeSecret: headerSecret }) : {})
};
const { headers, body, queryParams } = buildHttpRequest({
method,
params,
headerSecret,
customHeaders,
staticParams,
staticHeaders,
staticBody
});
const { data } = await axios({
method: method.toUpperCase(),
baseURL: baseUrl.startsWith('https://') ? baseUrl : `https://${baseUrl}`,
url: toolPath,
headers,
data: params,
params,
data: body,
params: queryParams,
timeout: 300000,
httpsAgent: new (require('https').Agent)({
rejectUnauthorized: false
})
});
return {
data
};
return { data };
} catch (error: any) {
console.log(error);
return {
errorMsg: getErrText(error)
};
return { errorMsg: getErrText(error) };
}
}
};

View File

@ -1,5 +1,5 @@
import { connectionMongo, getMongoModel } from '../../../common/mongo';
import { type ChatFavouriteAppSchema as ChatFavouriteAppType } from '@fastgpt/global/core/chat/favouriteApp/type';
import { type ChatFavouriteAppType } from '@fastgpt/global/core/chat/favouriteApp/type';
import { TeamCollectionName } from '@fastgpt/global/support/user/team/constant';
import { AppCollectionName } from '../../app/schema';

View File

@ -1,5 +1,5 @@
import { connectionMongo, getMongoModel } from '../../../common/mongo';
import { type ChatSettingSchema as ChatSettingType } from '@fastgpt/global/core/chat/setting/type';
import { type ChatSettingModelType } from '@fastgpt/global/core/chat/setting/type';
import { TeamCollectionName } from '@fastgpt/global/support/user/team/constant';
import { AppCollectionName } from '../../app/schema';
@ -55,7 +55,7 @@ ChatSettingSchema.virtual('quickAppList', {
ChatSettingSchema.index({ teamId: 1 });
export const MongoChatSetting = getMongoModel<ChatSettingType>(
export const MongoChatSetting = getMongoModel<ChatSettingModelType>(
ChatSettingCollectionName,
ChatSettingSchema
);

View File

@ -236,16 +236,19 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo
}
const { data, errorMsg } = await runHTTPTool({
baseUrl: baseUrl,
baseUrl: baseUrl || '',
toolPath: httpTool.path,
method: httpTool.method,
params,
headerSecret,
headerSecret: httpTool.headerSecret || headerSecret,
customHeaders: customHeaders
? typeof customHeaders === 'string'
? JSON.parse(customHeaders)
: customHeaders
: undefined
: undefined,
staticParams: httpTool.staticParams,
staticHeaders: httpTool.staticHeaders,
staticBody: httpTool.staticBody
});
if (errorMsg) {

View File

@ -633,14 +633,13 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR
this.chatNodeUsages = this.chatNodeUsages.concat(nodeDispatchUsages);
}
if (toolResponses !== undefined && toolResponses !== null) {
if (Array.isArray(toolResponses) && toolResponses.length === 0) return;
if (
!Array.isArray(toolResponses) &&
if (
(toolResponses !== undefined && toolResponses !== null) ||
(Array.isArray(toolResponses) && toolResponses.length > 0) ||
(!Array.isArray(toolResponses) &&
typeof toolResponses === 'object' &&
Object.keys(toolResponses).length === 0
)
return;
Object.keys(toolResponses).length > 0)
) {
this.toolRunResponse = toolResponses;
}

View File

@ -1,5 +1,6 @@
import { getErrText } from '@fastgpt/global/common/error/utils';
import {
contentTypeMap,
ContentTypes,
NodeInputKeyEnum,
NodeOutputKeyEnum,
@ -59,15 +60,6 @@ type HttpResponse = DispatchNodeResultType<
const UNDEFINED_SIGN = 'UNDEFINED_SIGN';
const contentTypeMap = {
[ContentTypes.none]: '',
[ContentTypes.formData]: '',
[ContentTypes.xWwwFormUrlencoded]: 'application/x-www-form-urlencoded',
[ContentTypes.json]: 'application/json',
[ContentTypes.xml]: 'application/xml',
[ContentTypes.raw]: 'text/plain'
};
export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<HttpResponse> => {
let {
runningAppInfo: { id: appId, teamId, tmbId },

View File

@ -54,7 +54,8 @@
"tiktoken": "1.0.17",
"tunnel": "^0.0.6",
"turndown": "^7.1.2",
"winston": "^3.17.0"
"winston": "^3.17.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/cookie": "^0.5.2",

View File

@ -17,7 +17,7 @@ import { mongoSessionRun } from '../../../common/mongo/sessionRun';
import { DefaultGroupName } from '@fastgpt/global/support/user/team/group/constant';
import { getAIApi } from '../../../core/ai/config';
import { createRootOrg } from '../../permission/org/controllers';
import { refreshSourceAvatar } from '../../../common/file/image/controller';
import { getS3AvatarSource } from '../../../common/s3/sources/avatar';
async function getTeamMember(match: Record<string, any>): Promise<TeamTmbItemType> {
const tmb = await MongoTeamMember.findOne(match).populate<{ team: TeamSchema }>('team').lean();
@ -244,7 +244,7 @@ export async function updateTeam({
{ session }
);
await refreshSourceAvatar(avatar, team?.avatar, session);
await getS3AvatarSource().refreshAvatar(avatar, team?.avatar, session);
}
});
}

View File

@ -22,7 +22,7 @@ export const getSafeEnv = () => {
};
export const getWorker = (name: `${WorkerNameEnum}`) => {
const workerPath = path.join(process.cwd(), '.next', 'server', 'worker', `${name}.js`);
const workerPath = path.join(process.cwd(), 'worker', `${name}.js`);
return new Worker(workerPath, {
env: getSafeEnv()
});

View File

@ -0,0 +1,91 @@
import { base64ToFile, fileToBase64 } from '../utils';
import { compressBase64Img } from '../img';
import { useToast } from '../../../hooks/useToast';
import { useCallback, useRef, useTransition } from 'react';
import { useTranslation } from 'next-i18next';
import { type CreatePostPresignedUrlResult } from '../../../../service/common/s3/type';
import { imageBaseUrl } from '@fastgpt/global/common/file/image/constants';
export const useUploadAvatar = (
api: (params: { filename: string }) => Promise<CreatePostPresignedUrlResult>,
{ onSuccess }: { onSuccess?: (avatar: string) => void } = {}
) => {
const { toast } = useToast();
const { t } = useTranslation();
const [uploading, startUpload] = useTransition();
const uploadAvatarRef = useRef<HTMLInputElement>(null);
const handleFileSelectorOpen = useCallback(() => {
if (!uploadAvatarRef.current) return;
uploadAvatarRef.current.click();
}, []);
// manually upload avatar
const handleUploadAvatar = useCallback(
async (file: File) => {
if (!file.name.match(/\.(jpg|png|jpeg)$/)) {
toast({ title: t('account_info:avatar_can_only_select_jpg_png'), status: 'warning' });
return;
}
startUpload(async () => {
const compressed = base64ToFile(
await compressBase64Img({
base64Img: await fileToBase64(file),
maxW: 300,
maxH: 300
}),
file.name
);
const { url, fields } = await api({ filename: file.name });
const formData = new FormData();
Object.entries(fields).forEach(([k, v]) => formData.set(k, v));
formData.set('file', compressed);
const res = await fetch(url, { method: 'POST', body: formData }); // 204
if (res.ok && res.status === 204) {
onSuccess?.(`${imageBaseUrl}${fields.key}`);
}
});
},
[t, toast, api, onSuccess]
);
const onUploadAvatarChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) {
e.target.value = '';
return;
}
if (files.length > 1) {
toast({ title: t('account_info:avatar_can_only_select_one'), status: 'warning' });
e.target.value = '';
return;
}
const file = files[0]!;
handleUploadAvatar(file);
},
[t, toast, handleUploadAvatar]
);
const Component = useCallback(() => {
return (
<input
hidden
type="file"
multiple={false}
accept=".jpg,.png,.jpeg"
ref={uploadAvatarRef}
onChange={onUploadAvatarChange}
/>
);
}, [onUploadAvatarChange]);
return {
uploading,
Component,
handleFileSelectorOpen,
handleUploadAvatar
};
};

View File

@ -99,3 +99,24 @@ async function detectFileEncoding(file: File): Promise<string> {
return encoding || 'utf-8';
}
export const fileToBase64 = (file: File) => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
};
export const base64ToFile = (base64: string, filename: string) => {
const arr = base64.split(',');
const mime = arr[0].match(/:(.*?);/)?.[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, { type: mime });
};

View File

@ -25,6 +25,7 @@ const LeftRadio = <T = any,>({
align = 'center',
px = 3.5,
py = 4,
gridGap = [3, 5],
defaultBg = 'myGray.50',
activeBg = 'primary.50',
onChange,
@ -75,7 +76,7 @@ const LeftRadio = <T = any,>({
);
return (
<Grid gridGap={[3, 5]} fontSize={['sm', 'md']} {...props}>
<Grid gridGap={gridGap} fontSize={['sm', 'md']} {...props}>
{list.map((item) => {
const isActive = value === item.value;
return (
@ -131,7 +132,7 @@ const LeftRadio = <T = any,>({
lineHeight={1}
color={'myGray.900'}
>
<Box>{t(item.title as any)}</Box>
<Box mb={1}>{t(item.title as any)}</Box>
{!!item.tooltip && <QuestionTip label={item.tooltip} color={'myGray.600'} />}
</HStack>
) : (

View File

@ -9,6 +9,8 @@
"app_amount": "App amount",
"avatar": "Avatar",
"avatar_selection_exception": "Abnormal avatar selection",
"avatar_can_only_select_one": "Avatar can only select one picture",
"avatar_can_only_select_jpg_png": "Avatar can only select jpg or png format",
"balance": "balance",
"billing_standard": "Standards",
"cancel": "Cancel",

View File

@ -1,7 +1,12 @@
{
"Add_tool": "Add tool",
"AutoOptimize": "Automatic optimization",
"Click_to_delete_this_field": "Click to delete this field",
"Custom_params": "input parameters",
"Edit_tool": "Edit tool",
"Filed_is_deprecated": "This field is deprecated",
"HTTPTools_Create_Type": "Create Type",
"HTTPTools_Create_Type_Tip": "Modification is not supported after selection",
"HTTP_tools_list_with_number": "Tool list: {{total}}",
"Index": "Index",
"MCP_tools_debug": "debug",
@ -30,6 +35,7 @@
"Selected": "Selected",
"Start_config": "Start configuration",
"Team_Tags": "Team tags",
"Tool_name": "Tool name",
"ai_point_price": "Billing",
"ai_settings": "AI Configuration",
"all_apps": "All Applications",
@ -89,6 +95,7 @@
"document_upload": "Document Upload",
"edit_app": "Application details",
"edit_info": "Edit",
"empty_tool_tips": "Please add tools on the left side",
"execute_time": "Execution Time",
"export_config_successful": "Configuration copied, some sensitive information automatically filtered. Please check for any remaining sensitive data.",
"export_configs": "Export",
@ -99,12 +106,15 @@
"file_upload_tip": "Once enabled, documents/images can be uploaded. Documents are retained for 7 days, images for 15 days. Using this feature may incur additional costs. To ensure a good experience, please choose an AI model with a larger context length when using this feature.",
"go_to_chat": "Go to Conversation",
"go_to_run": "Go to Execution",
"http_toolset_add_tips": "Click the \"Add\" button to add tools",
"http_toolset_config_tips": "Click \"Start Configuration\" to add tools",
"image_upload": "Image Upload",
"image_upload_tip": "How to activate model image recognition capabilities",
"import_configs": "Import",
"import_configs_failed": "Import configuration failed, please ensure the configuration is correct!",
"import_configs_success": "Import Successful",
"initial_form": "initial state",
"input_params_tips": "Tool input parameters will not be passed directly to the request URL; they can be referenced on the right side using \"/\".",
"interval.12_hours": "Every 12 Hours",
"interval.2_hours": "Every 2 Hours",
"interval.3_hours": "Every 3 Hours",
@ -283,6 +293,7 @@
"tool_detail": "Tool details",
"tool_input_param_tip": "This plugin requires configuration of related information to run properly.",
"tool_not_active": "This tool has not been activated yet",
"tool_params_description_tips": "The description of parameter functions, if used as tool invocation parameters, affects the model tool invocation effect.",
"tool_run_free": "This tool runs without points consumption",
"tool_tip": "When executed as a tool, is this field used as a tool response result?",
"tool_type_tools": "tool",

View File

@ -9,6 +9,8 @@
"app_amount": "应用数量",
"avatar": "头像",
"avatar_selection_exception": "头像选择异常",
"avatar_can_only_select_one": "头像只能选择一张图片",
"avatar_can_only_select_jpg_png": "头像只能选择 jpg 或 png 格式",
"balance": "余额",
"billing_standard": "计费标准",
"cancel": "取消",

View File

@ -1,7 +1,12 @@
{
"Add_tool": "添加工具",
"AutoOptimize": "自动优化",
"Click_to_delete_this_field": "点击删除该字段",
"Custom_params": "输入参数",
"Edit_tool": "编辑工具",
"Filed_is_deprecated": "该字段已弃用",
"HTTPTools_Create_Type": "创建方式",
"HTTPTools_Create_Type_Tip": "选择后不支持修改",
"HTTP_tools_detail": "查看详情",
"HTTP_tools_list_with_number": "工具列表: {{total}}",
"Index": "索引",
@ -31,6 +36,8 @@
"Selected": "已选择",
"Start_config": "开始配置",
"Team_Tags": "团队标签",
"Tool_description": "工具描述",
"Tool_name": "工具名称",
"ai_point_price": "AI积分计费",
"ai_settings": "AI 配置",
"all_apps": "全部应用",
@ -90,6 +97,8 @@
"document_upload": "文档上传",
"edit_app": "应用详情",
"edit_info": "编辑信息",
"edit_param": "编辑参数",
"empty_tool_tips": "请在左侧添加工具",
"execute_time": "执行时间",
"export_config_successful": "已复制配置,自动过滤部分敏感信息,请注意检查是否仍有敏感数据",
"export_configs": "导出配置",
@ -100,12 +109,15 @@
"file_upload_tip": "开启后,可以上传文档/图片。文档保留7天图片保留15天。使用该功能可能产生较多额外费用。为保证使用体验使用该功能时请选择上下文长度较大的AI模型。",
"go_to_chat": "去对话",
"go_to_run": "去运行",
"http_toolset_add_tips": "点击添加按钮来添加工具",
"http_toolset_config_tips": "点击开始配置来添加工具",
"image_upload": "图片上传",
"image_upload_tip": "如何启动模型图片识别能力",
"import_configs": "导入配置",
"import_configs_failed": "导入配置失败,请确保配置正常!",
"import_configs_success": "导入成功",
"initial_form": "初始状态",
"input_params_tips": "工具的输入参数,不会直接传递到请求地址,可在右侧通过 \"/\" 来引用变量",
"interval.12_hours": "每12小时",
"interval.2_hours": "每2小时",
"interval.3_hours": "每3小时",
@ -297,6 +309,7 @@
"tool_detail": "工具详情",
"tool_input_param_tip": "该插件正常运行需要配置相关信息",
"tool_not_active": "该工具尚未激活",
"tool_params_description_tips": "参数功能的描述,若作为工具调用参数,影响模型工具调用效果",
"tool_run_free": "该工具运行无积分消耗",
"tool_tip": "作为工具执行时,该字段是否作为工具响应结果",
"tool_type_tools": "工具",

View File

@ -9,6 +9,8 @@
"app_amount": "應用數量",
"avatar": "頭像",
"avatar_selection_exception": "頭像選擇異常",
"avatar_can_only_select_one": "頭像只能選擇一張圖片",
"avatar_can_only_select_jpg_png": "頭像只能選擇 jpg 或 png 格式",
"balance": "餘額",
"billing_standard": "計費標準",
"cancel": "取消",

View File

@ -1,7 +1,11 @@
{
"Add_tool": "添加工具",
"AutoOptimize": "自動優化",
"Click_to_delete_this_field": "點擊刪除該字段",
"Custom_params": "輸入參數",
"Filed_is_deprecated": "該字段已棄用",
"HTTPTools_Create_Type": "創建方式",
"HTTPTools_Create_Type_Tip": "選擇後不支持修改",
"HTTP_tools_list_with_number": "工具列表: {{total}}",
"Index": "索引",
"MCP_tools_debug": "偵錯",
@ -30,6 +34,8 @@
"Selected": "已選擇",
"Start_config": "開始配置",
"Team_Tags": "團隊標籤",
"Tool_description": "工具描述",
"Tool_name": "工具名稱",
"ai_point_price": "AI 積分計費",
"ai_settings": "AI 設定",
"all_apps": "所有應用程式",
@ -89,6 +95,7 @@
"document_upload": "文件上傳",
"edit_app": "應用詳情",
"edit_info": "編輯資訊",
"empty_tool_tips": "請在左側添加工具",
"execute_time": "執行時間",
"export_config_successful": "已複製設定,自動過濾部分敏感資訊,請注意檢查是否仍有敏感資料",
"export_configs": "匯出設定",
@ -99,12 +106,15 @@
"file_upload_tip": "開啟後,可以上傳文件/圖片。文件保留 7 天,圖片保留 15 天。使用這個功能可能產生較多額外費用。為了確保使用體驗,使用這個功能時,請選擇上下文長度較大的 AI 模型。",
"go_to_chat": "前往對話",
"go_to_run": "前往執行",
"http_toolset_add_tips": "點擊添加按鈕來添加工具",
"http_toolset_config_tips": "點擊開始配置來添加工具",
"image_upload": "圖片上傳",
"image_upload_tip": "如何啟用模型圖片辨識功能",
"import_configs": "匯入設定",
"import_configs_failed": "匯入設定失敗,請確認設定是否正常!",
"import_configs_success": "匯入成功",
"initial_form": "初始狀態",
"input_params_tips": "工具的輸入參數,不會直接傳遞到請求地址,可在右側通過 \"/\" 來引用變數",
"interval.12_hours": "每 12 小時",
"interval.2_hours": "每 2 小時",
"interval.3_hours": "每 3 小時",
@ -283,6 +293,7 @@
"tool_detail": "工具詳情",
"tool_input_param_tip": "這個外掛正常執行需要設定相關資訊",
"tool_not_active": "該工具尚未激活",
"tool_params_description_tips": "參數功能的描述,若作為工具調用參數,影響模型工具調用效果",
"tool_run_free": "該工具運行無積分消耗",
"tool_tip": "作為工具執行時,該字段是否作為工具響應結果",
"tool_type_tools": "工具",

View File

@ -22,7 +22,7 @@
"@lexical/utils": "0.12.6",
"@monaco-editor/react": "^4.6.0",
"@tanstack/react-query": "^4.24.10",
"ahooks": "^3.9.4",
"ahooks": "^3.9.5",
"date-fns": "2.30.0",
"dayjs": "^1.11.7",
"next": "14.2.32",

File diff suppressed because it is too large Load Diff

View File

@ -41,7 +41,9 @@ S3_PORT=9000
S3_USE_SSL=false
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_PLUGIN_BUCKET=fastgpt-plugin # 插件文件存储bucket
S3_PUBLIC_BUCKET=fastgpt-public # 插件文件存储公开桶
S3_PRIVATE_BUCKET=fastgpt-private # 插件文件存储公开桶
# Redis URL
REDIS_URL=redis://default:mypassword@127.0.0.1:6379
# mongo 数据库连接参数,本地开发连接远程数据库时,可能需要增加 directConnection=true 参数,才能连接上。

View File

@ -66,19 +66,17 @@ COPY --from=builder --chown=nextjs:nodejs /app/projects/app/.next/static /app/pr
# copy server chunks
COPY --from=builder --chown=nextjs:nodejs /app/projects/app/.next/server/chunks /app/projects/app/.next/server/chunks
# copy worker
COPY --from=builder --chown=nextjs:nodejs /app/projects/app/.next/server/worker /app/projects/app/.next/server/worker
COPY --from=builder --chown=nextjs:nodejs /app/projects/app/worker /app/projects/app/worker
# copy standload packages
COPY --from=maindeps /app/node_modules/tiktoken ./node_modules/tiktoken
RUN rm -rf ./node_modules/tiktoken/encoders
COPY --from=maindeps /app/node_modules/@zilliz/milvus2-sdk-node ./node_modules/@zilliz/milvus2-sdk-node
# copy package.json to version file
COPY --from=builder /app/projects/app/package.json ./package.json
# copy config
COPY ./projects/app/data /app/data
COPY ./projects/app/data/config.json /app/data/config.json
RUN chown -R nextjs:nodejs /app/data
# Add tmp directory permission control

View File

@ -2,6 +2,10 @@ const { i18n } = require('./next-i18next.config.js');
const path = require('path');
const fs = require('fs');
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
const isDev = process.env.NODE_ENV === 'development';
/** @type {import('next').NextConfig} */
@ -11,6 +15,10 @@ const nextConfig = {
output: 'standalone',
reactStrictMode: isDev ? false : true,
compress: true,
// 禁用 source map可选根据需要
productionBrowserSourceMaps: false,
// 优化编译性能
swcMinify: true, // 使用 SWC 压缩(生产环境已默认)
async headers() {
return [
{
@ -40,6 +48,7 @@ const nextConfig = {
}
];
},
webpack(config, { isServer, nextRuntime }) {
Object.assign(config.resolve.alias, {
'@mongodb-js/zstd': false,
@ -73,17 +82,7 @@ const nextConfig = {
config.externals.push('@node-rs/jieba');
if (nextRuntime === 'nodejs') {
const oldEntry = config.entry;
config = {
...config,
async entry(...args) {
const entries = await oldEntry(...args);
return {
...entries,
...getWorkerConfig()
};
}
};
}
} else {
config.resolve = {
@ -100,6 +99,32 @@ const nextConfig = {
layers: true
};
if (isDev && !isServer) {
// 使用更快的 source map
config.devtool = 'eval-cheap-module-source-map';
// 减少文件监听范围
config.watchOptions = {
...config.watchOptions,
ignored: [
'**/node_modules',
'**/.git',
'**/dist',
'**/coverage'
],
};
// 启用持久化缓存
config.cache = {
type: 'filesystem',
name: isServer ? 'server' : 'client',
buildDependencies: {
config: [__filename]
},
cacheDirectory: path.resolve(__dirname, '.next/cache/webpack'),
maxMemoryGenerations: isDev ? 5 : Infinity,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天
};
}
return config;
},
// 需要转译的包
@ -111,31 +136,14 @@ const nextConfig = {
'pg',
'bullmq',
'@zilliz/milvus2-sdk-node',
'tiktoken'
'tiktoken',
'@opentelemetry/api-logs'
],
outputFileTracingRoot: path.join(__dirname, '../../'),
instrumentationHook: true
instrumentationHook: true,
workerThreads: true
}
};
module.exports = nextConfig;
function getWorkerConfig() {
const result = fs.readdirSync(path.resolve(__dirname, '../../packages/service/worker'));
// 获取所有的目录名
const folderList = result.filter((item) => {
return fs
.statSync(path.resolve(__dirname, '../../packages/service/worker', item))
.isDirectory();
});
const workerConfig = folderList.reduce((acc, item) => {
acc[`worker/${item}`] = path.resolve(
process.cwd(),
`../../packages/service/worker/${item}/index.ts`
);
return acc;
}, {});
return workerConfig;
}

View File

@ -1,12 +1,14 @@
{
"name": "app",
"version": "4.13.1",
"version": "4.13.2",
"private": false,
"scripts": {
"dev": "next dev",
"build": "next build",
"dev": "npm run build:workers && next dev",
"build": "npm run build:workers && next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"build:workers": "npx tsx scripts/build-workers.ts",
"build:workers:watch": "npx tsx scripts/build-workers.ts --watch"
},
"dependencies": {
"@chakra-ui/anatomy": "2.2.1",
@ -24,13 +26,15 @@
"@fortaine/fetch-event-source": "^3.0.6",
"@modelcontextprotocol/sdk": "^1.12.1",
"@node-rs/jieba": "2.0.1",
"@scalar/api-reference-react": "^0.8.1",
"@tanstack/react-query": "^4.24.10",
"ahooks": "^3.7.11",
"ahooks": "^3.9.5",
"axios": "^1.12.1",
"date-fns": "2.30.0",
"dayjs": "^1.11.7",
"echarts": "5.4.1",
"echarts-gl": "2.0.9",
"esbuild": "^0.25.11",
"framer-motion": "9.1.7",
"hyperdown": "^2.4.29",
"i18next": "23.16.8",
@ -41,6 +45,7 @@
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"mermaid": "^10.9.4",
"minio": "^8.0.5",
"nanoid": "^5.1.3",
"next": "14.2.32",
"next-i18next": "15.4.2",
@ -64,10 +69,10 @@
"request-ip": "^3.3.0",
"sass": "^1.58.3",
"use-context-selector": "^1.4.4",
"zod": "^3.24.2",
"minio": "^8.0.5"
"zod": "^3.24.2"
},
"devDependencies": {
"@next/bundle-analyzer": "^15.5.6",
"@svgr/webpack": "^6.5.1",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.3",
@ -83,6 +88,7 @@
"@typescript-eslint/parser": "^6.21.0",
"eslint": "8.56.0",
"eslint-config-next": "14.2.26",
"tsx": "^4.20.6",
"typescript": "^5.1.3",
"vitest": "^3.0.2"
}

View File

@ -0,0 +1,152 @@
import { build, BuildOptions, context } from 'esbuild';
import fs from 'fs';
import path from 'path';
// 项目路径
const ROOT_DIR = path.resolve(__dirname, '../../..');
const WORKER_SOURCE_DIR = path.join(ROOT_DIR, 'packages/service/worker');
const WORKER_OUTPUT_DIR = path.join(__dirname, '../worker');
/**
* Worker
* Turbopack Worker
*/
async function buildWorkers(watch: boolean = false) {
console.log('🔨 开始编译 Worker 文件...\n');
// 确保输出目录存在
if (!fs.existsSync(WORKER_OUTPUT_DIR)) {
fs.mkdirSync(WORKER_OUTPUT_DIR, { recursive: true });
}
// 扫描 worker 目录
if (!fs.existsSync(WORKER_SOURCE_DIR)) {
console.error(`❌ Worker 源目录不存在: ${WORKER_SOURCE_DIR}`);
process.exit(1);
}
const workers = fs.readdirSync(WORKER_SOURCE_DIR).filter((item) => {
const fullPath = path.join(WORKER_SOURCE_DIR, item);
const isDir = fs.statSync(fullPath).isDirectory();
const hasIndexTs = fs.existsSync(path.join(fullPath, 'index.ts'));
return isDir && hasIndexTs;
});
if (workers.length === 0) {
return;
}
// esbuild 通用配置
const commonConfig: BuildOptions = {
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node18',
sourcemap: false,
// Tree Shaking 和代码压缩优化
minify: true,
treeShaking: true,
keepNames: false,
// 移除调试代码
drop: process.env.NODE_ENV === 'production' ? ['console', 'debugger'] : []
};
if (watch) {
// Watch 模式:使用 esbuild context API
const contexts = await Promise.all(
workers.map(async (worker) => {
const entryPoint = path.join(WORKER_SOURCE_DIR, worker, 'index.ts');
const outfile = path.join(WORKER_OUTPUT_DIR, `${worker}.js`);
const config: BuildOptions = {
...commonConfig,
entryPoints: [entryPoint],
outfile,
logLevel: 'info'
};
try {
const ctx = await context(config);
await ctx.watch();
console.log(`👁️ ${worker} 正在监听中...`);
return ctx;
} catch (error: any) {
console.error(`${worker} Watch 启动失败:`, error.message);
return null;
}
})
);
// 过滤掉失败的 context
const validContexts = contexts.filter((ctx) => ctx !== null);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`${validContexts.length}/${workers.length} 个 Worker 正在监听中`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('\n💡 提示: 按 Ctrl+C 停止监听\n');
// 保持进程运行
process.on('SIGINT', async () => {
console.log('\n\n🛑 正在停止 Worker 监听...');
await Promise.all(validContexts.map((ctx) => ctx?.dispose()));
console.log('✅ 已停止');
process.exit(0);
});
} else {
// 单次编译模式
const buildPromises = workers.map(async (worker) => {
const entryPoint = path.join(WORKER_SOURCE_DIR, worker, 'index.ts');
const outfile = path.join(WORKER_OUTPUT_DIR, `${worker}.js`);
try {
const config: BuildOptions = {
...commonConfig,
entryPoints: [entryPoint],
outfile
};
await build(config);
console.log(`${worker} 编译成功 → ${path.relative(process.cwd(), outfile)}`);
return { success: true, worker };
} catch (error: any) {
console.error(`${worker} 编译失败:`, error.message);
return { success: false, worker, error };
}
});
// 等待所有编译完成
const results = await Promise.all(buildPromises);
// 统计结果
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`✅ 编译成功: ${successCount}/${workers.length}`);
if (failCount > 0) {
console.log(`❌ 编译失败: ${failCount}/${workers.length}`);
const failedWorkers = results.filter((r) => !r.success).map((r) => r.worker);
console.log(`失败的 Worker: ${failedWorkers.join(', ')}`);
// 非监听模式下,如果有失败的编译,退出并返回错误码
process.exit(1);
}
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
}
}
// 解析命令行参数
const args = process.argv.slice(2);
const watch = args.includes('--watch') || args.includes('-w');
// 显示启动信息
console.log('');
console.log('╔═══════════════════════════════════════╗');
console.log('║ FastGPT Worker 预编译工具 v1.0 ║');
console.log('╚═══════════════════════════════════════╝');
console.log('');
// 执行编译
buildWorkers(watch).catch((err) => {
console.error('\n❌ Worker 编译过程发生错误:', err);
process.exit(1);
});

View File

@ -21,9 +21,8 @@ import MyBox from '@fastgpt/web/components/common/MyBox';
import { getCollectionSourceData } from '@fastgpt/global/core/dataset/collection/utils';
import Markdown from '.';
import { getSourceNameIcon } from '@fastgpt/global/core/dataset/utils';
import { Types } from 'mongoose';
import { isObjectId } from '@fastgpt/global/common/string/utils';
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import { useCreation } from 'ahooks';
export type AProps = {
chatAuthData?: {
@ -67,7 +66,7 @@ const CiteLink = React.memo(function CiteLink({
const { isOpen, onOpen, onClose } = useDisclosure();
if (!Types.ObjectId.isValid(id)) {
if (!isObjectId(id)) {
return <></>;
}

View File

@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import * as echarts from 'echarts';
import type { ECharts } from 'echarts';
import { Box, Skeleton } from '@chakra-ui/react';
import json5 from 'json5';
@ -57,8 +56,10 @@ const EChartsCodeBlock = ({ code }: { code: string }) => {
if (chartRef.current) {
try {
eChart.current = echarts.init(chartRef.current);
eChart.current.setOption(option);
import('echarts').then((module) => {
eChart.current = module.init(chartRef.current!);
eChart.current.setOption(option);
});
} catch (error) {
console.error('ECharts render failed:', error);
}

View File

@ -1,26 +1,7 @@
import React, { useEffect, useRef, useCallback, useState } from 'react';
import { Box } from '@chakra-ui/react';
import mermaid from 'mermaid';
import MyIcon from '@fastgpt/web/components/common/Icon';
const mermaidAPI = mermaid.mermaidAPI;
mermaidAPI.initialize({
startOnLoad: true,
theme: 'base',
flowchart: {
useMaxWidth: false
},
themeVariables: {
fontSize: '14px',
primaryColor: '#d6e8ff',
primaryTextColor: '#485058',
primaryBorderColor: '#fff',
lineColor: '#5A646E',
secondaryColor: '#B5E9E5',
tertiaryColor: '#485058'
}
});
const punctuationMap: Record<string, string> = {
'': ',',
'': ';',
@ -44,10 +25,52 @@ const punctuationMap: Record<string, string> = {
const MermaidBlock = ({ code }: { code: string }) => {
const ref = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState('');
const [mermaid, setMermaid] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string>('');
useEffect(() => {
let mounted = true;
import('mermaid')
.then((module) => {
if (!mounted) return;
const mermaidInstance = module.default;
mermaidInstance.mermaidAPI.initialize({
startOnLoad: true,
theme: 'base',
flowchart: {
useMaxWidth: false
},
themeVariables: {
fontSize: '14px',
primaryColor: '#d6e8ff',
primaryTextColor: '#485058',
primaryBorderColor: '#fff',
lineColor: '#5A646E',
secondaryColor: '#B5E9E5',
tertiaryColor: '#485058'
}
});
setMermaid(mermaidInstance);
setIsLoading(false);
})
.catch((error) => {
console.error('Failed to load mermaid:', error);
setIsLoading(false);
});
return () => {
mounted = false;
};
}, []);
useEffect(() => {
(async () => {
if (!code) return;
if (!code || !mermaid || isLoading) return;
try {
const formatCode = code.replace(
new RegExp(`[${Object.keys(punctuationMap).join('')}]`, 'g'),
@ -56,16 +79,16 @@ const MermaidBlock = ({ code }: { code: string }) => {
const { svg } = await mermaid.render(`mermaid-${Date.now()}`, formatCode);
setSvg(svg);
} catch (e: any) {
// console.log('[Mermaid] ', e?.message);
console.log('[Mermaid] ', e?.message);
}
})();
}, [code]);
}, [code, isLoading, mermaid]);
const onclickExport = useCallback(() => {
const svg = ref.current?.children[0];
if (!svg) return;
const svgElement = ref.current?.children[0];
if (!svgElement) return;
const rate = svg.clientHeight / svg.clientWidth;
const rate = svgElement.clientHeight / svgElement.clientWidth;
const w = 3000;
const h = rate * w;
@ -74,12 +97,13 @@ const MermaidBlock = ({ code }: { code: string }) => {
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 绘制白色背景
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, w, h);
const img = new Image();
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(ref.current?.innerHTML)}`;
const innerHTML = ref.current?.innerHTML || '';
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(innerHTML);
img.onload = () => {
ctx.drawImage(img, 0, 0, w, h);
@ -97,6 +121,31 @@ const MermaidBlock = ({ code }: { code: string }) => {
};
}, []);
if (isLoading) {
return (
<Box
minW={'100px'}
minH={'50px'}
py={4}
bg={'gray.50'}
borderRadius={'md'}
textAlign={'center'}
>
Loading...
</Box>
);
}
if (error) {
return (
<Box minW={'100px'} minH={'50px'} py={4} bg={'red.50'} borderRadius={'md'} p={3}>
<Box color={'red.600'} fontSize={'sm'}>
{error}
</Box>
</Box>
);
}
return (
<Box
position={'relative'}

View File

@ -1,13 +1,14 @@
import React from 'react';
import React, { useCallback } from 'react';
import { ModalFooter, ModalBody, Input, Button, Box, Textarea, HStack } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal/index';
import { useTranslation } from 'next-i18next';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import { useForm } from 'react-hook-form';
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { useUploadAvatar } from '@fastgpt/web/common/file/hooks/useUploadAvatar';
import { getUploadAvatarPresignedUrl } from '@/web/common/file/api';
export type EditResourceInfoFormType = {
id: string;
@ -41,14 +42,14 @@ const EditResourceModal = ({
}
);
const {
File,
onOpen: onOpenSelectFile,
onSelectImage
} = useSelectFile({
fileType: '.jpg,.png',
multiple: false
});
const afterUploadAvatar = useCallback(
(avatar: string) => {
setValue('avatar', avatar);
},
[setValue]
);
const { Component: AvatarUploader, handleFileSelectorOpen: handleAvatarSelectorOpen } =
useUploadAvatar(getUploadAvatarPresignedUrl, { onSuccess: afterUploadAvatar });
return (
<MyModal isOpen onClose={onClose} iconSrc={avatar} title={title}>
@ -64,7 +65,7 @@ const EditResourceModal = ({
h={'2rem'}
cursor={'pointer'}
borderRadius={'sm'}
onClick={onOpenSelectFile}
onClick={handleAvatarSelectorOpen}
/>
</MyTooltip>
<Input
@ -86,15 +87,7 @@ const EditResourceModal = ({
</Button>
</ModalFooter>
<File
onSelect={(e) =>
onSelectImage(e, {
maxH: 300,
maxW: 300,
callback: (e) => setValue('avatar', e)
})
}
/>
<AvatarUploader />
</MyModal>
);
};

View File

@ -62,7 +62,8 @@ const LabelAndFormRender = ({
name={props.fieldName}
rules={{
validate: (value) => {
if (!required || inputType === InputTypeEnum.switch) return true;
if (!required) return true;
if (typeof value === 'number' || typeof value === 'boolean') return true;
return !!value;
},
...(!!props?.minLength

View File

@ -24,7 +24,7 @@ import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { useCreation } from 'ahooks';
import type { ChatTypeEnum } from './constants';
import type { QuickAppType } from '@fastgpt/global/core/chat/setting/type';
import type { ChatQuickAppType } from '@fastgpt/global/core/chat/setting/type';
export type ChatProviderProps = {
appId: string;
@ -39,7 +39,7 @@ export type ChatProviderProps = {
slogan?: string;
currentQuickAppId?: string;
quickAppList?: QuickAppType[];
quickAppList?: ChatQuickAppType[];
onSwitchQuickApp?: (appId: string) => Promise<void>;
};

View File

@ -21,7 +21,8 @@ export async function register() {
{ loadSystemModels },
{ connectSignoz },
{ getSystemTools },
{ trackTimerProcess }
{ trackTimerProcess },
{ initS3Buckets }
] = await Promise.all([
import('@fastgpt/service/common/mongo/init'),
import('@fastgpt/service/common/mongo/index'),
@ -36,7 +37,8 @@ export async function register() {
import('@fastgpt/service/core/ai/config/utils'),
import('@fastgpt/service/common/otel/trace/register'),
import('@fastgpt/service/core/app/plugin/controller'),
import('@fastgpt/service/common/middle/tracks/processor')
import('@fastgpt/service/common/middle/tracks/processor'),
import('@fastgpt/service/common/s3')
]);
// connect to signoz
@ -46,9 +48,19 @@ export async function register() {
systemStartCb();
initGlobalVariables();
// init s3 buckets
initS3Buckets();
// Connect to MongoDB
await connectMongo(connectionMongo, MONGO_URL);
connectMongo(connectionLogMongo, MONGO_LOG_URL);
await connectMongo({
db: connectionMongo,
url: MONGO_URL,
connectedCb: () => startMongoWatch()
});
connectMongo({
db: connectionLogMongo,
url: MONGO_LOG_URL
});
//init system configinit vector databaseinit root user
await Promise.all([getInitConfig(), initVectorStore(), initRootUser(), loadSystemModels()]);
@ -60,7 +72,6 @@ export async function register() {
initAppTemplateTypes()
]);
startMongoWatch();
startCron();
startTrainingQueue(true);
trackTimerProcess();

View File

@ -1,7 +1,6 @@
import React from 'react';
import React, { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'next-i18next';
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import MyModal from '@fastgpt/web/components/common/MyModal';
import {
@ -21,6 +20,8 @@ import { type CreateTeamProps } from '@fastgpt/global/support/user/team/controll
import { DEFAULT_TEAM_AVATAR } from '@fastgpt/global/common/system/constants';
import Icon from '@fastgpt/web/components/common/Icon';
import dynamic from 'next/dynamic';
import { useUploadAvatar } from '@fastgpt/web/common/file/hooks/useUploadAvatar';
import { getUploadAvatarPresignedUrl } from '@/web/common/file/api';
const UpdateContact = dynamic(() => import('@/components/support/user/inform/UpdateContactModal'));
export type EditTeamFormDataType = CreateTeamProps & {
@ -50,15 +51,6 @@ function EditModal({
const avatar = watch('avatar');
const notificationAccount = watch('notificationAccount');
const {
File,
onOpen: onOpenSelectFile,
onSelectImage
} = useSelectFile({
fileType: '.jpg,.png,.svg',
multiple: false
});
const { mutate: onclickCreate, isLoading: creating } = useRequest({
mutationFn: async (data: CreateTeamProps) => {
return postCreateTeam(data);
@ -88,6 +80,19 @@ function EditModal({
const { isOpen: isOpenContact, onClose: onCloseContact, onOpen: onOpenContact } = useDisclosure();
const afterUploadAvatar = useCallback(
(avatar: string) => {
setValue('avatar', avatar);
},
[setValue]
);
const { Component: AvatarUploader, handleFileSelectorOpen } = useUploadAvatar(
getUploadAvatarPresignedUrl,
{
onSuccess: afterUploadAvatar
}
);
return (
<MyModal
isOpen
@ -100,6 +105,7 @@ function EditModal({
<Box color={'myGray.800'} fontWeight={'bold'}>
{t('account_team:set_name_avatar')}
</Box>
<AvatarUploader />
<Flex mt={3} alignItems={'center'}>
<MyTooltip label={t('common:set_avatar')}>
<Avatar
@ -109,7 +115,7 @@ function EditModal({
h={['28px', '32px']}
cursor={'pointer'}
borderRadius={'md'}
onClick={onOpenSelectFile}
onClick={handleFileSelectorOpen}
/>
</MyTooltip>
<Input
@ -179,15 +185,6 @@ function EditModal({
</Button>
)}
</ModalFooter>
<File
onSelect={(e) =>
onSelectImage(e, {
maxH: 300,
maxW: 300,
callback: (e) => setValue('avatar', e)
})
}
/>
{isOpenContact && (
<UpdateContact
onClose={onCloseContact}

View File

@ -4,12 +4,13 @@ import Avatar from '@fastgpt/web/components/common/Avatar';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import { useTranslation } from 'next-i18next';
import React from 'react';
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useForm } from 'react-hook-form';
import { postCreateGroup, putUpdateGroup } from '@/web/support/user/team/group/api';
import { DEFAULT_TEAM_AVATAR } from '@fastgpt/global/common/system/constants';
import { type MemberGroupListItemType } from '@fastgpt/global/support/permission/memberGroup/type';
import { useUploadAvatar } from '@fastgpt/web/common/file/hooks/useUploadAvatar';
import { getUploadAvatarPresignedUrl } from '@/web/common/file/api';
export type GroupFormType = {
avatar: string;
@ -28,12 +29,13 @@ function GroupInfoModal({
const { t } = useTranslation();
const {
File: AvatarSelect,
onOpen: onOpenSelectAvatar,
onSelectImage
} = useSelectFile({
fileType: '.jpg, .jpeg, .png',
multiple: false
Component: AvatarUploader,
handleFileSelectorOpen: handleAvatarSelectorOpen,
uploading: uploadingAvatar
} = useUploadAvatar(getUploadAvatarPresignedUrl, {
onSuccess: (avatar: string) => {
setValue('avatar', avatar);
}
});
const { register, handleSubmit, getValues, setValue } = useForm<GroupFormType>({
@ -43,20 +45,6 @@ function GroupInfoModal({
}
});
const { loading: uploadingAvatar, run: onSelectAvatar } = useRequest2(
async (file: File[]) => {
return onSelectImage(file, {
maxW: 300,
maxH: 300
});
},
{
onSuccess: (src: string) => {
setValue('avatar', src);
}
}
);
const { runAsync: onCreate, loading: isLoadingCreate } = useRequest2(
(data: GroupFormType) => {
return postCreateGroup({
@ -96,7 +84,7 @@ function GroupInfoModal({
<HStack>
<Avatar
src={getValues('avatar')}
onClick={onOpenSelectAvatar}
onClick={handleAvatarSelectorOpen}
cursor={'pointer'}
borderRadius={'md'}
/>
@ -121,7 +109,8 @@ function GroupInfoModal({
{editGroup ? t('common:Save') : t('common:new_create')}
</Button>
</ModalFooter>
<AvatarSelect onSelect={onSelectAvatar} />
{/* <AvatarSelect onSelect={onSelectAvatar} /> */}
<AvatarUploader />
</MyModal>
);
}

View File

@ -1,7 +1,8 @@
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
import { getUploadAvatarPresignedUrl } from '@/web/common/file/api';
import { postCreateOrg, putUpdateOrg } from '@/web/support/user/team/org/api';
import { Button, HStack, Input, ModalBody, ModalFooter, Textarea } from '@chakra-ui/react';
import { DEFAULT_ORG_AVATAR } from '@fastgpt/global/common/system/constants';
import { useUploadAvatar } from '@fastgpt/web/common/file/hooks/useUploadAvatar';
import Avatar from '@fastgpt/web/components/common/Avatar';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import MyModal from '@fastgpt/web/components/common/MyModal';
@ -90,26 +91,14 @@ function OrgInfoModal({
);
const {
File: AvatarSelect,
onOpen: onOpenSelectAvatar,
onSelectImage
} = useSelectFile({
fileType: '.jpg, .jpeg, .png',
multiple: false
});
const { loading: uploadingAvatar, run: onSelectAvatar } = useRequest2(
async (file: File[]) => {
return onSelectImage(file, {
maxW: 300,
maxH: 300
});
},
{
onSuccess: (src: string) => {
setValue('avatar', src);
}
Component: AvatarUploader,
uploading: uploadingAvatar,
handleFileSelectorOpen: handleAvatarSelectorOpen
} = useUploadAvatar(getUploadAvatarPresignedUrl, {
onSuccess: (avatar) => {
setValue('avatar', avatar);
}
);
});
const isLoading = uploadingAvatar || isLoadingUpdate || isLoadingCreate;
@ -125,7 +114,7 @@ function OrgInfoModal({
<HStack>
<Avatar
src={avatar || DEFAULT_ORG_AVATAR}
onClick={onOpenSelectAvatar}
onClick={handleAvatarSelectorOpen}
cursor={'pointer'}
borderRadius={'md'}
/>
@ -158,7 +147,7 @@ function OrgInfoModal({
{isEdit ? t('common:Save') : t('common:new_create')}
</Button>
</ModalFooter>
<AvatarSelect onSelect={onSelectAvatar} />
<AvatarUploader />
</MyModal>
);
}

View File

@ -1,56 +1,16 @@
import { getDashboardData } from '@/web/support/wallet/usage/api';
import { Box, Flex } from '@chakra-ui/react';
import { formatNumber } from '@fastgpt/global/common/math/tools';
import { Box } from '@chakra-ui/react';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { addDays } from 'date-fns';
import { useTranslation } from 'next-i18next';
import React, { useMemo } from 'react';
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
type TooltipProps
} from 'recharts';
import { type NameType, type ValueType } from 'recharts/types/component/DefaultTooltipContent';
import { type UnitType, type UsageFilterParams } from './type';
import { type UsageFilterParams } from './type';
import dayjs from 'dayjs';
import dynamic from 'next/dynamic';
export type usageFormType = {
date: string;
totalPoints: number;
};
const CustomTooltip = ({ active, payload }: TooltipProps<ValueType, NameType>) => {
const data = payload?.[0]?.payload as usageFormType;
const { t } = useTranslation();
if (active && data) {
return (
<Box
bg={'white'}
p={3}
borderRadius={'md'}
border={'0.5px solid'}
borderColor={'myGray.200'}
boxShadow={
'0px 24px 48px -12px rgba(19, 51, 107, 0.20), 0px 0px 1px 0px rgba(19, 51, 107, 0.20)'
}
>
<Box fontSize={'mini'} color={'myGray.600'} mb={3}>
{data.date}
</Box>
<Box fontSize={'14px'} color={'myGray.900'} fontWeight={'medium'}>
{`${formatNumber(data.totalPoints)} ${t('account_usage:points')}`}
</Box>
</Box>
);
}
return null;
};
const DashboardChart = dynamic(() => import('./DashboardChart'), {
ssr: false
});
const UsageDashboard = ({
filterParams,
@ -61,8 +21,6 @@ const UsageDashboard = ({
Tabs: React.ReactNode;
Selectors: React.ReactNode;
}) => {
const { t } = useTranslation();
const { dateRange, selectTmbIds, usageSources, unit, isSelectAllSource, isSelectAllTmb } =
filterParams;
@ -99,41 +57,7 @@ const UsageDashboard = ({
<Box>{Tabs}</Box>
<Box mt={4}>{Selectors}</Box>
<MyBox overflowY={'auto'} isLoading={totalPointsLoading}>
<Flex fontSize={'20px'} fontWeight={'medium'} my={6}>
<Box color={'black'}>{`${t('account_usage:total_usage')}:`}</Box>
<Box color={'primary.600'} ml={2}>
{`${formatNumber(totalUsage)} ${t('account_usage:points')}`}
</Box>
</Flex>
<Flex mb={4} fontSize={'mini'} color={'myGray.500'} fontWeight={'medium'}>
{t('account_usage:points')}
</Flex>
<ResponsiveContainer width="100%" height={424}>
<LineChart data={totalPoints} margin={{ top: 10, right: 30, left: -12, bottom: 0 }}>
<XAxis
dataKey="date"
padding={{ left: 40, right: 40 }}
tickMargin={10}
tickSize={0}
tick={{ fontSize: '12px', color: '#667085', fontWeight: '500' }}
/>
<YAxis
axisLine={false}
tickSize={0}
tickMargin={12}
tick={{ fontSize: '12px', color: '#667085', fontWeight: '500' }}
/>
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} />
<Tooltip content={<CustomTooltip />} />
<Line
type="monotone"
dataKey="totalPoints"
stroke="#5E8FFF"
strokeWidth={2.5}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
<DashboardChart totalPoints={totalPoints} totalUsage={totalUsage} />
</MyBox>
</>
);

View File

@ -0,0 +1,172 @@
import React, { useEffect, useState } from 'react';
import { Box, Flex, Skeleton } from '@chakra-ui/react';
import { formatNumber } from '@fastgpt/global/common/math/tools';
import { type NameType, type ValueType } from 'recharts/types/component/DefaultTooltipContent';
import type { TooltipProps } from 'recharts';
import { useTranslation } from 'next-i18next';
export type usageFormType = {
date: string;
totalPoints: number;
};
type RechartsComponents = {
ResponsiveContainer: any;
LineChart: any;
Line: any;
XAxis: any;
YAxis: any;
CartesianGrid: any;
Tooltip: any;
};
const CustomTooltip = ({ active, payload }: TooltipProps<ValueType, NameType>) => {
const data = payload?.[0]?.payload as usageFormType;
const { t } = useTranslation();
if (active && data) {
return (
<Box
bg={'white'}
p={3}
borderRadius={'md'}
border={'0.5px solid'}
borderColor={'myGray.200'}
boxShadow={
'0px 24px 48px -12px rgba(19, 51, 107, 0.20), 0px 0px 1px 0px rgba(19, 51, 107, 0.20)'
}
>
<Box fontSize={'mini'} color={'myGray.600'} mb={3}>
{data.date}
</Box>
<Box fontSize={'14px'} color={'myGray.900'} fontWeight={'medium'}>
{`${formatNumber(data.totalPoints)} ${t('account_usage:points')}`}
</Box>
</Box>
);
}
return null;
};
const DashboardChart = ({
totalPoints,
totalUsage
}: {
totalPoints: usageFormType[];
totalUsage: number;
}) => {
const { t } = useTranslation();
const [recharts, setRecharts] = useState<RechartsComponents | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string>('');
// 动态导入 recharts
useEffect(() => {
let mounted = true;
import('recharts')
.then((module) => {
if (!mounted) return;
setRecharts({
ResponsiveContainer: module.ResponsiveContainer,
LineChart: module.LineChart,
Line: module.Line,
XAxis: module.XAxis,
YAxis: module.YAxis,
CartesianGrid: module.CartesianGrid,
Tooltip: module.Tooltip
});
setIsLoading(false);
})
.catch((error) => {
console.error('Failed to load recharts:', error);
setError('加载图表库失败');
setIsLoading(false);
});
return () => {
mounted = false;
};
}, []);
// 加载状态
if (isLoading) {
return (
<Box>
<Flex fontSize={'20px'} fontWeight={'medium'} my={6}>
<Box color={'black'}>{`${t('account_usage:total_usage')}:`}</Box>
<Box color={'primary.600'} ml={2}>
{`${formatNumber(totalUsage)} ${t('account_usage:points')}`}
</Box>
</Flex>
<Flex mb={4} fontSize={'mini'} color={'myGray.500'} fontWeight={'medium'}>
{t('account_usage:points')}
</Flex>
<Skeleton height="424px" width="100%" borderRadius={'md'} />
</Box>
);
}
// 错误状态
if (error || !recharts) {
return (
<Box>
<Flex fontSize={'20px'} fontWeight={'medium'} my={6}>
<Box color={'black'}>{`${t('account_usage:total_usage')}:`}</Box>
<Box color={'primary.600'} ml={2}>
{`${formatNumber(totalUsage)} ${t('account_usage:points')}`}
</Box>
</Flex>
<Box minH={'424px'} py={4} bg={'red.50'} borderRadius={'md'} p={3}>
<Box color={'red.600'} fontSize={'sm'}>
{error || '图表加载失败'}
</Box>
</Box>
</Box>
);
}
const { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip } = recharts;
return (
<>
<Flex fontSize={'20px'} fontWeight={'medium'} my={6}>
<Box color={'black'}>{`${t('account_usage:total_usage')}:`}</Box>
<Box color={'primary.600'} ml={2}>
{`${formatNumber(totalUsage)} ${t('account_usage:points')}`}
</Box>
</Flex>
<Flex mb={4} fontSize={'mini'} color={'myGray.500'} fontWeight={'medium'}>
{t('account_usage:points')}
</Flex>
<ResponsiveContainer width="100%" height={424}>
<LineChart data={totalPoints} margin={{ top: 10, right: 30, left: -12, bottom: 0 }}>
<XAxis
dataKey="date"
padding={{ left: 40, right: 40 }}
tickMargin={10}
tickSize={0}
tick={{ fontSize: '12px', color: '#667085', fontWeight: '500' }}
/>
<YAxis
axisLine={false}
tickSize={0}
tickMargin={12}
tick={{ fontSize: '12px', color: '#667085', fontWeight: '500' }}
/>
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} />
<Tooltip content={<CustomTooltip />} />
<Line
type="monotone"
dataKey="totalPoints"
stroke="#5E8FFF"
strokeWidth={2.5}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</>
);
};
export default DashboardChart;

View File

@ -4,7 +4,7 @@ import { useContextSelector } from 'use-context-selector';
import { AppContext } from '../context';
import ChatItemContextProvider from '@/web/core/chat/context/chatItemContext';
import ChatRecordContextProvider from '@/web/core/chat/context/chatRecordContext';
import { Box, Button, Flex, HStack } from '@chakra-ui/react';
import { Box, Button, Center, Flex, HStack } from '@chakra-ui/react';
import { cardStyles } from '../constants';
import { useTranslation } from 'next-i18next';
import { type HttpToolConfigType } from '@fastgpt/global/core/app/type';
@ -19,6 +19,7 @@ import LabelAndFormRender from '@/components/core/app/formRender/LabelAndForm';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import ValueTypeLabel from '../WorkflowComponents/Flow/nodes/render/ValueTypeLabel';
import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
const ChatTest = ({
currentTool,
@ -52,14 +53,16 @@ const ChatTest = ({
const { runAsync: runTool, loading: isRunning } = useRequest2(
async (data: Record<string, any>) => {
if (!currentTool) return;
return await postRunHTTPTool({
return postRunHTTPTool({
baseUrl,
params: data,
headerSecret,
headerSecret: currentTool.headerSecret || headerSecret,
toolPath: currentTool.path,
method: currentTool.method,
customHeaders: customHeaders
customHeaders: customHeaders,
staticParams: currentTool.staticParams,
staticHeaders: currentTool.staticHeaders,
staticBody: currentTool.staticBody
});
},
{
@ -74,6 +77,7 @@ const ChatTest = ({
}
}
);
console.log(currentTool);
return (
<Flex h={'full'} gap={2}>
@ -95,72 +99,81 @@ const ChatTest = ({
<Box flex={1} />
</Flex>
<Box px={[2, 5]} mb={6}>
<LightRowTabs
list={tabList}
value={activeTab}
onChange={(value) => {
setActiveTab(value);
}}
/>
</Box>
{activeTab === 'input' ? (
<Box flex={1} px={[2, 5]} overflow={'auto'}>
{Object.keys(currentTool?.inputSchema.properties || {}).length > 0 ? (
<>
<Box border={'1px solid'} borderColor={'myGray.200'} borderRadius={'8px'} p={3}>
{Object.entries(currentTool?.inputSchema.properties || {}).map(
([paramName, paramInfo]) => {
const inputType = valueTypeToInputType(
getNodeInputTypeFromSchemaInputType({ type: paramInfo.type })
);
const required = currentTool?.inputSchema.required?.includes(paramName);
return (
<LabelAndFormRender
label={
<HStack spacing={0} mr={2}>
<FormLabel required={required}>{paramName}</FormLabel>
<ValueTypeLabel
valueType={getNodeInputTypeFromSchemaInputType({
type: paramInfo.type,
arrayItems: paramInfo.items
})}
h={'auto'}
/>
</HStack>
}
required={required}
key={paramName}
inputType={inputType}
fieldName={paramName}
form={form}
placeholder={paramName}
/>
);
}
)}
</Box>
</>
) : (
<Box fontWeight={'medium'} pb={4} px={2}>
{t('app:this_tool_requires_no_input')}
</Box>
)}
<Button mt={3} isLoading={isRunning} onClick={handleSubmit(runTool)}>
{t('common:Run')}
</Button>
</Box>
{!currentTool ? (
<Center>
<EmptyTip text={t('app:empty_tool_tips')} />
</Center>
) : (
<Box flex={1} px={[2, 5]} overflow={'auto'}>
{output && (
<Box>
<Markdown source={`~~~json\n${output}`} />
<>
<Box px={[2, 5]} mb={6}>
<LightRowTabs
gap={4}
list={tabList}
value={activeTab}
onChange={(value) => {
setActiveTab(value);
}}
/>
</Box>
{activeTab === 'input' ? (
<Box flex={1} px={[2, 5]} overflow={'auto'}>
{Object.keys(currentTool?.inputSchema.properties || {}).length > 0 ? (
<>
<Box border={'1px solid'} borderColor={'myGray.200'} borderRadius={'8px'} p={3}>
{Object.entries(currentTool?.inputSchema.properties || {}).map(
([paramName, paramInfo]) => {
const inputType = valueTypeToInputType(
getNodeInputTypeFromSchemaInputType({ type: paramInfo.type })
);
const required = currentTool?.inputSchema.required?.includes(paramName);
return (
<LabelAndFormRender
label={
<HStack spacing={0} mr={2}>
<FormLabel required={required}>{paramName}</FormLabel>
<ValueTypeLabel
valueType={getNodeInputTypeFromSchemaInputType({
type: paramInfo.type,
arrayItems: paramInfo.items
})}
h={'auto'}
/>
</HStack>
}
required={required}
key={paramName}
inputType={inputType}
fieldName={paramName}
form={form}
placeholder={paramName}
/>
);
}
)}
</Box>
</>
) : (
<Box fontWeight={'medium'} pb={4} px={2}>
{t('app:this_tool_requires_no_input')}
</Box>
)}
<Button mt={3} isLoading={isRunning} onClick={handleSubmit(runTool)}>
{t('common:Run')}
</Button>
</Box>
) : (
<Box flex={1} px={[2, 5]} overflow={'auto'}>
{output && (
<Box>
<Markdown source={`~~~json\n${output}`} />
</Box>
)}
</Box>
)}
</Box>
</>
)}
</Box>
</Flex>

View File

@ -0,0 +1,141 @@
import MyModal from '@fastgpt/web/components/common/MyModal';
import React from 'react';
import { useTranslation } from 'next-i18next';
import { Button, ModalBody, ModalFooter, Textarea } from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { parseCurl } from '@fastgpt/global/common/string/http';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { type HttpMethod, ContentTypes } from '@fastgpt/global/core/workflow/constants';
import type { ParamItemType } from './ManualToolModal';
import type { StoreSecretValueType } from '@fastgpt/global/common/secret/type';
export type CurlImportResult = {
method: HttpMethod;
path: string;
params?: ParamItemType[];
headers?: ParamItemType[];
bodyType: string;
bodyContent?: string;
bodyFormData?: ParamItemType[];
headerSecret?: StoreSecretValueType;
};
type CurlImportModalProps = {
onClose: () => void;
onImport: (result: CurlImportResult) => void;
};
const CurlImportModal = ({ onClose, onImport }: CurlImportModalProps) => {
const { t } = useTranslation();
const { toast } = useToast();
const { register, handleSubmit } = useForm({
defaultValues: {
curlContent: ''
}
});
const handleCurlImport = (data: { curlContent: string }) => {
try {
const parsed = parseCurl(data.curlContent);
const convertToParamItemType = (
items: Array<{ key: string; value?: string; type?: string }>
): ParamItemType[] => {
return items.map((item) => ({
key: item.key,
value: item.value || ''
}));
};
const { headerSecret, filteredHeaders } = (() => {
let headerSecret: StoreSecretValueType | undefined;
const filteredHeaders = parsed.headers.filter((header) => {
if (header.key.toLowerCase() === 'authorization') {
const authValue = header.value || '';
if (authValue.startsWith('Bearer ')) {
const token = authValue.substring(7).trim();
headerSecret = {
Bearer: {
value: token,
secret: ''
}
};
return false;
}
if (authValue.startsWith('Basic ')) {
const credentials = authValue.substring(6).trim();
headerSecret = {
Basic: {
value: credentials,
secret: ''
}
};
return false;
}
}
return true;
});
return { headerSecret, filteredHeaders };
})();
const bodyType = (() => {
if (!parsed.body || parsed.body === '{}') {
return ContentTypes.none;
}
return ContentTypes.json;
})();
const result: CurlImportResult = {
method: parsed.method as HttpMethod,
path: parsed.url,
params: parsed.params.length > 0 ? convertToParamItemType(parsed.params) : undefined,
headers: filteredHeaders.length > 0 ? convertToParamItemType(filteredHeaders) : undefined,
bodyType,
bodyContent: bodyType === ContentTypes.json ? parsed.body : undefined,
...(headerSecret && { headerSecret })
};
onImport(result);
toast({
title: t('common:import_success'),
status: 'success'
});
} catch (error: any) {
toast({
title: t('common:import_failed'),
description: error.message,
status: 'error'
});
console.error('Curl import error:', error);
}
};
return (
<MyModal
isOpen
onClose={onClose}
iconSrc="modal/edit"
title={t('common:core.module.http.curl import')}
w={600}
>
<ModalBody>
<Textarea
rows={20}
mt={2}
autoFocus
{...register('curlContent')}
placeholder={t('common:core.module.http.curl import placeholder')}
/>
</ModalBody>
<ModalFooter>
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
{t('common:Close')}
</Button>
<Button onClick={handleSubmit(handleCurlImport)}>{t('common:Confirm')}</Button>
</ModalFooter>
</MyModal>
);
};
export default React.memo(CurlImportModal);

View File

@ -1,6 +1,6 @@
import { Box, Flex } from '@chakra-ui/react';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import styles from '../SimpleApp/styles.module.scss';
import { cardStyles } from '../constants';
import AppCard from './AppCard';
@ -27,7 +27,7 @@ const Edit = () => {
);
const baseUrl = toolSetData?.baseUrl ?? '';
const toolList = toolSetData?.toolList ?? [];
const apiSchemaStr = toolSetData?.apiSchemaStr ?? '';
const apiSchemaStr = toolSetData?.apiSchemaStr;
const headerSecret = toolSetData?.headerSecret ?? {};
const customHeaders = useMemo(() => {
try {
@ -37,6 +37,20 @@ const Edit = () => {
}
}, [appDetail.pluginData?.customHeaders]);
useEffect(() => {
if (!currentTool || toolList.length === 0) {
setCurrentTool(toolList[0]);
return;
}
const updatedTool = toolList.find((tool) => tool.name === currentTool.name);
if (updatedTool) {
setCurrentTool(updatedTool);
} else {
setCurrentTool(toolList[0]);
}
}, [toolSetData]);
return (
<MyBox
display={['block', 'flex']}

View File

@ -1,4 +1,13 @@
import { Box, Button, Flex, ModalBody, ModalFooter, Switch, useDisclosure } from '@chakra-ui/react';
import {
Box,
Button,
Center,
Flex,
ModalBody,
ModalFooter,
Switch,
useDisclosure
} from '@chakra-ui/react';
import React, { useState } from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
@ -13,7 +22,8 @@ import Avatar from '@fastgpt/web/components/common/Avatar';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { putUpdateHttpPlugin } from '@/web/core/app/api/plugin';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import ConfigModal from './ConfigModal';
import SchemaConfigModal from './SchemaConfigModal';
import ManualToolModal from './ManualToolModal';
import type { StoreSecretValueType } from '@fastgpt/global/common/secret/type';
import type { UpdateHttpPluginBody } from '@/pages/api/core/app/httpTools/update';
@ -36,7 +46,13 @@ const EditForm = ({
}) => {
const { t } = useTranslation();
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
const reloadApp = useContextSelector(AppContext, (v) => v.reloadApp);
const [toolDetail, setToolDetail] = useState<HttpToolConfigType | null>(null);
const [editingManualTool, setEditingManualTool] = useState<HttpToolConfigType | null>(null);
const isBatchMode = apiSchemaStr !== undefined;
const {
onOpen: onOpenConfigModal,
@ -44,6 +60,22 @@ const EditForm = ({
onClose: onCloseConfigModal
} = useDisclosure();
const { runAsync: runDeleteHttpTool, loading: isDeletingTool } = useRequest2(
async (updatedToolList: HttpToolConfigType[]) =>
await putUpdateHttpPlugin({
appId: appDetail._id,
toolList: updatedToolList
}),
{
manual: true,
onSuccess: () => {
reloadApp();
},
successToast: t('common:delete_success'),
errorToast: t('common:delete_failed')
}
);
return (
<>
<Box p={6}>
@ -54,122 +86,185 @@ const EditForm = ({
total: toolList?.length || 0
})}
</FormLabel>
<Button
px={'2'}
leftIcon={
<MyIcon
name={toolList?.length && toolList.length > 0 ? 'change' : 'common/setting'}
w={'18px'}
/>
}
onClick={onOpenConfigModal}
>
{toolList?.length && toolList.length > 0 ? t('common:Config') : t('app:Start_config')}
</Button>
{isBatchMode ? (
<Button
px={'2'}
leftIcon={
<MyIcon
name={toolList?.length && toolList.length > 0 ? 'change' : 'common/setting'}
w={'18px'}
/>
}
onClick={onOpenConfigModal}
>
{toolList?.length && toolList.length > 0 ? t('common:Config') : t('app:Start_config')}
</Button>
) : (
<Button
px={'2'}
leftIcon={<MyIcon name={'common/addLight'} w={'18px'} />}
onClick={() =>
setEditingManualTool({
name: '',
description: '',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
path: '',
method: 'POST'
})
}
>
{t('common:Add')}
</Button>
)}
</Flex>
<Box mt={3}>
{toolList?.map((tool, index) => {
return (
<MyBox
key={tool.name}
role="group"
position="relative"
border={'1px solid'}
{...(currentTool?.name === tool.name
? {
borderRadius: '8px',
borderColor: 'primary.600',
borderBottomColor: 'primary.600',
boxShadow:
'0px 4px 4px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
: {
borderRadius: 'none',
borderColor: 'transparent',
borderBottomColor: 'myGray.150',
boxShadow: 'none'
})}
_hover={{
borderRadius: '8px',
boxShadow:
'0px 4px 4px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}}
cursor={'pointer'}
onClick={() => {
setCurrentTool?.(tool);
}}
>
<Flex alignItems={'center'} py={3} px={3}>
<Box maxW={'full'} pl={2} position="relative" width="calc(100% - 30px)">
<Flex alignItems="center" gap={2} mb={1}>
<Box>{renderHttpMethod(tool.method)}</Box>
<Box
color={'myGray.900'}
fontSize={'14px'}
lineHeight={'20px'}
letterSpacing={'0.25px'}
>
{tool.name}
</Box>
<Box w={'1px'} h={'12px'} bg={'myGray.250'}></Box>
<Box
color={'myGray.600'}
fontSize={'14px'}
lineHeight={'20px'}
letterSpacing={'0.25px'}
>
{tool.path}
</Box>
</Flex>
<Box
overflow="hidden"
whiteSpace="nowrap"
color={'myGray.500'}
textOverflow="ellipsis"
fontSize={'12px'}
lineHeight={'16px'}
letterSpacing={'0.048px'}
>
{tool.description || t('app:tools_no_description')}
</Box>
</Box>
<Box flex={1} />
</Flex>
<Flex
position="absolute"
right={3}
top="50%"
transform="translateY(-50%)"
gap={2}
display="none"
_groupHover={{ display: 'flex' }}
bg="linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 15%, rgba(255,255,255,1) 100%)"
paddingLeft="20px"
<MyBox mt={3} isLoading={isDeletingTool}>
{toolList && toolList.length > 0 ? (
toolList.map((tool, index) => {
return (
<MyBox
key={tool.name}
role="group"
position="relative"
border={'1px solid'}
{...(currentTool?.name === tool.name
? {
borderRadius: '8px',
borderColor: 'primary.600',
borderBottomColor: 'primary.600',
boxShadow:
'0px 4px 4px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
: {
borderRadius: 'none',
borderColor: 'transparent',
borderBottomColor: 'myGray.150',
boxShadow: 'none'
})}
_hover={{
borderRadius: '8px',
boxShadow:
'0px 4px 4px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}}
cursor={'pointer'}
onClick={() => {
setCurrentTool?.(tool);
}}
>
<MyIconButton
size={'16px'}
icon={'common/detail'}
p={2}
border={'1px solid'}
borderColor={'myGray.250'}
hoverBg={'rgba(51, 112, 255, 0.10)'}
hoverBorderColor={'primary.300'}
tip={t('app:HTTP_tools_detail')}
onClick={(e) => {
e.stopPropagation();
setToolDetail(tool);
}}
/>
</Flex>
</MyBox>
);
})}
</Box>
<Flex alignItems={'center'} py={3} px={3}>
<Box maxW={'full'} pl={2} position="relative" width="calc(100% - 30px)">
<Flex alignItems="center" gap={2} mb={1}>
<Box>{renderHttpMethod(tool.method)}</Box>
<Box
color={'myGray.900'}
fontSize={'14px'}
lineHeight={'20px'}
letterSpacing={'0.25px'}
>
{tool.name}
</Box>
<Box w={'1px'} h={'12px'} bg={'myGray.250'}></Box>
<Box
color={'myGray.600'}
fontSize={'14px'}
lineHeight={'20px'}
letterSpacing={'0.25px'}
>
{tool.path}
</Box>
</Flex>
<Box
overflow="hidden"
whiteSpace="nowrap"
color={'myGray.500'}
textOverflow="ellipsis"
fontSize={'12px'}
lineHeight={'16px'}
letterSpacing={'0.048px'}
>
{tool.description || t('app:tools_no_description')}
</Box>
</Box>
<Box flex={1} />
</Flex>
<Flex
position="absolute"
right={3}
top="50%"
transform="translateY(-50%)"
gap={2}
display="none"
_groupHover={{ display: 'flex' }}
bg="linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 15%, rgba(255,255,255,1) 100%)"
paddingLeft="20px"
>
{isBatchMode ? (
<MyIconButton
size={'16px'}
icon={'common/detail'}
p={2}
border={'1px solid'}
borderColor={'myGray.250'}
hoverBg={'rgba(51, 112, 255, 0.10)'}
hoverBorderColor={'primary.300'}
tip={t('app:HTTP_tools_detail')}
onClick={(e) => {
e.stopPropagation();
setToolDetail(tool);
}}
/>
) : (
<>
<MyIconButton
size={'16px'}
icon={'edit'}
p={2}
border={'1px solid'}
borderColor={'myGray.250'}
hoverBg={'rgba(51, 112, 255, 0.10)'}
hoverBorderColor={'primary.300'}
tip={t('common:Edit')}
onClick={(e) => {
e.stopPropagation();
setEditingManualTool(tool);
}}
/>
<MyIconButton
size={'16px'}
icon={'delete'}
p={2}
border={'1px solid'}
borderColor={'myGray.250'}
_hover={{
color: 'red.500',
bg: 'rgba(255, 0, 0, 0.10)',
borderColor: 'red.300'
}}
tip={t('common:Delete')}
onClick={(e) => {
e.stopPropagation();
const updatedToolList =
toolList?.filter((t) => t.name !== tool.name) || [];
runDeleteHttpTool(updatedToolList);
}}
/>
</>
)}
</Flex>
</MyBox>
);
})
) : (
<Center h={24} fontSize={'14px'}>
{isBatchMode ? t('app:http_toolset_config_tips') : t('app:http_toolset_add_tips')}
</Center>
)}
</MyBox>
</Box>
{isOpenConfigModal && <ConfigModal onClose={onCloseConfigModal} />}
{isOpenConfigModal && <SchemaConfigModal onClose={onCloseConfigModal} />}
{toolDetail && (
<ToolDetailModal
tool={toolDetail}
@ -181,6 +276,12 @@ const EditForm = ({
customHeaders={customHeaders || '{}'}
/>
)}
{editingManualTool && (
<ManualToolModal
onClose={() => setEditingManualTool(null)}
editingTool={editingManualTool}
/>
)}
</>
);
};

View File

@ -0,0 +1,783 @@
import MyModal from '@fastgpt/web/components/common/MyModal';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'next-i18next';
import {
Box,
Button,
Flex,
Input,
ModalBody,
ModalFooter,
Textarea,
useDisclosure,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Switch,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon
} from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useContextSelector } from 'use-context-selector';
import { AppContext } from '../context';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import MySelect from '@fastgpt/web/components/common/MySelect';
import { useToast } from '@fastgpt/web/hooks/useToast';
import {
HTTP_METHODS,
type HttpMethod,
toolValueTypeList,
ContentTypes,
VARIABLE_NODE_ID
} from '@fastgpt/global/core/workflow/constants';
import {
headerValue2StoreHeader,
storeHeader2HeaderValue
} from '@/components/common/secret/HeaderAuthConfig';
import HeaderAuthForm from '@/components/common/secret/HeaderAuthForm';
import type { StoreSecretValueType } from '@fastgpt/global/common/secret/type';
import MyIcon from '@fastgpt/web/components/common/Icon';
import HttpInput from '@fastgpt/web/components/common/Input/HttpInput';
import { putUpdateHttpPlugin } from '@/web/core/app/api/plugin';
import type { HttpToolConfigType } from '@fastgpt/global/core/app/type';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import CurlImportModal from './CurlImportModal';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import type { EditorVariableLabelPickerType } from '@fastgpt/web/components/common/Textarea/PromptEditor/type';
import PromptEditor from '@fastgpt/web/components/common/Textarea/PromptEditor';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
type ManualToolFormType = {
name: string;
description: string;
method: HttpMethod;
path: string;
headerSecret: StoreSecretValueType;
customParams: CustomParamItemType[];
params: ParamItemType[];
bodyType: ContentTypes;
bodyContent: string;
bodyFormData: ParamItemType[];
headers: ParamItemType[];
};
type CustomParamItemType = {
key: string;
description: string;
type: string;
required: boolean;
isTool: boolean;
};
export type ParamItemType = {
key: string;
value: string;
};
const ManualToolModal = ({
onClose,
editingTool
}: {
onClose: () => void;
editingTool: HttpToolConfigType;
}) => {
const { t } = useTranslation();
const { toast } = useToast();
const { feConfigs } = useSystemStore();
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
const reloadApp = useContextSelector(AppContext, (v) => v.reloadApp);
const isEditMode = !!editingTool.name;
const { register, handleSubmit, watch, setValue } = useForm<ManualToolFormType>({
defaultValues: {
name: editingTool.name,
description: editingTool.description,
method: editingTool.method.toUpperCase() as HttpMethod,
path: editingTool.path,
headerSecret: editingTool.headerSecret || {},
customParams: editingTool
? Object.entries(editingTool.inputSchema.properties || {}).map(
([key, value]: [string, any]) => ({
key,
description: value.description || '',
type: value.type || 'string',
required: editingTool.inputSchema.required?.includes(key) || false,
isTool: !!value['x-tool-description']
})
)
: [],
params: editingTool.staticParams || [],
bodyType: editingTool.staticBody?.type || ContentTypes.json,
bodyContent: editingTool.staticBody?.content || '',
bodyFormData: editingTool.staticBody?.formData || [],
headers: editingTool.staticHeaders || []
}
});
const method = watch('method');
const headerSecret = watch('headerSecret');
const customParams = watch('customParams');
const params = watch('params');
const bodyType = watch('bodyType');
const bodyContent = watch('bodyContent');
const bodyFormData = watch('bodyFormData');
const headers = watch('headers');
const hasBody = method !== 'GET' && method !== 'DELETE';
const isFormBody =
bodyType === ContentTypes.formData || bodyType === ContentTypes.xWwwFormUrlencoded;
const isContentBody =
bodyType === ContentTypes.json ||
bodyType === ContentTypes.xml ||
bodyType === ContentTypes.raw;
const [editingParam, setEditingParam] = useState<CustomParamItemType | null>(null);
const {
onOpen: onOpenCurlImport,
isOpen: isOpenCurlImport,
onClose: onCloseCurlImport
} = useDisclosure();
const { runAsync: onSubmit, loading: isSubmitting } = useRequest2(
async (data: ManualToolFormType) => {
if (bodyType === ContentTypes.json && bodyContent) {
try {
JSON.parse(bodyContent);
} catch (error) {
return Promise.reject(t('common:json_parse_error'));
}
}
const inputProperties: Record<string, any> = {};
const inputRequired: string[] = [];
customParams.forEach((param) => {
inputProperties[param.key] = {
type: param.type,
description: param.description || '',
'x-tool-description': param.isTool ? param.description : ''
};
if (param.required) {
inputRequired.push(param.key);
}
});
const newTool: HttpToolConfigType = {
name: data.name,
description: data.description,
path: data.path,
method: data.method.toLowerCase(),
inputSchema: {
type: 'object',
properties: inputProperties,
required: inputRequired
},
outputSchema: {
type: 'object',
properties: {},
required: []
},
...(params.length > 0 && { staticParams: params }),
...(headers.length > 0 && { staticHeaders: headers }),
...(hasBody &&
bodyType !== ContentTypes.none && {
staticBody: {
type: bodyType,
...(isContentBody ? { content: bodyContent } : {}),
...(isFormBody ? { formData: bodyFormData } : {})
}
}),
headerSecret: data.headerSecret
};
const toolSetNode = appDetail.modules.find(
(item) => item.flowNodeType === FlowNodeTypeEnum.toolSet
);
const existingToolList = toolSetNode?.toolConfig?.httpToolSet?.toolList || [];
const updatedToolList = (() => {
if (isEditMode) {
return existingToolList.map((tool) => (tool.name === editingTool.name ? newTool : tool));
}
return [...existingToolList, newTool];
})();
return putUpdateHttpPlugin({
appId: appDetail._id,
toolList: updatedToolList
});
},
{
onSuccess: () => {
reloadApp();
onClose();
}
}
);
const formatVariables = useMemo(
() =>
customParams.map((item) => ({
key: item.key,
label: item.key,
parent: {
id: VARIABLE_NODE_ID,
label: t('app:Custom_params'),
avatar: 'core/workflow/template/variable'
}
})),
[t, customParams]
);
return (
<MyModal
isOpen
onClose={onClose}
iconSrc={isEditMode ? 'modal/edit' : 'common/addLight'}
iconColor={'primary.600'}
title={isEditMode ? t('app:Edit_tool') : t('app:Add_tool')}
w={'100%'}
h={'100%'}
maxW={['90vh', '1080px']}
minH={['90vh', '600px']}
>
<ModalBody display={'flex'} h={'100%'} px={0} py={5}>
<Flex
flex={4}
px={10}
flexDirection={'column'}
gap={6}
borderRight={'base'}
h={'100%'}
overflow={'auto'}
>
<Flex gap={8} alignItems={'center'}>
<FormLabel>{t('app:Tool_name')}</FormLabel>
<Input
h={8}
{...register('name', { required: true })}
placeholder={t('app:Tool_name')}
/>
</Flex>
<Box>
<FormLabel mb={2}>{t('app:Tool_description')}</FormLabel>
<Textarea
{...register('description')}
rows={6}
minH={'100px'}
placeholder={t('app:Tool_description')}
/>
</Box>
<Box>
<Flex alignItems={'center'} mb={2}>
<FormLabel flex={1} alignItems={'center'}>
{t('app:Custom_params')}
<QuestionTip ml={1} label={t('app:input_params_tips')} />
</FormLabel>
<Button
size={'sm'}
variant={'whitePrimary'}
leftIcon={<MyIcon name={'common/addLight'} w={'14px'} />}
onClick={() => {
setEditingParam({
key: '',
description: '',
type: 'string',
required: false,
isTool: true
});
}}
>
{t('common:add_new')}
</Button>
</Flex>
<CustomParamsTable
list={customParams}
onEdit={(param) => {
setEditingParam(param);
}}
onDelete={(index) => {
setValue(
'customParams',
customParams.filter((_, i) => i !== index)
);
}}
/>
</Box>
</Flex>
<Flex flex={5} px={10} flexDirection={'column'} gap={6} h={'100%'} overflow={'auto'}>
<Box px={2}>
<Flex mb={2} alignItems={'center'} justifyContent={'space-between'}>
<FormLabel>{t('common:core.module.Http request settings')}</FormLabel>
<Button size={'sm'} onClick={onOpenCurlImport}>
{t('common:core.module.http.curl import')}
</Button>
</Flex>
<Flex gap={2}>
<MySelect
h={9}
w={'100px'}
value={method}
list={HTTP_METHODS.map((method) => ({ label: method, value: method }))}
onChange={(e) => setValue('method', e)}
/>
<Input
{...register('path', { required: true })}
placeholder={t('common:core.module.input.label.Http Request Url')}
/>
</Flex>
</Box>
<Accordion allowMultiple defaultIndex={[0, 1, 2]}>
<AccordionItem border={'none'}>
<AccordionButton
fontSize={'sm'}
fontWeight={'500'}
color={'myGray.900'}
justifyContent={'space-between'}
alignItems={'center'}
borderRadius={'md'}
px={2}
py={2}
_hover={{ bg: 'myGray.50' }}
>
{t('common:auth_config')}
<AccordionIcon />
</AccordionButton>
<AccordionPanel py={1} px={2} mb={5}>
<HeaderAuthForm
headerSecretValue={storeHeader2HeaderValue(headerSecret)}
onChange={(data) => {
const storeData = headerValue2StoreHeader(data);
setValue('headerSecret', storeData);
}}
fontWeight="normal"
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem border={'none'}>
<AccordionButton
fontSize={'sm'}
fontWeight={'500'}
color={'myGray.900'}
justifyContent={'space-between'}
alignItems={'center'}
borderRadius={'md'}
px={2}
py={2}
_hover={{ bg: 'myGray.50' }}
>
Params
<AccordionIcon />
</AccordionButton>
<AccordionPanel py={1} px={2} mb={5}>
<ParamsTable
list={params}
setList={(newParams) => setValue('params', newParams)}
variableLabels={formatVariables}
/>
</AccordionPanel>
</AccordionItem>
{hasBody && (
<AccordionItem border={'none'}>
<AccordionButton
fontSize={'sm'}
fontWeight={'500'}
color={'myGray.900'}
justifyContent={'space-between'}
alignItems={'center'}
borderRadius={'md'}
px={2}
py={2}
_hover={{ bg: 'myGray.50' }}
>
Body
<AccordionIcon />
</AccordionButton>
<AccordionPanel py={1} px={2} mb={5}>
<Flex
mb={2}
p={1}
flexWrap={'nowrap'}
bg={'myGray.25'}
border={'1px solid'}
borderColor={'myGray.200'}
borderRadius={'8px'}
justifyContent={'space-between'}
>
{Object.values(ContentTypes).map((type) => (
<Box
key={type}
cursor={'pointer'}
px={3}
py={1.5}
fontSize={'12px'}
fontWeight={'medium'}
color={'myGray.500'}
borderRadius={'6px'}
bg={bodyType === type ? 'white' : 'none'}
boxShadow={
bodyType === type
? '0 1px 2px 0 rgba(19, 51, 107, 0.10), 0 0 1px 0 rgba(19, 51, 107, 0.15)'
: ''
}
onClick={() => setValue('bodyType', type)}
>
{type}
</Box>
))}
</Flex>
{isContentBody && (
<PromptEditor
bg={'white'}
showOpenModal={false}
variableLabels={formatVariables}
minH={100}
maxH={200}
value={bodyContent}
placeholder={t('workflow:http_body_placeholder')}
onChange={(e) => setValue('bodyContent', e)}
/>
)}
{isFormBody && (
<ParamsTable
list={bodyFormData}
setList={(newFormData) => setValue('bodyFormData', newFormData)}
variableLabels={formatVariables}
/>
)}
</AccordionPanel>
</AccordionItem>
)}
<AccordionItem border={'none'}>
<AccordionButton
fontSize={'sm'}
fontWeight={'500'}
color={'myGray.900'}
justifyContent={'space-between'}
alignItems={'center'}
borderRadius={'md'}
px={2}
py={2}
_hover={{ bg: 'myGray.50' }}
>
Headers
<AccordionIcon />
</AccordionButton>
<AccordionPanel py={1} px={2}>
<ParamsTable
list={headers}
setList={(newHeaders) => setValue('headers', newHeaders)}
variableLabels={formatVariables}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Flex>
</ModalBody>
<ModalFooter>
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
{t('common:Close')}
</Button>
<Button onClick={handleSubmit((data) => onSubmit(data))} isLoading={isSubmitting}>
{t('common:Confirm')}
</Button>
</ModalFooter>
{isOpenCurlImport && (
<CurlImportModal
onClose={onCloseCurlImport}
onImport={(result) => {
setValue('method', result.method);
setValue('path', result.path);
if (result.params) {
setValue('params', result.params);
}
if (result.headers) {
setValue('headers', result.headers);
}
if (result.headerSecret) {
setValue('headerSecret', result.headerSecret);
}
setValue('bodyType', result.bodyType as ContentTypes);
if (result.bodyContent) {
setValue('bodyContent', result.bodyContent);
}
if (result.bodyFormData) {
setValue('bodyFormData', result.bodyFormData);
}
onCloseCurlImport();
}}
/>
)}
{editingParam && (
<CustomParamEditModal
param={editingParam}
onClose={() => setEditingParam(null)}
onConfirm={(newParam) => {
if (editingParam.key) {
setValue(
'customParams',
customParams.map((param) => (param.key === editingParam.key ? newParam : param))
);
} else {
setValue('customParams', [...customParams, newParam]);
}
}}
/>
)}
</MyModal>
);
};
const CustomParamEditModal = ({
param,
onClose,
onConfirm
}: {
param: CustomParamItemType;
onClose: () => void;
onConfirm: (param: CustomParamItemType) => void;
}) => {
const { t } = useTranslation();
const isEdit = !!param.key;
const { register, handleSubmit, watch, setValue } = useForm<CustomParamItemType>({
defaultValues: param
});
const type = watch('type');
const required = watch('required');
const isTool = watch('isTool');
return (
<MyModal
isOpen
onClose={onClose}
iconSrc={isEdit ? 'modal/edit' : 'common/addLight'}
iconColor={'primary.600'}
title={isEdit ? t('app:edit_param') : t('common:add_new_param')}
w={500}
>
<ModalBody px={9}>
<Flex mb={6} alignItems={'center'}>
<FormLabel w={'120px'}>{t('common:core.module.http.Props name')}</FormLabel>
<Input
{...register('key', { required: true })}
placeholder={t('common:core.module.http.Props name')}
bg={'myGray.50'}
/>
</Flex>
<Flex mb={6}>
<FormLabel w={'120px'}>{t('common:plugin.Description')}</FormLabel>
<Textarea
{...register('description', { required: isTool })}
rows={4}
placeholder={t('app:tool_params_description_tips')}
bg={'myGray.50'}
/>
</Flex>
<Flex mb={6} alignItems={'center'}>
<FormLabel w={'120px'}>{t('common:core.module.Data Type')}</FormLabel>
<MySelect
value={type}
list={toolValueTypeList}
onChange={(val) => setValue('type', val)}
flex={1}
/>
</Flex>
<Flex mb={6} alignItems={'center'}>
<FormLabel w={'120px'}>{t('common:Required_input')}</FormLabel>
<Switch isChecked={required} onChange={(e) => setValue('required', e.target.checked)} />
</Flex>
<Flex mb={6} alignItems={'center'}>
<FormLabel w={'120px'}>{t('workflow:field_used_as_tool_input')}</FormLabel>
<Switch isChecked={isTool} onChange={(e) => setValue('isTool', e.target.checked)} />
</Flex>
</ModalBody>
<ModalFooter>
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
{t('common:Close')}
</Button>
<Button
onClick={handleSubmit((data) => {
onConfirm(data);
onClose();
})}
>
{t('common:Confirm')}
</Button>
</ModalFooter>
</MyModal>
);
};
const CustomParamsTable = ({
list,
onEdit,
onDelete
}: {
list: CustomParamItemType[];
onEdit: (param: CustomParamItemType) => void;
onDelete: (index: number) => void;
}) => {
const { t } = useTranslation();
return (
<Box
borderRadius={'md'}
overflow={'hidden'}
borderWidth={'1px'}
borderBottom={'none'}
bg={'white'}
>
<TableContainer overflowY={'visible'} overflowX={'unset'}>
<Table size={'sm'}>
<Thead>
<Tr bg={'myGray.50'} h={8}>
<Th px={2}>{t('common:core.module.http.Props name')}</Th>
<Th px={2}>{t('common:plugin.Description')}</Th>
<Th px={2}>{t('common:support.standard.type')}</Th>
<Th px={2}>{t('workflow:tool_input')}</Th>
<Th px={2}>{t('common:Operation')}</Th>
</Tr>
</Thead>
<Tbody>
{list.map((item, index) => (
<Tr key={index} h={8}>
<Td px={2}>{item.key}</Td>
<Td px={2}>{item.description}</Td>
<Td px={2}>{item.type}</Td>
<Td px={2}>{item.isTool ? t('common:yes') : t('common:no')}</Td>
<Td px={2}>
<Flex gap={2}>
<MyIcon
name={'edit'}
cursor={'pointer'}
_hover={{ color: 'primary.600' }}
w={'14px'}
onClick={() => onEdit(item)}
/>
<MyIcon
name={'delete'}
cursor={'pointer'}
_hover={{ color: 'red.600' }}
w={'14px'}
onClick={() => onDelete(index)}
/>
</Flex>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
);
};
const ParamsTable = ({
list,
setList,
variableLabels
}: {
list: ParamItemType[];
setList: (list: ParamItemType[]) => void;
variableLabels?: EditorVariableLabelPickerType[];
}) => {
const { t } = useTranslation();
const { toast } = useToast();
const [updateTrigger, setUpdateTrigger] = useState(false);
return (
<Box borderRadius={'md'} overflow={'hidden'} borderWidth={'1px'} borderBottom={'none'}>
<TableContainer overflowY={'visible'} overflowX={'unset'}>
<Table size={'sm'}>
<Thead>
<Tr bg={'myGray.50'} h={8}>
<Th px={2}>{t('common:core.module.http.Props name')}</Th>
<Th px={2}>{t('common:core.module.http.Props value')}</Th>
</Tr>
</Thead>
<Tbody>
{[...list, { key: '', value: '' }].map((item, index) => (
<Tr key={index}>
<Td w={1 / 2} p={0} borderRight={'1px solid'} borderColor={'myGray.150'}>
<HttpInput
placeholder={'key'}
value={item.key}
onBlur={(val) => {
if (!val) return;
setUpdateTrigger((prev) => !prev);
if (list.find((item, i) => i !== index && item.key === val)) {
toast({
status: 'warning',
title: t('common:core.module.http.Key already exists')
});
return;
}
if (index === list.length) {
setList([...list, { key: val, value: '' }]);
} else {
setList(list.map((p, i) => (i === index ? { ...p, key: val } : p)));
}
}}
updateTrigger={updateTrigger}
variableLabels={variableLabels}
/>
</Td>
<Td w={1 / 2} p={0} borderColor={'myGray.150'}>
<Box display={'flex'} alignItems={'center'}>
<HttpInput
placeholder={'value'}
value={item.value}
onBlur={(val) => {
setUpdateTrigger((prev) => !prev);
setList(list.map((p, i) => (i === index ? { ...p, value: val } : p)));
}}
updateTrigger={updateTrigger}
variableLabels={variableLabels}
/>
{index !== list.length && (
<MyIcon
name={'delete'}
cursor={'pointer'}
_hover={{ color: 'red.600' }}
w={'14px'}
mx={'2'}
display={'block'}
onClick={() => setList(list.filter((_, i) => i !== index))}
/>
)}
</Box>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
);
};
export default React.memo(ManualToolModal);

View File

@ -1,5 +1,5 @@
import MyModal from '@fastgpt/web/components/common/MyModal';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'next-i18next';
import {
Box,
@ -26,7 +26,6 @@ import { useContextSelector } from 'use-context-selector';
import { AppContext } from '../context';
import HttpInput from '@fastgpt/web/components/common/Input/HttpInput';
import MyIcon from '@fastgpt/web/components/common/Icon';
import type { OpenApiJsonSchema } from '@fastgpt/global/core/app/httpTools/type';
import { pathData2ToolList } from '@fastgpt/global/core/app/httpTools/utils';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { str2OpenApiSchema } from '@fastgpt/global/core/app/jsonschema';
@ -36,7 +35,7 @@ import {
} from '@/components/common/secret/HeaderAuthConfig';
import HeaderAuthForm from '@/components/common/secret/HeaderAuthForm';
const ConfigModal = ({ onClose }: { onClose: () => void }) => {
const SchemaConfigModal = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation();
const { toast } = useToast();
@ -117,12 +116,6 @@ const ConfigModal = ({ onClose }: { onClose: () => void }) => {
});
onClose();
reloadApp();
},
onError: (err) => {
toast({
status: 'warning',
title: t('common:plugin.Invalid Schema')
});
}
}
);
@ -180,6 +173,20 @@ const ConfigModal = ({ onClose }: { onClose: () => void }) => {
/>
</Box>
<Box mt={6} mb={2} color={'myGray.900'} fontSize={'14px'} fontWeight={'medium'}>
{t('common:auth_config')}
</Box>
<Box mt={2}>
<HeaderAuthForm
headerSecretValue={storeHeader2HeaderValue(headerSecret)}
onChange={(data) => {
const storeData = headerValue2StoreHeader(data);
setValue('headerSecret', storeData);
}}
fontWeight="normal"
/>
</Box>
<Box mt={6} mb={2} color={'myGray.900'} fontSize={'14px'} fontWeight={'medium'}>
{t('app:request_headers')}
</Box>
@ -296,20 +303,6 @@ const ConfigModal = ({ onClose }: { onClose: () => void }) => {
</Table>
</TableContainer>
</Box>
<Box mt={6} mb={2} color={'myGray.900'} fontSize={'14px'} fontWeight={'medium'}>
{t('common:auth_config')}
</Box>
<Box mt={2}>
<HeaderAuthForm
headerSecretValue={storeHeader2HeaderValue(headerSecret)}
onChange={(data) => {
const storeData = headerValue2StoreHeader(data);
setValue('headerSecret', storeData);
}}
fontWeight="normal"
/>
</Box>
</ModalBody>
<ModalFooter px={9} display={'flex'} flexDirection={'column'}>
@ -330,4 +323,4 @@ const ConfigModal = ({ onClose }: { onClose: () => void }) => {
);
};
export default React.memo(ConfigModal);
export default React.memo(SchemaConfigModal);

Some files were not shown because too many files have changed in this diff Show More