Compare commits

...

20 Commits

Author SHA1 Message Date
heheer 4fbe27f2df
add plan activity config (#6139)
Some checks failed
Build FastGPT images in Personal warehouse / get-vars (push) Has been cancelled
Build FastGPT images in Personal warehouse / build-fastgpt-images (map[arch:amd64 runs-on:ubuntu-24.04]) (push) Has been cancelled
Build FastGPT images in Personal warehouse / build-fastgpt-images (map[arch:arm64 runs-on:ubuntu-24.04-arm]) (push) Has been cancelled
Build FastGPT images in Personal warehouse / release-fastgpt-images (push) Has been cancelled
* activity points

* modal

* ui

* fix

* pref: zod schema

* perf: ad api with zod

* perf: plan year switch

* perf: plan

* i18n

* fix: hook

* fix: activity checker

* fix: i18n

* fix clear token

* fix

* back

* can close modal in pay

* ad token

* rename

* fix

* total points

* eng i18n

---------

Co-authored-by: archer <545436317@qq.com>
2025-12-24 18:22:25 +08:00
Archer e53242e8bc
4.14.5 dev (#6146)
Some checks failed
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
Document deploy / sync-images (push) Has been cancelled
Document deploy / generate-timestamp (push) Has been cancelled
Document deploy / build-images (map[domain:https://fastgpt.cn suffix:cn]) (push) Has been cancelled
Document deploy / build-images (map[domain:https://fastgpt.io suffix:io]) (push) Has been cancelled
Document deploy / update-images (map[deployment:fastgpt-docs domain:https://fastgpt.cn kube_config:KUBE_CONFIG_CN suffix:cn]) (push) Has been cancelled
Document deploy / update-images (map[deployment:fastgpt-docs domain:https://fastgpt.io kube_config:KUBE_CONFIG_IO suffix:io]) (push) Has been cancelled
* stop design doc

* remove invalid doc

* action
2025-12-24 17:09:26 +08:00
heheer f175a1a30c
optimize app update time (#6127)
* feat: add chat visibility controls and improve quote reader permissions (#6102)

* feat: add chat visibility controls and improve quote reader permissions

* fix test

* zod

* fix

* test & openapi

* frontend filter

* update name

* fix

* fix

* rename variables

* fix

* test

* fix build

* fix

* fix

---------

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

* app update time

* recent app

* fix

* type

* fix

* context

* perf: update app usingtime code

* fix: ts

* update parent

* doc

* perf: code per

* unauth refresh

---------

Co-authored-by: archer <545436317@qq.com>
2025-12-24 14:28:42 +08:00
Archer ab743b9358
fix: ticktime (#6134)
Some checks failed
Document deploy / sync-images (push) Has been cancelled
Build FastGPT images in Personal warehouse / get-vars (push) Has been cancelled
Document deploy / generate-timestamp (push) Has been cancelled
Document deploy / build-images (map[domain:https://fastgpt.cn suffix:cn]) (push) Has been cancelled
Document deploy / build-images (map[domain:https://fastgpt.io suffix:io]) (push) Has been cancelled
Document deploy / update-images (map[deployment:fastgpt-docs domain:https://fastgpt.cn kube_config:KUBE_CONFIG_CN suffix:cn]) (push) Has been cancelled
Document deploy / update-images (map[deployment:fastgpt-docs domain:https://fastgpt.io kube_config:KUBE_CONFIG_IO suffix:io]) (push) Has been cancelled
Build FastGPT images in Personal warehouse / build-fastgpt-images (map[arch:amd64 runs-on:ubuntu-24.04]) (push) Has been cancelled
Build FastGPT images in Personal warehouse / build-fastgpt-images (map[arch:arm64 runs-on:ubuntu-24.04-arm]) (push) Has been cancelled
Build FastGPT images in Personal warehouse / release-fastgpt-images (push) Has been cancelled
* stop design doc

* remove invalid doc

* del s3 tip

* fix: ticktime

* fix: ticktime

* fix: ticktime
2025-12-22 00:02:23 +08:00
Archer 6fb93ef8a5
fix: s3 del worker while (#6133)
* stop design doc

* remove invalid doc

* fix: s3 del worker while

* fix: s3 del worker while

* perf: regx
2025-12-21 23:28:19 +08:00
Archer b0a48603f8
perf: remove dataset code (#6132)
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
* stop design doc

* perf: init worker

* perf: remove dataset cide

* remove invalid doc
2025-12-21 20:56:50 +08:00
Archer 2fea73bb68
perf: index (#6131)
* perf: index

* stop design doc

* perf: stop workflow;perf: mongo connection

* fix: ts

* mq export
2025-12-21 19:15:10 +08:00
heheer 4f95f6867e
app delete queue (#6122)
Some checks failed
Document deploy / sync-images (push) Has been cancelled
Build FastGPT images in Personal warehouse / get-vars (push) Has been cancelled
Document deploy / generate-timestamp (push) Has been cancelled
Document deploy / build-images (map[domain:https://fastgpt.cn suffix:cn]) (push) Has been cancelled
Document deploy / build-images (map[domain:https://fastgpt.io suffix:io]) (push) Has been cancelled
Document deploy / update-images (map[deployment:fastgpt-docs domain:https://fastgpt.cn kube_config:KUBE_CONFIG_CN suffix:cn]) (push) Has been cancelled
Document deploy / update-images (map[deployment:fastgpt-docs domain:https://fastgpt.io kube_config:KUBE_CONFIG_IO suffix:io]) (push) Has been cancelled
Build FastGPT images in Personal warehouse / build-fastgpt-images (map[arch:amd64 runs-on:ubuntu-24.04]) (push) Has been cancelled
Build FastGPT images in Personal warehouse / build-fastgpt-images (map[arch:arm64 runs-on:ubuntu-24.04-arm]) (push) Has been cancelled
Build FastGPT images in Personal warehouse / release-fastgpt-images (push) Has been cancelled
* app delete queue

* test

* perf: del app queue

* perf: log

* perf: query

* perf: retry del s3

* fix: ts

* perf: add job

* redis retry

* perf: mq check

* update log

* perf: mq concurrency

* perf: error check

* perf: mq

* perf: init model

---------

Co-authored-by: archer <545436317@qq.com>
2025-12-20 13:11:02 +08:00
heheer 36821600a4
limit custom param name width in http tools (#6125)
Some checks are pending
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
* limit custom param name width in http tools

* scroll

* cron log

---------

Co-authored-by: archer <545436317@qq.com>
2025-12-19 14:13:34 +08:00
Archer 5631ec781e
rename log (#6124)
* rename log

* mq del log
2025-12-19 12:13:44 +08:00
Archer d398c9cd39
fix: openapi (#6121)
Some checks failed
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
Document deploy / sync-images (push) Has been cancelled
Document deploy / generate-timestamp (push) Has been cancelled
Document deploy / build-images (map[domain:https://fastgpt.cn suffix:cn]) (push) Has been cancelled
Document deploy / build-images (map[domain:https://fastgpt.io suffix:io]) (push) Has been cancelled
Document deploy / update-images (map[deployment:fastgpt-docs domain:https://fastgpt.cn kube_config:KUBE_CONFIG_CN suffix:cn]) (push) Has been cancelled
Document deploy / update-images (map[deployment:fastgpt-docs domain:https://fastgpt.io kube_config:KUBE_CONFIG_IO suffix:io]) (push) Has been cancelled
* fix: openapi

* fix: openapi

* fix: default maxResponse load

* fix: default maxResponse load

* doc
2025-12-19 00:08:30 +08:00
heheer 5231f4281f
image compatibility for various content-types (#6119)
* image compatibility for various content-types

* perf: image type detect

* perf: gethistory

* update test

* update rerank log

* perf: login

* fix: query extension use

---------

Co-authored-by: archer <545436317@qq.com>
2025-12-18 23:25:48 +08:00
Archer ea7c37745a
add savechat test (#6118)
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
2025-12-18 14:34:44 +08:00
Archer c6fe6f18da
fix: create time (#6117) 2025-12-18 13:58:12 +08:00
Archer 0d88761378
perf: redis del (#6115)
* fix: log

* fix: redirect api
2025-12-18 13:49:45 +08:00
heheer 527237d019
limit custom param description width in http tools (#6116) 2025-12-18 13:37:36 +08:00
Archer 44c0e9e83f
perf: redis del;perf: cron app run (#6113)
* cron app run

* perf: redis del
2025-12-18 11:09:13 +08:00
ROKY 7f715a8752
add mineru_saas_api for fastgpt (#5923)
* add pdf-mineru

添加了基于MinerU的PDF转Markdown接口服务,调用方式与pdf-marker一致,开箱即用。

* Rename Readme.md to README.md

* Rename pdf_parser_mineru.py to main.py

* mineru_saas_api for fastgpt

已有成熟本地部署方案,现提供使用mineru官方saas服务api的调用方法
2025-12-18 11:08:24 +08:00
Archer cffe395e9a
perf: Get redis kes function (#6112)
* perf: replace redis KEYS with SCAN (#6101)

* perf: replace redis KEYS with SCAN

* test: add redis scan mock to fix unit tests

* Fix formatting in redis.ts mock functions

* fix comment word

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

---------

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

* perf: get redis keys function

* replace prefix code

* add pipeline delete keys

---------

Co-authored-by: lgphone <inboxcvt@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-18 10:50:37 +08:00
heheer 09b9fa517b
chat log soft delete (#6110)
* chat log soft delete

* perf: history api

* add history test

* Update packages/web/i18n/en/app.json

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

* zod parse error

* fix: ts

---------

Co-authored-by: archer <545436317@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-18 10:17:10 +08:00
273 changed files with 10531 additions and 2555 deletions

View File

@ -0,0 +1,672 @@
---
name: workflow-stop-design
description: 工作流暂停逻辑设计方案
---
## 1. Redis 状态管理方案
### 1.1 状态键设计
**Redis Key 结构:**
```typescript
// Key 格式: agent_runtime_stopping:{appId}:{chatId}
const WORKFLOW_STATUS_PREFIX = 'agent_runtime_stopping';
type WorkflowStatusKey = `${typeof WORKFLOW_STATUS_PREFIX}:${string}:${string}`;
// 示例: agent_runtime_stopping:app_123456:chat_789012
```
**状态值设计:**
- **存在键 (值为 1)**: 工作流应该停止
- **不存在键**: 工作流正常运行
- **设计简化**: 不使用状态枚举,仅通过键的存在与否判断
**参数类型定义:**
```typescript
type WorkflowStatusParams = {
appId: string;
chatId: string;
};
```
### 1.2 状态生命周期管理
**状态转换流程:**
```
正常运行(无键) → 停止中(键存在) → 完成(删除键)
```
**TTL 设置:**
- **停止标志 TTL**: 60 秒
- 原因: 避免因意外情况导致的键泄漏
- 正常情况下会在工作流完成时主动删除
- **工作流完成后**: 直接删除 Redis 键
- 原因: 不需要保留终态,减少 Redis 内存占用
### 1.3 核心函数说明
**1. setAgentRuntimeStop**
- **功能**: 设置停止标志
- **参数**: `{ appId, chatId }`
- **实现**: 使用 `SETEX` 命令,设置键值为 1,TTL 60 秒
**2. shouldWorkflowStop**
- **功能**: 检查工作流是否应该停止
- **参数**: `{ appId, chatId }`
- **返回**: `Promise<boolean>` - true=应该停止, false=继续运行
- **实现**: GET 命令获取键值,存在则返回 true
**3. delAgentRuntimeStopSign**
- **功能**: 删除停止标志
- **参数**: `{ appId, chatId }`
- **实现**: DEL 命令删除键
**4. waitForWorkflowComplete**
- **功能**: 等待工作流完成(停止标志被删除)
- **参数**: `{ appId, chatId, timeout?, pollInterval? }`
- **实现**: 轮询检查停止标志是否被删除,超时返回
### 1.4 边界情况处理
**1. Redis 操作失败**
- **错误处理**: 所有 Redis 操作都包含 `.catch()` 错误处理
- **降级策略**:
- `shouldWorkflowStop`: 出错时返回 `false` (认为不需要停止,继续运行)
- `delAgentRuntimeStopSign`: 出错时记录错误日志,但不影响主流程
- **设计原因**: Redis 异常不应阻塞工作流运行,降级到继续执行策略
**2. TTL 自动清理**
- **TTL 设置**: 60 秒
- **清理时机**: Redis 自动清理过期键
- **设计原因**:
- 避免因异常情况导致的 Redis 键泄漏
- 自动清理减少手动维护成本
- 60 秒足够大多数工作流完成停止操作
**3. stop 接口等待超时**
- **超时时间**: 5 秒
- **超时策略**: `waitForWorkflowComplete` 在 5 秒内轮询检查停止标志是否被删除
- **超时处理**: 5 秒后直接返回,不影响工作流继续执行
- **设计原因**:
- 避免前端长时间等待
- 5 秒足够大多数节点完成当前操作
- 用户体验优先,超时后前端可选择重试或放弃
**4. 并发停止请求**
- **处理方式**: 多次调用 `setAgentRuntimeStop` 是安全的,Redis SETEX 是幂等操作
- **设计原因**: 避免用户多次点击停止按钮导致的问题
---
## 2. Redis 工具函数实现
**位置**: `packages/service/core/workflow/dispatch/workflowStatus.ts`
```typescript
import { addLog } from '../../../common/system/log';
import { getGlobalRedisConnection } from '../../../common/redis/index';
import { delay } from '@fastgpt/global/common/system/utils';
const WORKFLOW_STATUS_PREFIX = 'agent_runtime_stopping';
const TTL = 60; // 60秒
export const StopStatus = 'STOPPING';
export type WorkflowStatusParams = {
appId: string;
chatId: string;
};
// 获取工作流状态键
export const getRuntimeStatusKey = (params: WorkflowStatusParams): string => {
return `${WORKFLOW_STATUS_PREFIX}:${params.appId}:${params.chatId}`;
};
// 设置停止标志
export const setAgentRuntimeStop = async (params: WorkflowStatusParams): Promise<void> => {
const redis = getGlobalRedisConnection();
const key = getRuntimeStatusKey(params);
await redis.setex(key, TTL, 1);
};
// 删除停止标志
export const delAgentRuntimeStopSign = async (params: WorkflowStatusParams): Promise<void> => {
const redis = getGlobalRedisConnection();
const key = getRuntimeStatusKey(params);
await redis.del(key).catch((err) => {
addLog.error(`[Agent Runtime Stop] Delete stop sign error`, err);
});
};
// 检查工作流是否应该停止
export const shouldWorkflowStop = (params: WorkflowStatusParams): Promise<boolean> => {
const redis = getGlobalRedisConnection();
const key = getRuntimeStatusKey(params);
return redis
.get(key)
.then((res) => !!res)
.catch(() => false);
};
/**
* 等待工作流完成(停止标志被删除)
* @param params 工作流参数
* @param timeout 超时时间(毫秒),默认5秒
* @param pollInterval 轮询间隔(毫秒),默认50毫秒
*/
export const waitForWorkflowComplete = async ({
appId,
chatId,
timeout = 5000,
pollInterval = 50
}: {
appId: string;
chatId: string;
timeout?: number;
pollInterval?: number;
}) => {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const sign = await shouldWorkflowStop({ appId, chatId });
// 如果停止标志已被删除,说明工作流已完成
if (!sign) {
return;
}
// 等待下一次轮询
await delay(pollInterval);
}
// 超时后直接返回
return;
};
```
**测试用例位置**: `test/cases/service/core/app/workflow/workflowStatus.test.ts`
```typescript
import { describe, test, expect, beforeEach } from 'vitest';
import {
setAgentRuntimeStop,
delAgentRuntimeStopSign,
shouldWorkflowStop,
waitForWorkflowComplete
} from '@fastgpt/service/core/workflow/dispatch/workflowStatus';
describe('Workflow Status Redis Functions', () => {
const testAppId = 'test_app_123';
const testChatId = 'test_chat_456';
beforeEach(async () => {
// 清理测试数据
await delAgentRuntimeStopSign({ appId: testAppId, chatId: testChatId });
});
test('should set stopping sign', async () => {
await setAgentRuntimeStop({ appId: testAppId, chatId: testChatId });
const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId });
expect(shouldStop).toBe(true);
});
test('should return false for non-existent status', async () => {
const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId });
expect(shouldStop).toBe(false);
});
test('should return false after deleting stop sign', async () => {
await setAgentRuntimeStop({ appId: testAppId, chatId: testChatId });
await delAgentRuntimeStopSign({ appId: testAppId, chatId: testChatId });
const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId });
expect(shouldStop).toBe(false);
});
test('should wait for workflow completion', async () => {
// 设置初始停止标志
await setAgentRuntimeStop({ appId: testAppId, chatId: testChatId });
// 模拟异步完成(删除停止标志)
setTimeout(async () => {
await delAgentRuntimeStopSign({ appId: testAppId, chatId: testChatId });
}, 500);
// 等待完成
await waitForWorkflowComplete({
appId: testAppId,
chatId: testChatId,
timeout: 2000
});
// 验证停止标志已被删除
const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId });
expect(shouldStop).toBe(false);
});
test('should timeout when waiting too long', async () => {
await setAgentRuntimeStop({ appId: testAppId, chatId: testChatId });
// 等待超时(不删除标志)
await waitForWorkflowComplete({
appId: testAppId,
chatId: testChatId,
timeout: 100
});
// 验证停止标志仍然存在
const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId });
expect(shouldStop).toBe(true);
});
test('should handle concurrent stop sign operations', async () => {
// 并发设置停止标志
await Promise.all([
setAgentRuntimeStop({ appId: testAppId, chatId: testChatId }),
setAgentRuntimeStop({ appId: testAppId, chatId: testChatId })
]);
// 停止标志应该存在
const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId });
expect(shouldStop).toBe(true);
});
});
```
## 3. 工作流停止检测机制改造
### 3.1 修改位置
**文件**: `packages/service/core/workflow/dispatch/index.ts`
### 3.2 工作流启动时的停止检测机制
**改造点 1: 停止检测逻辑 (行 196-216)**
使用内存变量 + 定时轮询 Redis 的方式:
```typescript
import { delAgentRuntimeStopSign, shouldWorkflowStop } from './workflowStatus';
// 初始化停止检测
let stopping = false;
const checkIsStopping = (): boolean => {
if (apiVersion === 'v2') {
return stopping;
}
if (apiVersion === 'v1') {
if (!res) return false;
return res.closed || !!res.errored;
}
return false;
};
// v2 版本: 启动定时器定期检查 Redis
const checkStoppingTimer =
apiVersion === 'v2'
? setInterval(async () => {
stopping = await shouldWorkflowStop({
appId: runningAppInfo.id,
chatId
});
}, 100)
: undefined;
```
**设计要点**:
- v2 版本使用内存变量 `stopping` + 100ms 定时器轮询 Redis
- v1 版本仍使用原有的 `res.closed/res.errored` 检测
- 轮询频率 100ms,平衡性能和响应速度
**改造点 2: 工作流完成后清理 (行 232-249)**
```typescript
return runWorkflow({
...data,
checkIsStopping, // 传递检测函数
query,
histories,
// ... 其他参数
}).finally(async () => {
// 清理定时器
if (streamCheckTimer) {
clearInterval(streamCheckTimer);
}
if (checkStoppingTimer) {
clearInterval(checkStoppingTimer);
}
// Close mcpClient connections
Object.values(mcpClientMemory).forEach((client) => {
client.closeConnection();
});
// 工作流完成后删除 Redis 记录
await delAgentRuntimeStopSign({
appId: runningAppInfo.id,
chatId
});
});
```
### 3.3 节点执行前的停止检测
**位置**: `packages/service/core/workflow/dispatch/index.ts:861-868`
`checkNodeCanRun` 方法中,每个节点执行前检查:
```typescript
private async checkNodeCanRun(
node: RuntimeNodeItemType,
skippedNodeIdList = new Set<string>()
) {
// ... 其他检查逻辑 ...
// Check queue status
if (data.maxRunTimes <= 0) {
addLog.error('Max run times is 0', {
appId: data.runningAppInfo.id
});
return;
}
// 停止检测
if (checkIsStopping()) {
addLog.warn('Workflow stopped', {
appId: data.runningAppInfo.id,
nodeId: node.nodeId,
nodeName: node.name
});
return;
}
// ... 执行节点逻辑 ...
}
```
**说明**:
- 直接调用 `checkIsStopping()` 同步方法
- 内部会检查内存变量 `stopping`
- 定时器每 100ms 更新一次该变量
- 检测到停止时记录日志并直接返回,不执行节点
## 4. v2/chat/stop 接口设计
### 4.1 接口规范
**接口路径**: `/api/v2/chat/stop`
**Schema 位置**: `packages/global/openapi/core/chat/api.ts`
**接口文档位置**: `packages/global/openapi/core/chat/index.ts`
**请求方法**: POST
**请求参数**:
```typescript
// packages/global/openapi/core/chat/api.ts
export const StopV2ChatSchema = z
.object({
appId: ObjectIdSchema.describe('应用ID'),
chatId: z.string().min(1).describe('对话ID'),
outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据')
});
export type StopV2ChatParams = z.infer<typeof StopV2ChatSchema>;
```
**响应格式**:
```typescript
export const StopV2ChatResponseSchema = z
.object({
success: z.boolean().describe('是否成功停止')
});
export type StopV2ChatResponse = z.infer<typeof StopV2ChatResponseSchema>;
```
### 4.2 接口实现
**文件位置**: `projects/app/src/pages/api/v2/chat/stop.ts`
```typescript
import type { NextApiRequest, NextApiResponse } from 'next';
import { NextAPI } from '@/service/middleware/entry';
import { authChatCrud } from '@/service/support/permission/auth/chat';
import {
setAgentRuntimeStop,
waitForWorkflowComplete
} from '@fastgpt/service/core/workflow/dispatch/workflowStatus';
import { StopV2ChatSchema, type StopV2ChatResponse } from '@fastgpt/global/openapi/core/chat/controler/api';
async function handler(req: NextApiRequest, res: NextApiResponse): Promise<StopV2ChatResponse> {
const { appId, chatId, outLinkAuthData } = StopV2ChatSchema.parse(req.body);
// 鉴权 (复用聊天 CRUD 鉴权)
await authChatCrud({
req,
authToken: true,
authApiKey: true,
appId,
chatId,
...outLinkAuthData
});
// 设置停止标志
await setAgentRuntimeStop({
appId,
chatId
});
// 等待工作流完成 (最多等待 5 秒)
await waitForWorkflowComplete({ appId, chatId, timeout: 5000 });
return {
success: true
};
}
export default NextAPI(handler);
```
**接口文档** (`packages/global/openapi/core/chat/index.ts`):
```typescript
export const ChatPath: OpenAPIPath = {
// ... 其他路径
'/v2/chat/stop': {
post: {
summary: '停止 Agent 运行',
description: `优雅停止正在运行的 Agent, 会尝试等待当前节点结束后返回,最长 5s超过 5s 仍未结束,则会返回成功。
LLM 节点,流输出时会同时被终止,但 HTTP 请求节点这种可能长时间运行的,不会被终止。`,
tags: [TagsMap.chatPage],
requestBody: {
content: {
'application/json': {
schema: StopV2ChatSchema
}
}
},
responses: {
200: {
description: '成功停止工作流',
content: {
'application/json': {
schema: StopV2ChatResponseSchema
}
}
}
}
}
}
};
```
**说明**:
- 接口使用 `authChatCrud` 进行鉴权,支持 Token 和 API Key
- 支持分享链接和团队空间的鉴权数据
- 设置停止标志后等待最多 5 秒
- 无论是否超时,都返回 `success: true`
## 5. 前端改造
由于当前代码已经能够正常工作,且 v2 版本的后端已经实现了基于 Redis 的停止机制,前端可以保持现有的简单实现:
**保持现有实现的原因**:
1. 后端已经通过定时器轮询 Redis 实现了停止检测
2. 前端调用 `abort()` 后,后端会在下个检测周期(100ms内)发现停止标志
3. 简化前端逻辑,避免增加复杂性
4. 用户体验上,立即中断连接响应更快
**可选的增强方案**:
如果需要在前端显示更详细的停止状态,可以添加 API 客户端函数:
**文件位置**: `projects/app/src/web/core/chat/api.ts`
```typescript
import { POST } from '@/web/common/api/request';
import type { StopV2ChatParams, StopV2ChatResponse } from '@fastgpt/global/openapi/core/chat/controler/api';
/**
* 停止 v2 版本工作流运行
*/
export const stopV2Chat = (data: StopV2ChatParams) =>
POST<StopV2ChatResponse>('/api/v2/chat/stop', data);
```
**增强的 abortRequest 函数**:
```typescript
/* Abort chat completions, questionGuide */
const abortRequest = useMemoizedFn(async (reason: string = 'stop') => {
// 先调用 abort 中断连接
chatController.current?.abort(new Error(reason));
questionGuideController.current?.abort(new Error(reason));
pluginController.current?.abort(new Error(reason));
// v2 版本: 可选地通知后端优雅停止
if (chatBoxData?.app?.version === 'v2' && appId && chatId) {
try {
await stopV2Chat({
appId,
chatId,
outLinkAuthData
});
} catch (error) {
// 静默失败,不影响用户体验
console.warn('Failed to notify backend to stop workflow', error);
}
}
});
```
**建议**:
- **推荐**: 保持当前简单实现,后端已经足够健壮
- **可选**: 如果需要更精确的停止状态追踪,可以实现上述增强方案
## 6. 完整调用流程
### 6.1 正常停止流程
```
用户点击停止按钮
前端: abortRequest()
前端: chatController.abort() [立即中断 HTTP 连接]
[可选] 前端: POST /api/v2/chat/stop
后端: setAgentRuntimeStop(appId, chatId) [设置停止标志]
后端: 定时器检测到 Redis 停止标志,更新内存变量 stopping = true
后端: 下个节点执行前 checkIsStopping() 返回 true
后端: 停止处理新节点,记录日志
后端: 工作流 finally 块删除 Redis 停止标志
[可选] 后端: waitForWorkflowComplete() 检测到停止标志被删除
[可选] 前端: 显示停止成功提示
```
### 6.2 超时流程
```
[可选] 前端: POST /api/v2/chat/stop
后端: setAgentRuntimeStop(appId, chatId)
后端: waitForWorkflowComplete(timeout=5s)
后端: 5秒后停止标志仍存在
后端: 返回成功响应 (不区分超时)
[可选] 前端: 显示成功提示
后端: 工作流继续运行,最终完成后删除停止标志
```
### 6.3 工作流自然完成流程
```
工作流运行中
所有节点执行完成
dispatchWorkFlow.finally()
删除 Redis 停止标志
清理定时器
60秒 TTL 确保即使删除失败也会自动清理
```
### 6.4 时序说明
**关键时间点**:
- **100ms**: 后端定时器检查 Redis 停止标志的频率
- **5s**: stop 接口等待工作流完成的超时时间
- **60s**: Redis 键的 TTL,自动清理时间
**响应时间**:
- 用户点击停止 → HTTP 连接中断: **立即** (前端 abort)
- 停止标志写入 Redis: **< 50ms** (Redis SETEX 操作)
- 后端检测到停止: **< 100ms** (定时器轮询周期)
- 当前节点停止执行: **取决于节点类型**
- LLM 流式输出: **立即**中断流
- HTTP 请求节点: **等待请求完成**
- 其他节点: **等待当前操作完成**
## 7. 测试策略
### 7.1 单元测试
**Redis 工具函数测试**:
- `setAgentRuntimeStop` / `shouldWorkflowStop` 基本功能
- `delAgentRuntimeStopSign` 删除功能
- `waitForWorkflowComplete` 等待机制和超时
- 并发操作安全性
**文件位置**: `test/cases/service/core/app/workflow/workflowStatus.test.ts`
**测试用例**:
```typescript
describe('Workflow Status Redis Functions', () => {
test('should set stopping sign')
test('should return false for non-existent status')
test('should detect stopping status')
test('should return false after deleting stop sign')
test('should wait for workflow completion')
test('should timeout when waiting too long')
test('should delete workflow stop sign')
test('should handle concurrent stop sign operations')
});
```

View File

@ -73,7 +73,6 @@ jobs:
--label "org.opencontainers.image.description=${{ steps.config.outputs.DESCRIPTION }}" \
--push \
--cache-from=type=local,src=/tmp/.buildx-cache \
--cache-to=type=local,dest=/tmp/.buildx-cache \
-t ${{ steps.config.outputs.DOCKER_REPO_TAGGED }} \
.

View File

@ -616,7 +616,7 @@ event取值
<Tab value="请求示例">
```bash
curl --location --request POST 'http://localhost:3000/api/core/chat/getHistories' \
curl --location --request POST 'http://localhost:3000/api/core/chat/history/getHistories' \
--header 'Authorization: Bearer [apikey]' \
--header 'Content-Type: application/json' \
--data-raw '{
@ -679,7 +679,7 @@ curl --location --request POST 'http://localhost:3000/api/core/chat/getHistories
<Tab value="请求示例">
```bash
curl --location --request POST 'http://localhost:3000/api/core/chat/updateHistory' \
curl --location --request POST 'http://localhost:3000/api/core/chat/history/updateHistory' \
--header 'Authorization: Bearer [apikey]' \
--header 'Content-Type: application/json' \
--data-raw '{
@ -721,7 +721,7 @@ curl --location --request POST 'http://localhost:3000/api/core/chat/updateHistor
<Tab value="请求示例">
```bash
curl --location --request POST 'http://localhost:3000/api/core/chat/updateHistory' \
curl --location --request POST 'http://localhost:3000/api/core/chat/history/updateHistory' \
--header 'Authorization: Bearer [apikey]' \
--header 'Content-Type: application/json' \
--data-raw '{
@ -763,7 +763,7 @@ curl --location --request POST 'http://localhost:3000/api/core/chat/updateHistor
<Tab value="请求示例">
```bash
curl --location --request DELETE 'http://localhost:3000/api/core/chat/delHistory?chatId=[chatId]&appId=[appId]' \
curl --location --request DELETE 'http://localhost:3000/api/core/chat/history/delHistory?chatId=[chatId]&appId=[appId]' \
--header 'Authorization: Bearer [apikey]'
```
@ -800,7 +800,7 @@ curl --location --request DELETE 'http://localhost:3000/api/core/chat/delHistory
<Tab value="请求示例">
```bash
curl --location --request DELETE 'http://localhost:3000/api/core/chat/clearHistories?appId=[appId]' \
curl --location --request DELETE 'http://localhost:3000/api/core/chat/history/clearHistories?appId=[appId]' \
--header 'Authorization: Bearer [apikey]'
```

View File

@ -6,14 +6,22 @@ description: 'FastGPT V4.14.5 更新说明'
## 🚀 新增内容
1. 对话记录使用侧改成软删除,增加从日志管理里删除对话记录。
2. 更新Agent/工具时,会更新其上层所有目录的更新时间,以便其会排在列表前面。
3. 门户页支持配置单个应用运行可见度配。
## ⚙️ 优化
1. 优化获取 redis 所有 key 的逻辑,避免大量获取时导致阻塞。
2. MongoDB, Redis 和 MQ 的重连逻辑优化。
## 🐛 修复
1. MCP 工具创建时,使用自定义鉴权头会报错。
2. 获取对话日志列表时,如果用户头像为空,会抛错。
3. chatAgent 未开启问题优化时,前端 UI 显示开启。
4. 加载默认模型时maxTokens 字段未赋值,导致模型最大响应值配置为空。
5. S3 文件清理队列因网络稳定问题出现阻塞,导致删除任务不再执行。
## 插件

View File

@ -31,7 +31,7 @@
"document/content/docs/introduction/development/modelConfig/ppio.mdx": "2025-09-29T11:52:39+08:00",
"document/content/docs/introduction/development/modelConfig/siliconCloud.mdx": "2025-08-05T23:20:39+08:00",
"document/content/docs/introduction/development/openapi/app.mdx": "2025-09-26T13:18:51+08:00",
"document/content/docs/introduction/development/openapi/chat.mdx": "2025-11-14T13:21:17+08:00",
"document/content/docs/introduction/development/openapi/chat.mdx": "2025-12-18T13:49:45+08:00",
"document/content/docs/introduction/development/openapi/dataset.mdx": "2025-09-29T11:34:11+08:00",
"document/content/docs/introduction/development/openapi/intro.mdx": "2025-09-29T11:34:11+08:00",
"document/content/docs/introduction/development/openapi/share.mdx": "2025-12-09T23:33:32+08:00",
@ -102,7 +102,7 @@
"document/content/docs/protocol/terms.en.mdx": "2025-12-15T23:36:54+08:00",
"document/content/docs/protocol/terms.mdx": "2025-12-15T23:36:54+08:00",
"document/content/docs/toc.en.mdx": "2025-08-04T13:42:36+08:00",
"document/content/docs/toc.mdx": "2025-12-09T23:33:32+08:00",
"document/content/docs/toc.mdx": "2025-12-17T17:44:38+08:00",
"document/content/docs/upgrading/4-10/4100.mdx": "2025-08-02T19:38:37+08:00",
"document/content/docs/upgrading/4-10/4101.mdx": "2025-09-08T20:07:20+08:00",
"document/content/docs/upgrading/4-11/4110.mdx": "2025-08-05T23:20:39+08:00",
@ -120,6 +120,7 @@
"document/content/docs/upgrading/4-14/4142.mdx": "2025-11-18T19:27:14+08:00",
"document/content/docs/upgrading/4-14/4143.mdx": "2025-11-26T20:52:05+08:00",
"document/content/docs/upgrading/4-14/4144.mdx": "2025-12-16T14:56:04+08:00",
"document/content/docs/upgrading/4-14/4145.mdx": "2025-12-21T23:28:19+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

@ -1,4 +1,4 @@
import type { OutLinkChatAuthProps } from '../../support/permission/chat.d';
import type { OutLinkChatAuthProps } from '../../support/permission/chat';
export type preUploadImgProps = OutLinkChatAuthProps & {
// expiredTime?: Date;

View File

@ -11,8 +11,12 @@ export enum TrackEnum {
clickOperationalAd = 'clickOperationalAd',
closeOperationalAd = 'closeOperationalAd',
teamChatQPM = 'teamChatQPM',
// Admin cron job tracks
subscriptionDeleted = 'subscriptionDeleted',
freeAccountCleanup = 'freeAccountCleanup',
auditLogCleanup = 'auditLogCleanup',
chatHistoryCleanup = 'chatHistoryCleanup',
// web tracks
clientError = 'clientError'

View File

@ -3,7 +3,8 @@ export enum SystemConfigsTypeEnum {
fastgptPro = 'fastgptPro',
systemMsgModal = 'systemMsgModal',
license = 'license',
operationalAd = 'operationalAd'
operationalAd = 'operationalAd',
activityAd = 'activityAd'
}
export const SystemConfigsTypeMap = {
@ -21,5 +22,8 @@ export const SystemConfigsTypeMap = {
},
[SystemConfigsTypeEnum.operationalAd]: {
label: 'operationalAd'
},
[SystemConfigsTypeEnum.activityAd]: {
label: 'activityAd'
}
};

View File

@ -37,7 +37,7 @@ export const AppLogKeysEnumMap = {
};
export const DefaultAppLogKeys = [
{ key: AppLogKeysEnum.SOURCE, enable: true },
{ key: AppLogKeysEnum.SOURCE, enable: false },
{ key: AppLogKeysEnum.USER, enable: true },
{ key: AppLogKeysEnum.TITLE, enable: true },
{ key: AppLogKeysEnum.SESSION_ID, enable: false },

View File

@ -59,6 +59,9 @@ export type AppSchema = {
inited?: boolean;
/** @deprecated */
teamTags: string[];
// 软删除字段
deleteTime?: Date | null;
};
export type AppListItemType = {

View File

@ -1,68 +0,0 @@
import type { OutLinkChatAuthType } from '../../support/permission/chat/type';
import { OutLinkChatAuthSchema } from '../../support/permission/chat/type';
import { ObjectIdSchema } from '../../common/type/mongo';
import z from 'zod';
export const PresignChatFileGetUrlSchema = z
.object({
key: z.string().min(1),
appId: ObjectIdSchema,
outLinkAuthData: OutLinkChatAuthSchema.optional()
})
.meta({
description: '获取对话文件预览链接',
example: {
key: '1234567890',
appId: '1234567890',
outLinkAuthData: {
shareId: '1234567890',
outLinkUid: '1234567890'
}
}
});
export type PresignChatFileGetUrlParams = z.infer<typeof PresignChatFileGetUrlSchema> & {
outLinkAuthData?: OutLinkChatAuthType;
};
export const PresignChatFilePostUrlSchema = z
.object({
filename: z.string().min(1),
appId: ObjectIdSchema,
chatId: ObjectIdSchema,
outLinkAuthData: OutLinkChatAuthSchema.optional()
})
.meta({
description: '获取上传对话文件预签名 URL',
example: {
filename: '1234567890',
appId: '1234567890',
chatId: '1234567890',
outLinkAuthData: {
shareId: '1234567890',
outLinkUid: '1234567890'
}
}
});
export type PresignChatFilePostUrlParams = z.infer<typeof PresignChatFilePostUrlSchema> & {
outLinkAuthData?: OutLinkChatAuthType;
};
export const UpdateChatFeedbackSchema = z
.object({
appId: z.string().min(1),
chatId: z.string().min(1),
dataId: z.string().min(1),
userBadFeedback: z.string().optional(),
userGoodFeedback: z.string().optional()
})
.meta({
description: '更新对话反馈',
example: {
appId: '1234567890',
chatId: '1234567890',
dataId: '1234567890',
userBadFeedback: '1234567890',
userGoodFeedback: '1234567890'
}
});
export type UpdateChatFeedbackProps = z.infer<typeof UpdateChatFeedbackSchema>;

View File

@ -51,6 +51,8 @@ export type ChatSchemaType = {
hasBadFeedback?: boolean;
hasUnreadGoodFeedback?: boolean;
hasUnreadBadFeedback?: boolean;
deleteTime?: Date | null;
};
export type ChatWithAppSchema = Omit<ChatSchemaType, 'appId'> & {
@ -197,7 +199,7 @@ export type HistoryItemType = {
};
export type ChatHistoryItemType = HistoryItemType & {
appId: string;
top: boolean;
top?: boolean;
};
/* ------- response data ------------ */

View File

@ -40,6 +40,7 @@ export type ExternalProviderType = {
/* workflow props */
export type ChatDispatchProps = {
res?: NextApiResponse;
checkIsStopping: () => boolean;
lang?: localeType;
requestOrigin?: string;
mode: 'test' | 'chat' | 'debug';
@ -63,7 +64,7 @@ export type ChatDispatchProps = {
};
uid: string; // Who run this workflow
chatId?: string;
chatId: string;
responseChatItemId?: string;
histories: ChatItemType[];
variables: Record<string, any>; // global variable
@ -76,7 +77,7 @@ export type ChatDispatchProps = {
maxRunTimes: number;
isToolCall?: boolean;
workflowStreamResponse?: WorkflowResponseType;
version?: 'v1' | 'v2';
apiVersion?: 'v1' | 'v2';
workflowDispatchDeep: number;

View File

@ -1,6 +1,7 @@
import { createDocument } from 'zod-openapi';
import { DashboardPath } from './admin/core/dashboard';
import { TagsMap } from './tag';
import { AdminSupportPath } from './admin/support';
export const adminOpenAPIDocument = createDocument({
openapi: '3.1.0',
@ -10,13 +11,18 @@ export const adminOpenAPIDocument = createDocument({
description: 'FastGPT Admin API 文档'
},
paths: {
...DashboardPath
...DashboardPath,
...AdminSupportPath
},
servers: [{ url: '/api' }],
'x-tagGroups': [
{
name: '仪表盘',
tags: [TagsMap.adminDashboard]
},
{
name: '系统配置',
tags: [TagsMap.adminInform]
}
]
});

View File

@ -13,8 +13,6 @@ import {
} from './api';
import { TagsMap } from '../../../tag';
export * from './api';
export const DashboardPath: OpenAPIPath = {
'/admin/core/dashboard/getUserStats': {
get: {

View File

@ -0,0 +1,6 @@
import { AdminUserPath } from './user';
import type { OpenAPIPath } from '../../type';
export const AdminSupportPath: OpenAPIPath = {
...AdminUserPath
};

View File

@ -0,0 +1,6 @@
import { AdminInformPath } from './inform';
import type { OpenAPIPath } from '../../../type';
export const AdminUserPath: OpenAPIPath = {
...AdminInformPath
};

View File

@ -0,0 +1,57 @@
import { z } from 'zod';
import { InformLevelEnum } from '../../../../../support/user/inform/constants';
// Send system inform
export const SendSystemInformBodySchema = z.object({
title: z.string().meta({ description: '通知标题' }),
content: z.string().meta({ description: '通知内容' }),
level: z.enum(InformLevelEnum).meta({ description: '通知等级' })
});
export type SendSystemInformBodyType = z.infer<typeof SendSystemInformBodySchema>;
// Update system modal
export const UpdateSystemModalBodySchema = z.object({
content: z.string().meta({ description: '系统弹窗内容' })
});
export type UpdateSystemModalBodyType = z.infer<typeof UpdateSystemModalBodySchema>;
// Update operational ad
export const UpdateOperationalAdBodySchema = z.object({
operationalAdImage: z.string().meta({ description: '活动图片URL' }),
operationalAdLink: z.string().meta({ description: '活动链接' })
});
export type UpdateOperationalAdBodyType = z.infer<typeof UpdateOperationalAdBodySchema>;
// Update activity ad
export const UpdateActivityAdBodySchema = z.object({
activityAdImage: z.string().meta({ description: '底部广告图片URL' }),
activityAdLink: z.string().meta({ description: '底部广告链接' })
});
export type UpdateActivityAdBodyType = z.infer<typeof UpdateActivityAdBodySchema>;
// Response schemas
export const SystemMsgModalResponseSchema = z
.object({
id: z.string().meta({ description: '弹窗ID' }),
content: z.string().meta({ description: '弹窗内容' })
})
.optional();
export type SystemMsgModalValueType = z.infer<typeof SystemMsgModalResponseSchema>;
export const OperationalAdResponseSchema = z
.object({
id: z.string().meta({ description: '广告ID' }),
operationalAdImage: z.string().meta({ description: '广告图片URL' }),
operationalAdLink: z.string().meta({ description: '广告链接' })
})
.optional();
export type OperationalAdResponseType = z.infer<typeof OperationalAdResponseSchema>;
export const ActivityAdResponseSchema = z
.object({
id: z.string().meta({ description: '广告ID' }),
activityAdImage: z.string().meta({ description: '广告图片URL' }),
activityAdLink: z.string().meta({ description: '广告链接' })
})
.optional();
export type ActivityAdResponseType = z.infer<typeof ActivityAdResponseSchema>;

View File

@ -0,0 +1,161 @@
import type { OpenAPIPath } from '../../../../type';
import {
SendSystemInformBodySchema,
UpdateSystemModalBodySchema,
UpdateOperationalAdBodySchema,
UpdateActivityAdBodySchema,
SystemMsgModalResponseSchema,
OperationalAdResponseSchema,
ActivityAdResponseSchema
} from './api';
import { TagsMap } from '../../../../tag';
export const AdminInformPath: OpenAPIPath = {
'/admin/support/user/inform/sendSystemInform': {
post: {
summary: '发送系统通知给所有用户',
description: '向所有用户发送系统通知消息',
tags: [TagsMap.adminInform],
requestBody: {
content: {
'application/json': {
schema: SendSystemInformBodySchema
}
}
},
responses: {
200: {
description: '成功发送系统通知',
content: {
'application/json': {
schema: {}
}
}
}
}
}
},
'/support/user/inform/getSystemMsgModal': {
get: {
summary: '获取系统弹窗内容',
description: '获取系统消息弹窗的内容',
tags: [TagsMap.adminInform],
responses: {
200: {
description: '成功获取系统弹窗内容',
content: {
'application/json': {
schema: SystemMsgModalResponseSchema
}
}
}
}
}
},
'/admin/support/user/inform/updateSystemModal': {
post: {
summary: '更新系统弹窗内容',
description: '更新系统消息弹窗的内容',
tags: [TagsMap.adminInform],
requestBody: {
content: {
'application/json': {
schema: UpdateSystemModalBodySchema
}
}
},
responses: {
200: {
description: '成功更新系统弹窗',
content: {
'application/json': {
schema: {}
}
}
}
}
}
},
'/support/user/inform/getOperationalAd': {
get: {
summary: '获取运营广告',
description: '获取运营广告的图片和链接',
tags: [TagsMap.adminInform],
responses: {
200: {
description: '成功获取运营广告',
content: {
'application/json': {
schema: OperationalAdResponseSchema
}
}
}
}
}
},
'/admin/support/user/inform/updateOperationalAd': {
post: {
summary: '更新运营广告',
description: '更新运营广告的图片和链接',
tags: [TagsMap.adminInform],
requestBody: {
content: {
'application/json': {
schema: UpdateOperationalAdBodySchema
}
}
},
responses: {
200: {
description: '成功更新运营广告',
content: {
'application/json': {
schema: {}
}
}
}
}
}
},
'/support/user/inform/getActivityAd': {
get: {
summary: '获取活动广告',
description: '获取活动广告的图片和链接',
tags: [TagsMap.adminInform],
responses: {
200: {
description: '成功获取活动广告',
content: {
'application/json': {
schema: ActivityAdResponseSchema
}
}
}
}
}
},
'/admin/support/user/inform/updateActivityAd': {
post: {
summary: '更新活动广告',
description: '更新活动广告的图片和链接',
tags: [TagsMap.adminInform],
requestBody: {
content: {
'application/json': {
schema: UpdateActivityAdBodySchema
}
}
},
responses: {
200: {
description: '成功更新活动广告',
content: {
'application/json': {
schema: {}
}
}
}
}
}
}
};

View File

@ -1,7 +1,17 @@
import { z } from 'zod';
export const PaginationSchema = z.object({
pageSize: z.union([z.number(), z.string()]),
offset: z.union([z.number(), z.string()]).optional(),
pageNum: z.union([z.number(), z.string()]).optional()
pageSize: z.union([z.number(), z.string()]).optional().describe('每页条数'),
offset: z.union([z.number(), z.string()]).optional().describe('偏移量(与页码二选一)'),
pageNum: z.union([z.number(), z.string()]).optional().describe('页码(与偏移量二选一)')
});
export type PaginationType = z.infer<typeof PaginationSchema>;
export const PaginationResponseSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>
z.object({
total: z.number().optional().default(0),
list: z.array(itemSchema).optional().default([])
});
export type PaginationResponseType<T extends z.ZodTypeAny> = z.infer<
ReturnType<typeof PaginationResponseSchema<T>>
>;

View File

@ -1,6 +1,8 @@
import type { OpenAPIPath } from '../../type';
import { AppLogPath } from './log';
import { PublishChannelPath } from './publishChannel';
export const AppPath: OpenAPIPath = {
...AppLogPath
...AppLogPath,
...PublishChannelPath
};

View File

@ -31,12 +31,15 @@ export type updateLogKeysBody = z.infer<typeof UpdateLogKeysBodySchema>;
export const ChatLogItemSchema = z.object({
_id: z.string().meta({ example: '68ad85a7463006c963799a05', description: '对话日志 ID' }),
chatId: z.string().meta({ example: 'chat123', description: '对话 ID' }),
title: z.string().optional().meta({ example: '用户对话', description: '对话标题' }),
title: z.string().nullish().meta({ example: '用户对话', description: '对话标题' }),
customTitle: z.string().nullish().meta({ example: '自定义标题', description: '自定义对话标题' }),
source: z.enum(ChatSourceEnum).meta({ example: ChatSourceEnum.api, description: '对话来源' }),
sourceName: z.string().optional().meta({ example: 'API调用', description: '来源名称' }),
sourceName: z.string().nullish().meta({ example: 'API调用', description: '来源名称' }),
updateTime: z.date().meta({ example: '2024-01-01T00:30:00.000Z', description: '更新时间' }),
createTime: z.date().meta({ example: '2024-01-01T00:00:00.000Z', description: '创建时间' }),
createTime: z
.date()
.nullish()
.meta({ example: '2024-01-01T00:00:00.000Z', description: '创建时间' }),
messageCount: z.int().nullish().meta({ example: 10, description: '消息数量' }),
userGoodFeedbackCount: z.int().nullish().meta({ example: 3, description: '好评反馈数量' }),
userBadFeedbackCount: z.int().nullish().meta({ example: 1, description: '差评反馈数量' }),
@ -50,7 +53,7 @@ export const ChatLogItemSchema = z.object({
totalPoints: z.number().nullish().meta({ example: 150.5, description: '总积分消耗' }),
outLinkUid: z.string().nullish().meta({ example: 'outLink123', description: '外链用户 ID' }),
tmbId: z.string().nullish().meta({ example: 'tmb123', description: '团队成员 ID' }),
sourceMember: SourceMemberSchema.optional().meta({ description: '来源成员信息' }),
sourceMember: SourceMemberSchema.nullish().meta({ description: '来源成员信息' }),
versionName: z.string().nullish().meta({ example: 'v1.0.0', description: '版本名称' }),
region: z.string().nullish().meta({ example: '中国', description: '区域' })
});

View File

@ -0,0 +1,5 @@
import { PlaygroundPath } from './playground';
export const PublishChannelPath = {
...PlaygroundPath
};

View File

@ -0,0 +1,52 @@
import { z } from 'zod';
import { ObjectIdSchema } from '../../../../../common/type/mongo';
// Playground Visibility Config Fields
const PlaygroundVisibilityConfigFieldsSchema = z.object({
showRunningStatus: z.boolean().meta({
example: true,
description: '是否显示运行状态'
}),
showCite: z.boolean().meta({
example: true,
description: '是否显示引用'
}),
showFullText: z.boolean().meta({
example: true,
description: '是否显示全文'
}),
canDownloadSource: z.boolean().meta({
example: true,
description: '是否可下载来源'
})
});
// Get Playground Visibility Config Parameters
export const GetPlaygroundVisibilityConfigParamsSchema = z.object({
appId: ObjectIdSchema.meta({
example: '68ad85a7463006c963799a05',
description: '应用 ID'
})
});
export type GetPlaygroundVisibilityConfigParamsType = z.infer<
typeof GetPlaygroundVisibilityConfigParamsSchema
>;
// Playground Visibility Config Response
export const PlaygroundVisibilityConfigResponseSchema = PlaygroundVisibilityConfigFieldsSchema;
export type PlaygroundVisibilityConfigResponseType = z.infer<
typeof PlaygroundVisibilityConfigResponseSchema
>;
// Update Playground Visibility Config Parameters
export const UpdatePlaygroundVisibilityConfigParamsSchema = z
.object({
appId: ObjectIdSchema.meta({
example: '68ad85a7463006c963799a05',
description: '应用 ID'
})
})
.extend(PlaygroundVisibilityConfigFieldsSchema.shape);
export type UpdatePlaygroundVisibilityConfigParamsType = z.infer<
typeof UpdatePlaygroundVisibilityConfigParamsSchema
>;

View File

@ -0,0 +1,109 @@
import { z } from 'zod';
import type { OpenAPIPath } from '../../../../type';
import {
GetPlaygroundVisibilityConfigParamsSchema,
PlaygroundVisibilityConfigResponseSchema,
UpdatePlaygroundVisibilityConfigParamsSchema
} from './api';
import { TagsMap } from '../../../../tag';
export const PlaygroundPath: OpenAPIPath = {
'/api/support/outLink/playground/config': {
get: {
summary: '获取门户配置',
description:
'获取指定应用的门户聊天界面的可见性配置,包括节点状态、响应详情、全文显示和原始来源显示的设置',
tags: [TagsMap.publishChannel],
requestParams: {
query: GetPlaygroundVisibilityConfigParamsSchema
},
responses: {
200: {
description: '成功返回门户配置',
content: {
'application/json': {
schema: PlaygroundVisibilityConfigResponseSchema
}
}
},
400: {
description: '请求参数错误',
content: {
'application/json': {
schema: z.object({
code: z.literal(500),
statusText: z.literal('Invalid Params'),
message: z.string(),
data: z.null()
})
}
}
},
401: {
description: '用户未授权',
content: {
'application/json': {
schema: z.object({
code: z.literal(401),
statusText: z.literal('unAuthorization'),
message: z.string(),
data: z.null()
})
}
}
}
}
}
},
'/api/support/outLink/playground/update': {
post: {
summary: '更新门户配置',
description:
'更新指定应用的门户聊天界面的可见性配置,包括节点状态、响应详情、全文显示和原始来源显示的设置。如果配置不存在则创建新配置',
tags: [TagsMap.publishChannel],
requestBody: {
content: {
'application/json': {
schema: UpdatePlaygroundVisibilityConfigParamsSchema
}
}
},
responses: {
200: {
description: '成功更新门户配置',
content: {
'application/json': {
schema: z.null()
}
}
},
400: {
description: '请求参数错误',
content: {
'application/json': {
schema: z.object({
code: z.literal(500),
statusText: z.literal('Invalid Params'),
message: z.string(),
data: z.null()
})
}
}
},
401: {
description: '用户未授权',
content: {
'application/json': {
schema: z.object({
code: z.literal(401),
statusText: z.literal('unAuthorization'),
message: z.string(),
data: z.null()
})
}
}
}
}
}
}
};

View File

@ -0,0 +1,12 @@
import { ObjectIdSchema } from '../../../common/type/mongo';
import z from 'zod';
/* Recently Used Apps */
export const GetRecentlyUsedAppsResponseSchema = z.array(
z.object({
appId: ObjectIdSchema.describe('应用ID'),
name: z.string().min(1).describe('应用名称'),
avatar: z.string().min(1).describe('应用头像')
})
);
export type GetRecentlyUsedAppsResponseType = z.infer<typeof GetRecentlyUsedAppsResponseSchema>;

View File

@ -0,0 +1,72 @@
import { OutLinkChatAuthSchema } from '../../../../support/permission/chat/type';
import { ObjectIdSchema } from '../../../../common/type/mongo';
import z from 'zod';
/* ============ v2/chat/stop ============ */
export const StopV2ChatSchema = z
.object({
appId: ObjectIdSchema.describe('应用ID'),
chatId: z.string().min(1).describe('对话ID'),
outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据')
})
.meta({
example: {
appId: '1234567890',
chatId: '1234567890',
outLinkAuthData: {
shareId: '1234567890',
outLinkUid: '1234567890'
}
}
});
export type StopV2ChatParams = z.infer<typeof StopV2ChatSchema>;
export const StopV2ChatResponseSchema = z
.object({
success: z.boolean().describe('是否成功停止')
})
.meta({
example: {
success: true
}
});
export type StopV2ChatResponse = z.infer<typeof StopV2ChatResponseSchema>;
/* ============ chat file ============ */
export const PresignChatFileGetUrlSchema = z
.object({
key: z.string().min(1).describe('文件key'),
appId: ObjectIdSchema.describe('应用ID'),
outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据')
})
.meta({
example: {
key: '1234567890',
appId: '1234567890',
outLinkAuthData: {
shareId: '1234567890',
outLinkUid: '1234567890'
}
}
});
export type PresignChatFileGetUrlParams = z.infer<typeof PresignChatFileGetUrlSchema>;
export const PresignChatFilePostUrlSchema = z
.object({
filename: z.string().min(1).describe('文件名'),
appId: ObjectIdSchema.describe('应用ID'),
chatId: z.string().min(1).describe('对话ID'),
outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据')
})
.meta({
example: {
filename: '1234567890',
appId: '1234567890',
chatId: '1234567890',
outLinkAuthData: {
shareId: '1234567890',
outLinkUid: '1234567890'
}
}
});
export type PresignChatFilePostUrlParams = z.infer<typeof PresignChatFilePostUrlSchema>;

View File

@ -0,0 +1,86 @@
import type { OpenAPIPath } from '../../../type';
import { TagsMap } from '../../../tag';
import {
StopV2ChatSchema,
StopV2ChatResponseSchema,
PresignChatFilePostUrlSchema,
PresignChatFileGetUrlSchema
} from './api';
import { CreatePostPresignedUrlResultSchema } from '../../../../../service/common/s3/type';
import { z } from 'zod';
export const ChatControllerPath: OpenAPIPath = {
'/v2/chat/stop': {
post: {
summary: '停止 Agent 运行',
description: `优雅停止正在运行的 Agent, 会尝试等待当前节点结束后返回,最长 5s超过 5s 仍未结束,则会返回成功。
LLM HTTP `,
tags: [TagsMap.chatController],
requestBody: {
content: {
'application/json': {
schema: StopV2ChatSchema
}
}
},
responses: {
200: {
description: '成功停止工作流',
content: {
'application/json': {
schema: StopV2ChatResponseSchema
}
}
}
}
}
},
'/core/chat/presignChatFilePostUrl': {
post: {
summary: '获取文件上传 URL',
description: '获取文件上传 URL',
tags: [TagsMap.chatController],
requestBody: {
content: {
'application/json': {
schema: PresignChatFilePostUrlSchema
}
}
},
responses: {
200: {
description: '成功上传对话文件预签名 URL',
content: {
'application/json': {
schema: CreatePostPresignedUrlResultSchema
}
}
}
}
}
},
'/core/chat/presignChatFileGetUrl': {
post: {
summary: '获取文件预览地址',
description: '获取文件预览地址',
tags: [TagsMap.chatController],
requestBody: {
content: {
'application/json': {
schema: PresignChatFileGetUrlSchema
}
}
},
responses: {
200: {
description: '成功获取对话文件预签名 URL',
content: {
'application/json': {
schema: z.string()
}
}
}
}
}
}
};

View File

@ -105,11 +105,11 @@ export const UpdateUserFeedbackBodySchema = z.object({
example: 'data123',
description: '消息数据 ID'
}),
userGoodFeedback: z.string().optional().nullable().meta({
userGoodFeedback: z.string().nullish().meta({
example: '回答很好',
description: '用户好评反馈内容'
}),
userBadFeedback: z.string().optional().nullable().meta({
userBadFeedback: z.string().nullish().meta({
example: '回答不准确',
description: '用户差评反馈内容'
})

View File

@ -14,81 +14,9 @@ import {
} from './api';
export const ChatFeedbackPath: OpenAPIPath = {
'/core/chat/feedback/updateFeedbackReadStatus': {
post: {
summary: '更新反馈阅读状态',
description: '标记指定消息的反馈为已读或未读状态',
tags: [TagsMap.chatFeedback],
requestBody: {
content: {
'application/json': {
schema: UpdateFeedbackReadStatusBodySchema
}
}
},
responses: {
200: {
description: '成功更新反馈阅读状态',
content: {
'application/json': {
schema: UpdateFeedbackReadStatusResponseSchema
}
}
}
}
}
},
'/core/chat/feedback/adminUpdate': {
post: {
summary: '管理员标注反馈',
description: '管理员为指定消息添加或更新标注反馈,包含数据集关联信息',
tags: [TagsMap.chatFeedback],
requestBody: {
content: {
'application/json': {
schema: AdminUpdateFeedbackBodySchema
}
}
},
responses: {
200: {
description: '成功更新管理员反馈标注',
content: {
'application/json': {
schema: AdminUpdateFeedbackResponseSchema
}
}
}
}
}
},
'/core/chat/feedback/closeCustom': {
post: {
summary: '关闭自定义反馈',
description: '删除或关闭指定索引位置的自定义反馈条目',
tags: [TagsMap.chatFeedback],
requestBody: {
content: {
'application/json': {
schema: CloseCustomFeedbackBodySchema
}
}
},
responses: {
200: {
description: '成功关闭自定义反馈',
content: {
'application/json': {
schema: CloseCustomFeedbackResponseSchema
}
}
}
}
}
},
'/core/chat/feedback/updateUserFeedback': {
post: {
summary: '更新用户反馈',
summary: '添加/更新用户反馈',
description: '用户对消息添加或更新好评/差评反馈',
tags: [TagsMap.chatFeedback],
requestBody: {
@ -133,5 +61,77 @@ export const ChatFeedbackPath: OpenAPIPath = {
}
}
}
},
'/core/chat/feedback/updateFeedbackReadStatus': {
post: {
summary: '应用管理员-更新反馈阅读状态',
description: '标记指定消息的反馈为已读或未读状态',
tags: [TagsMap.chatFeedback],
requestBody: {
content: {
'application/json': {
schema: UpdateFeedbackReadStatusBodySchema
}
}
},
responses: {
200: {
description: '成功更新反馈阅读状态',
content: {
'application/json': {
schema: UpdateFeedbackReadStatusResponseSchema
}
}
}
}
}
},
'/core/chat/feedback/adminUpdate': {
post: {
summary: '应用管理员-标注反馈',
description: '管理员为指定消息添加或更新标注反馈,包含数据集关联信息',
tags: [TagsMap.chatFeedback],
requestBody: {
content: {
'application/json': {
schema: AdminUpdateFeedbackBodySchema
}
}
},
responses: {
200: {
description: '成功更新管理员反馈标注',
content: {
'application/json': {
schema: AdminUpdateFeedbackResponseSchema
}
}
}
}
}
},
'/core/chat/feedback/closeCustom': {
post: {
summary: '应用管理员-关闭自定义反馈',
description: '删除或关闭指定索引位置的自定义反馈条目',
tags: [TagsMap.chatFeedback],
requestBody: {
content: {
'application/json': {
schema: CloseCustomFeedbackBodySchema
}
}
},
responses: {
200: {
description: '成功关闭自定义反馈',
content: {
'application/json': {
schema: CloseCustomFeedbackResponseSchema
}
}
}
}
}
}
};

View File

@ -0,0 +1,65 @@
import z from 'zod';
import { ObjectIdSchema } from '../../../../common/type/mongo';
import { OutLinkChatAuthSchema } from '../../../../support/permission/chat';
import { ChatSourceEnum } from '../../../../core/chat/constants';
import { PaginationSchema, PaginationResponseSchema } from '../../../api';
// Get chat histories schema
export const GetHistoriesBodySchema = PaginationSchema.extend(OutLinkChatAuthSchema.shape).extend({
appId: z.string().optional().describe('应用ID'),
source: z.enum(ChatSourceEnum).optional().describe('对话来源'),
startCreateTime: z.string().optional().describe('创建时间开始'),
endCreateTime: z.string().optional().describe('创建时间结束'),
startUpdateTime: z.string().optional().describe('更新时间开始'),
endUpdateTime: z.string().optional().describe('更新时间结束')
});
export type GetHistoriesBodyType = z.infer<typeof GetHistoriesBodySchema>;
export const GetHistoriesResponseSchema = PaginationResponseSchema(
z.object({
chatId: z.string(),
updateTime: z.date(),
appId: z.string(),
customTitle: z.string().optional(),
title: z.string(),
top: z.boolean().optional()
})
);
export type GetHistoriesResponseType = z.infer<typeof GetHistoriesResponseSchema>;
// Update chat history schema
export const UpdateHistoryBodySchema = OutLinkChatAuthSchema.and(
z.object({
appId: ObjectIdSchema.describe('应用ID'),
chatId: z.string().min(1).describe('对话ID'),
title: z.string().optional().describe('标题'),
customTitle: z.string().optional().describe('自定义标题'),
top: z.boolean().optional().describe('是否置顶')
})
);
export type UpdateHistoryBodyType = z.infer<typeof UpdateHistoryBodySchema>;
// Delete single chat history schema
export const DelChatHistorySchema = OutLinkChatAuthSchema.extend({
appId: ObjectIdSchema.describe('应用ID'),
chatId: z.string().min(1).describe('对话ID')
});
export type DelChatHistoryType = z.infer<typeof DelChatHistorySchema>;
// Clear all chat histories schema
export const ClearChatHistoriesSchema = OutLinkChatAuthSchema.extend({
appId: ObjectIdSchema.describe('应用ID')
});
export type ClearChatHistoriesType = z.infer<typeof ClearChatHistoriesSchema>;
// Batch delete chat histories schema (for log manager)
export const ChatBatchDeleteBodySchema = z.object({
appId: ObjectIdSchema,
chatIds: z
.array(z.string().min(1))
.min(1)
.meta({
description: '对话ID列表',
example: ['chat_123456', 'chat_789012']
})
});
export type ChatBatchDeleteBodyType = z.infer<typeof ChatBatchDeleteBodySchema>;

View File

@ -0,0 +1,109 @@
import type { OpenAPIPath } from '../../../type';
import { TagsMap } from '../../../tag';
import {
GetHistoriesBodySchema,
GetHistoriesResponseSchema,
UpdateHistoryBodySchema,
ChatBatchDeleteBodySchema,
DelChatHistorySchema,
ClearChatHistoriesSchema
} from './api';
export const ChatHistoryPath: OpenAPIPath = {
'/core/chat/history/getHistories': {
post: {
summary: '获取对话历史列表',
description: '分页获取指定应用的对话历史记录',
tags: [TagsMap.chatHistory],
requestBody: {
content: {
'application/json': {
schema: GetHistoriesBodySchema
}
}
},
responses: {
200: {
description: '成功获取对话历史列表',
content: {
'application/json': {
schema: GetHistoriesResponseSchema
}
}
}
}
}
},
'/core/chat/history/updateHistory': {
put: {
summary: '修改对话历史',
description: '修改对话历史的标题、自定义标题或置顶状态',
tags: [TagsMap.chatHistory],
requestBody: {
content: {
'application/json': {
schema: UpdateHistoryBodySchema
}
}
},
responses: {
200: {
description: '成功修改对话历史'
}
}
}
},
'/core/chat/history/delHistory': {
delete: {
summary: '删除单个对话历史',
description: '软删除指定的单个对话记录',
tags: [TagsMap.chatHistory],
requestBody: {
content: {
'application/json': {
schema: DelChatHistorySchema
}
}
},
responses: {
200: {
description: '成功删除对话'
}
}
}
},
'/core/chat/history/clearHistories': {
delete: {
summary: '清空应用对话历史',
description: '清空指定应用的所有对话记录(软删除)',
tags: [TagsMap.chatHistory],
requestParams: {
query: ClearChatHistoriesSchema
},
responses: {
200: {
description: '成功清空对话历史'
}
}
}
},
'/core/chat/history/batchDelete': {
post: {
summary: '批量删除对话历史',
description: '批量删除指定应用的多个对话记录(真实删除),需应用日志权限。',
tags: [TagsMap.chatHistory],
requestBody: {
content: {
'application/json': {
schema: ChatBatchDeleteBodySchema
}
}
},
responses: {
200: {
description: '成功删除对话'
}
}
}
}
};

View File

@ -2,58 +2,29 @@ import type { OpenAPIPath } from '../../type';
import { ChatSettingPath } from './setting';
import { ChatFavouriteAppPath } from './favourite/index';
import { ChatFeedbackPath } from './feedback/index';
import { z } from 'zod';
import { CreatePostPresignedUrlResultSchema } from '../../../../service/common/s3/type';
import { PresignChatFileGetUrlSchema, PresignChatFilePostUrlSchema } from '../../../core/chat/api';
import { ChatHistoryPath } from './history/index';
import { GetRecentlyUsedAppsResponseSchema } from './api';
import { TagsMap } from '../../tag';
import { ChatControllerPath } from './controler';
export const ChatPath: OpenAPIPath = {
...ChatSettingPath,
...ChatFavouriteAppPath,
...ChatFeedbackPath,
...ChatHistoryPath,
...ChatControllerPath,
'/core/chat/presignChatFileGetUrl': {
post: {
summary: '获取对话文件预签名 URL',
description: '获取对话文件的预签名 URL',
'/core/chat/recentlyUsed': {
get: {
summary: '获取最近使用的应用',
description: '获取最近使用的应用',
tags: [TagsMap.chatPage],
requestBody: {
content: {
'application/json': {
schema: PresignChatFileGetUrlSchema
}
}
},
responses: {
200: {
description: '成功获取对话文件预签名 URL',
description: '成功返回最近使用的应用',
content: {
'application/json': {
schema: z.string()
}
}
}
}
}
},
'/core/chat/presignChatFilePostUrl': {
post: {
summary: '上传对话文件预签名 URL',
description: '上传对话文件的预签名 URL',
tags: [TagsMap.chatPage],
requestBody: {
content: {
'application/json': {
schema: PresignChatFilePostUrlSchema
}
}
},
responses: {
200: {
description: '成功上传对话文件预签名 URL',
content: {
'application/json': {
schema: CreatePostPresignedUrlResultSchema
schema: GetRecentlyUsedAppsResponseSchema
}
}
}

View File

@ -1,11 +1,9 @@
import { createDocument } from 'zod-openapi';
import { ChatPath } from './core/chat';
import { ApiKeyPath } from './support/openapi';
import { TagsMap } from './tag';
import { PluginPath } from './core/plugin';
import { WalletPath } from './support/wallet';
import { CustomDomainPath } from './support/customDomain';
import { AppPath } from './core/app';
import { SupportPath } from './support';
export const openAPIDocument = createDocument({
openapi: '3.1.0',
@ -17,28 +15,26 @@ export const openAPIDocument = createDocument({
paths: {
...AppPath,
...ChatPath,
...ApiKeyPath,
...PluginPath,
...WalletPath,
...CustomDomainPath
...SupportPath
},
servers: [{ url: '/api' }],
'x-tagGroups': [
{
name: 'Agent 应用',
tags: [TagsMap.appLog]
tags: [TagsMap.appLog, TagsMap.publishChannel]
},
{
name: '对话管理',
tags: [TagsMap.chatSetting, TagsMap.chatPage, TagsMap.chatFeedback]
tags: [TagsMap.chatHistory, TagsMap.chatPage, TagsMap.chatFeedback, TagsMap.chatSetting]
},
{
name: '插件系统',
tags: [TagsMap.pluginToolTag, TagsMap.pluginTeam]
},
{
name: '支付系统',
tags: [TagsMap.walletBill, TagsMap.walletDiscountCoupon]
name: '用户体系',
tags: [TagsMap.userInform, TagsMap.walletBill, TagsMap.walletDiscountCoupon]
},
{
name: '通用-辅助功能',

View File

@ -0,0 +1,12 @@
import { UserPath } from './user';
import type { OpenAPIPath } from '../type';
import { WalletPath } from './wallet';
import { ApiKeyPath } from './openapi';
import { CustomDomainPath } from './customDomain';
export const SupportPath: OpenAPIPath = {
...UserPath,
...WalletPath,
...ApiKeyPath,
...CustomDomainPath
};

View File

@ -0,0 +1,6 @@
import { UserInformPath } from './inform';
import type { OpenAPIPath } from '../../type';
export const UserPath: OpenAPIPath = {
...UserInformPath
};

View File

@ -0,0 +1,61 @@
import type { OpenAPIPath } from '../../../type';
import {
SystemMsgModalResponseSchema,
OperationalAdResponseSchema,
ActivityAdResponseSchema
} from '../../../admin/support/user/inform/api';
import { TagsMap } from '../../../tag';
export const UserInformPath: OpenAPIPath = {
'/proApi/support/user/inform/getSystemMsgModal': {
get: {
summary: '获取系统弹窗内容',
description: '获取系统消息弹窗的内容',
tags: [TagsMap.userInform],
responses: {
200: {
description: '成功获取系统弹窗内容',
content: {
'application/json': {
schema: SystemMsgModalResponseSchema
}
}
}
}
}
},
'/proApi/support/user/inform/getOperationalAd': {
get: {
summary: '获取运营广告',
description: '获取运营广告的图片和链接',
tags: [TagsMap.userInform],
responses: {
200: {
description: '成功获取运营广告',
content: {
'application/json': {
schema: OperationalAdResponseSchema
}
}
}
}
}
},
'/proApi/support/user/inform/getActivityAd': {
get: {
summary: '获取活动广告',
description: '获取活动广告的图片和链接',
tags: [TagsMap.userInform],
responses: {
200: {
description: '成功获取活动广告',
content: {
'application/json': {
schema: ActivityAdResponseSchema
}
}
}
}
}
}
};

View File

@ -25,6 +25,32 @@ export const BillListResponseSchema = z.object({
});
export type GetBillListResponseType = z.infer<typeof BillListResponseSchema>;
// Bill detail
export const BillDetailQuerySchema = z.object({
billId: ObjectIdSchema.meta({ description: '订单 ID' })
});
export type BillDetailQueryType = z.infer<typeof BillDetailQuerySchema>;
export const BillDetailResponseSchema = BillSchema.safeExtend({
discountCouponName: z.string().optional(),
couponDetail: z
.object({
key: z.string(),
type: z.enum(CouponTypeEnum),
subscriptions: z.array(
z.object({
type: z.enum(SubTypeEnum),
durationDay: z.number(),
totalPoints: z.number().optional(),
level: z.enum(StandardSubLevelEnum).optional(),
extraDatasetSize: z.number().optional(),
customConfig: z.record(z.string(), z.any()).optional()
})
)
})
.optional()
});
export type BillDetailResponseType = z.infer<typeof BillDetailResponseSchema>;
// Create
export const CreateStandPlanBillSchema = z
.object({
@ -88,30 +114,14 @@ export const CheckPayResultResponseSchema = z.object({
});
export type CheckPayResultResponseType = z.infer<typeof CheckPayResultResponseSchema>;
// Bill detail
export const BillDetailResponseSchema = BillSchema.safeExtend({
discountCouponName: z.string().optional(),
couponDetail: z
.object({
key: z.string(),
type: z.enum(CouponTypeEnum),
subscriptions: z.array(
z.object({
type: z.enum(SubTypeEnum),
durationDay: z.number(),
totalPoints: z.number().optional(),
level: z.enum(StandardSubLevelEnum).optional(),
extraDatasetSize: z.number().optional(),
customConfig: z.record(z.string(), z.any()).optional()
})
)
})
.optional()
});
export type BillDetailResponseType = z.infer<typeof BillDetailResponseSchema>;
// Cancel bill
export const CancelBillPropsSchema = z.object({
billId: ObjectIdSchema.meta({ description: '订单 ID' })
});
export type CancelBillPropsType = z.infer<typeof CancelBillPropsSchema>;
// Check pay result
export const CheckPayResultQuerySchema = z.object({
payId: ObjectIdSchema.meta({ description: '订单 ID' })
});
export type CheckPayResultQueryType = z.infer<typeof CheckPayResultQuerySchema>;

View File

@ -8,7 +8,9 @@ import {
CheckPayResultResponseSchema,
BillDetailResponseSchema,
BillListQuerySchema,
CancelBillPropsSchema
CancelBillPropsSchema,
CheckPayResultQuerySchema,
BillDetailQuerySchema
} from './api';
import { TagsMap } from '../../../tag';
import { ObjectIdSchema } from '../../../../common/type/mongo';
@ -68,11 +70,7 @@ export const BillPath: OpenAPIPath = {
description: '检查订单的支付状态,用于轮询支付结果',
tags: [TagsMap.walletBill],
requestParams: {
query: z.object({
payId: ObjectIdSchema.meta({
description: '订单 ID'
})
})
query: CheckPayResultQuerySchema
},
responses: {
200: {
@ -92,11 +90,7 @@ export const BillPath: OpenAPIPath = {
description: '根据订单 ID 获取订单详细信息,包括优惠券名称等',
tags: [TagsMap.walletBill],
requestParams: {
query: z.object({
billId: ObjectIdSchema.meta({
description: '订单 ID'
})
})
query: BillDetailQuerySchema
},
responses: {
200: {

View File

@ -5,6 +5,8 @@ export const TagsMap = {
// Chat - home
chatPage: '对话页',
chatController: '对话框操作',
chatHistory: '对话历史管理',
chatSetting: '门户页配置',
chatFeedback: '对话反馈',
@ -12,11 +14,16 @@ export const TagsMap = {
pluginToolTag: '工具标签',
pluginTeam: '团队插件管理',
// Publish Channel
publishChannel: '发布渠道',
/* Support */
// Wallet
walletBill: '订单',
walletDiscountCoupon: '优惠券',
customDomain: '自定义域名',
// User
userInform: '用户通知',
/* Common */
// APIKey
@ -28,5 +35,7 @@ export const TagsMap = {
pluginAdmin: '管理员插件管理',
pluginToolAdmin: '管理员系统工具管理',
// Data
adminDashboard: '管理员仪表盘'
adminDashboard: '管理员仪表盘',
// Inform
adminInform: '管理员通知管理'
};

View File

@ -1,5 +1,7 @@
import { z } from 'zod';
import type { HistoryItemType } from '../../core/chat/type.d';
import type { OutLinkSchema } from './type.d';
import type { OutLinkSchema, PlaygroundVisibilityConfigType } from './type.d';
import { PlaygroundVisibilityConfigSchema } from './type.d';
export type AuthOutLinkInitProps = {
outLinkUid: string;
@ -10,3 +12,20 @@ export type AuthOutLinkLimitProps = AuthOutLinkChatProps & { outLink: OutLinkSch
export type AuthOutLinkResponse = {
uid: string;
};
export const UpdatePlaygroundVisibilityConfigBodySchema = PlaygroundVisibilityConfigSchema.extend({
appId: z.string().min(1, 'App ID is required')
});
export type UpdatePlaygroundVisibilityConfigBody = z.infer<
typeof UpdatePlaygroundVisibilityConfigBodySchema
>;
export const PlaygroundVisibilityConfigQuerySchema = z.object({
appId: z.string().min(1, 'App ID is required')
});
export type PlaygroundVisibilityConfigQuery = z.infer<typeof PlaygroundVisibilityConfigQuerySchema>;
export const PlaygroundVisibilityConfigResponseSchema = PlaygroundVisibilityConfigSchema;
export type PlaygroundVisibilityConfigResponse = z.infer<
typeof PlaygroundVisibilityConfigResponseSchema
>;

View File

@ -5,5 +5,6 @@ export enum PublishChannelEnum {
feishu = 'feishu',
dingtalk = 'dingtalk',
wecom = 'wecom',
officialAccount = 'official_account'
officialAccount = 'official_account',
playground = 'playground'
}

View File

@ -1,3 +1,4 @@
import { z } from 'zod';
import { AppSchema } from '../../core/app/type';
import type { PublishChannelEnum } from './constant';
import { RequireOnlyOne } from '../../common/type/utils';
@ -63,14 +64,14 @@ export type OutLinkSchema<T extends OutlinkAppType = undefined> = {
lastTime: Date;
type: PublishChannelEnum;
// whether the response content is detailed
responseDetail: boolean;
// whether to hide the node status
showNodeStatus?: boolean;
// wheter to show the full text reader
// showFullText?: boolean;
// whether to show the complete quote
showRawSource?: boolean;
// whether to show the quote
showCite: boolean;
// whether to show the running status
showRunningStatus: boolean;
// whether to show the full text reader
showFullText: boolean;
// whether can download source
canDownloadSource: boolean;
// response when request
immediateResponse?: string;
@ -93,10 +94,10 @@ export type OutLinkSchema<T extends OutlinkAppType = undefined> = {
export type OutLinkEditType<T = undefined> = {
_id?: string;
name: string;
responseDetail?: OutLinkSchema<T>['responseDetail'];
showNodeStatus?: OutLinkSchema<T>['showNodeStatus'];
// showFullText?: OutLinkSchema<T>['showFullText'];
showRawSource?: OutLinkSchema<T>['showRawSource'];
showCite?: OutLinkSchema<T>['showCite'];
showRunningStatus?: OutLinkSchema<T>['showRunningStatus'];
showFullText?: OutLinkSchema<T>['showFullText'];
canDownloadSource?: OutLinkSchema<T>['canDownloadSource'];
// response when request
immediateResponse?: string;
// response when error or other situation
@ -106,3 +107,12 @@ export type OutLinkEditType<T = undefined> = {
// config for specific platform
app?: T;
};
export const PlaygroundVisibilityConfigSchema = z.object({
showRunningStatus: z.boolean(),
showCite: z.boolean(),
showFullText: z.boolean(),
canDownloadSource: z.boolean()
});
export type PlaygroundVisibilityConfigType = z.infer<typeof PlaygroundVisibilityConfigSchema>;

View File

@ -1,9 +0,0 @@
type ShareChatAuthProps = {
shareId?: string;
outLinkUid?: string;
};
type TeamChatAuthProps = {
teamId?: string;
teamToken?: string;
};
export type OutLinkChatAuthProps = ShareChatAuthProps & TeamChatAuthProps;

View File

@ -0,0 +1,16 @@
import z from 'zod';
export const ShareChatAuthSchema = z.object({
shareId: z.string().optional().describe('分享链接ID'),
outLinkUid: z.string().optional().describe('外链用户ID')
});
export type ShareChatAuthProps = z.infer<typeof ShareChatAuthSchema>;
export const TeamChatAuthSchema = z.object({
teamId: z.string().optional().describe('团队ID'),
teamToken: z.string().optional().describe('团队Token')
});
export type TeamChatAuthProps = z.infer<typeof TeamChatAuthSchema>;
export const OutLinkChatAuthSchema = ShareChatAuthSchema.extend(TeamChatAuthSchema.shape);
export type OutLinkChatAuthProps = z.infer<typeof OutLinkChatAuthSchema>;

View File

@ -2,8 +2,7 @@ import type { auditLogMap, adminAuditLogMap } from '../../../../web/support/user
export enum AdminAuditEventEnum {
ADMIN_LOGIN = 'ADMIN_LOGIN',
ADMIN_UPDATE_SYSTEM_MODAL = 'ADMIN_UPDATE_SYSTEM_MODAL',
ADMIN_SEND_SYSTEM_INFORM = 'ADMIN_SEND_SYSTEM_INFORM',
ADMIN_ADD_USER = 'ADMIN_ADD_USER',
ADMIN_UPDATE_USER = 'ADMIN_UPDATE_USER',
ADMIN_UPDATE_TEAM = 'ADMIN_UPDATE_TEAM',
@ -21,7 +20,13 @@ export enum AdminAuditEventEnum {
ADMIN_DELETE_PLUGIN = 'ADMIN_DELETE_PLUGIN',
ADMIN_CREATE_PLUGIN_GROUP = 'ADMIN_CREATE_PLUGIN_GROUP',
ADMIN_UPDATE_PLUGIN_GROUP = 'ADMIN_UPDATE_PLUGIN_GROUP',
ADMIN_DELETE_PLUGIN_GROUP = 'ADMIN_DELETE_PLUGIN_GROUP'
ADMIN_DELETE_PLUGIN_GROUP = 'ADMIN_DELETE_PLUGIN_GROUP',
// Inform
ADMIN_UPDATE_SYSTEM_MODAL = 'ADMIN_UPDATE_SYSTEM_MODAL',
ADMIN_SEND_SYSTEM_INFORM = 'ADMIN_SEND_SYSTEM_INFORM',
ADMIN_UPDATE_ACTIVITY_AD = 'ADMIN_UPDATE_ACTIVITY_AD',
ADMIN_UPDATE_OPERATIONAL_AD = 'ADMIN_UPDATE_OPERATIONAL_AD'
}
export enum AuditEventEnum {

View File

@ -1,7 +1,7 @@
import type { SourceMemberType } from '../user/type';
import type { AuditEventEnum } from './constants';
export type OperationLogSchema = {
export type TeamAuditSchemaType = {
_id: string;
tmbId: string;
teamId: string;
@ -10,7 +10,7 @@ export type OperationLogSchema = {
metadata?: Record<string, string>;
};
export type OperationListItemType = {
export type TeamAuditListItemType = {
_id: string;
sourceMember: SourceMemberType;
event: `${AuditEventEnum}`;

View File

@ -38,9 +38,7 @@ export type UserType = {
export const SourceMemberSchema = z.object({
name: z.string().meta({ example: '张三', description: '成员名称' }),
avatar: z
.string()
.meta({ example: 'https://cloud.fastgpt.cn/avatar.png', description: '成员头像' }),
avatar: z.string().nullish().meta({ description: '成员头像' }),
status: z
.enum(TeamMemberStatusEnum)
.meta({ example: TeamMemberStatusEnum.active, description: '成员状态' })

View File

@ -1,5 +1,4 @@
import type { StandardSubLevelEnum, SubModeEnum } from './constants';
import { TeamSubSchema } from './type.d';
export type StandardSubPlanParams = {
level: `${StandardSubLevelEnum}`;

View File

@ -1,103 +0,0 @@
import type { StandardSubLevelEnum, SubModeEnum, SubTypeEnum } from './constants';
// Content of plan
export type TeamStandardSubPlanItemType = {
name?: string;
desc?: string; // Plan description
price: number; // read price / month
pointPrice: number; // read price/ one thousand
totalPoints: number; // n
maxTeamMember: number;
maxAppAmount: number; // max app or plugin amount
maxDatasetAmount: number;
maxDatasetSize: number;
requestsPerMinute?: number;
appRegistrationCount?: number;
chatHistoryStoreDuration: number; // n day
websiteSyncPerDataset?: number;
auditLogStoreDuration?: number;
ticketResponseTime?: number;
customDomain?: number;
// Custom plan specific fields
priceDescription?: string;
customFormUrl?: string;
customDescriptions?: string[];
};
export type StandSubPlanLevelMapType = Record<
`${StandardSubLevelEnum}`,
TeamStandardSubPlanItemType
>;
export type PointsPackageItem = {
points: number;
month: number;
price: number;
};
export type SubPlanType = {
[SubTypeEnum.standard]?: StandSubPlanLevelMapType;
planDescriptionUrl?: string;
appRegistrationUrl?: string;
communitySupportTip?: string;
[SubTypeEnum.extraDatasetSize]: {
price: number;
};
[SubTypeEnum.extraPoints]: {
packages: PointsPackageItem[];
};
};
export type TeamSubSchema = {
_id: string;
teamId: string;
type: `${SubTypeEnum}`;
startTime: Date;
expiredTime: Date;
currentMode: `${SubModeEnum}`;
nextMode: `${SubModeEnum}`;
currentSubLevel: StandardSubLevelEnum;
nextSubLevel: StandardSubLevelEnum;
maxTeamMember?: number;
maxApp?: number;
maxDataset?: number;
// custom level configurations
requestsPerMinute?: number;
chatHistoryStoreDuration?: number;
maxDatasetSize?: number;
websiteSyncPerDataset?: number;
appRegistrationCount?: number;
auditLogStoreDuration?: number;
ticketResponseTime?: number;
customDomain?: number;
totalPoints: number;
surplusPoints: number;
currentExtraDatasetSize: number;
};
export type TeamPlanStatusType = {
[SubTypeEnum.standard]?: TeamSubSchema;
standardConstants?: TeamStandardSubPlanItemType;
totalPoints: number;
usedPoints: number;
// standard + extra
datasetMaxSize: number;
};
export type ClientTeamPlanStatusType = TeamPlanStatusType & {
usedMember: number;
usedAppAmount: number;
usedDatasetSize: number;
usedDatasetIndexSize: number;
usedRegistrationCount: number;
};

View File

@ -0,0 +1,111 @@
import z from 'zod';
import { StandardSubLevelEnum, SubModeEnum, SubTypeEnum } from './constants';
import { ObjectIdSchema } from '../../../common/type/mongo';
// Content of plan
export const TeamStandardSubPlanItemSchema = z.object({
name: z.string().optional(),
desc: z.string().optional(),
price: z.number(),
totalPoints: z.int(), // 总积分
maxTeamMember: z.int(),
maxAppAmount: z.int(),
maxDatasetAmount: z.int(),
maxDatasetSize: z.int(),
requestsPerMinute: z.int().optional(), // QPM
appRegistrationCount: z.int().optional(), // 应用备案数量
chatHistoryStoreDuration: z.int(), // 历史记录保留天数
websiteSyncPerDataset: z.int().optional(), // 站点同步最大页面
auditLogStoreDuration: z.int().optional(), // 审计日志保留天数
ticketResponseTime: z.int().optional(), // 工单支持时间
customDomain: z.int().optional(), // 自定义域名数量
// 定制套餐
priceDescription: z.string().optional(), // 价格描述
customFormUrl: z.string().optional(), // 自定义表单 URL
customDescriptions: z.array(z.string()).optional(), // 自定义描述
// Active
annualBonusPoints: z.int().optional(), // 年度赠送积分
// @deprecated
pointPrice: z.number().optional()
});
export type TeamStandardSubPlanItemType = z.infer<typeof TeamStandardSubPlanItemSchema>;
export const StandSubPlanLevelMapSchema = z.record(
z.enum(StandardSubLevelEnum),
TeamStandardSubPlanItemSchema
);
export type StandSubPlanLevelMapType = z.infer<typeof StandSubPlanLevelMapSchema>;
export const PointsPackageItemSchema = z.object({
points: z.int(),
month: z.int(),
price: z.number(),
activityBonusPoints: z.int().optional() // 活动赠送积分
});
export type PointsPackageItem = z.infer<typeof PointsPackageItemSchema>;
export const SubPlanSchema = z.object({
[SubTypeEnum.standard]: StandSubPlanLevelMapSchema.optional(),
[SubTypeEnum.extraDatasetSize]: z.object({ price: z.number() }).optional(),
[SubTypeEnum.extraPoints]: z.object({ packages: PointsPackageItemSchema.array() }).optional(),
planDescriptionUrl: z.string().optional(),
appRegistrationUrl: z.string().optional(),
communitySupportTip: z.string().optional(),
activityExpirationTime: z.date().optional()
});
export type SubPlanType = z.infer<typeof SubPlanSchema>;
export const TeamSubSchema = z.object({
_id: ObjectIdSchema,
teamId: ObjectIdSchema,
type: z.enum(SubTypeEnum),
startTime: z.date(),
expiredTime: z.date(),
currentMode: z.enum(SubModeEnum),
nextMode: z.enum(SubModeEnum),
currentSubLevel: z.enum(StandardSubLevelEnum),
nextSubLevel: z.enum(StandardSubLevelEnum),
maxTeamMember: z.int().optional(),
maxApp: z.int().optional(),
maxDataset: z.int().optional(),
totalPoints: z.int(),
annualBonusPoints: z.int().optional(),
surplusPoints: z.int(),
currentExtraDatasetSize: z.int(),
// 定制版特有属性
requestsPerMinute: z.int().optional(),
chatHistoryStoreDuration: z.int().optional(),
maxDatasetSize: z.int().optional(),
websiteSyncPerDataset: z.int().optional(),
appRegistrationCount: z.int().optional(),
auditLogStoreDuration: z.int().optional(),
ticketResponseTime: z.int().optional(),
customDomain: z.int().optional()
});
export type TeamSubSchemaType = z.infer<typeof TeamSubSchema>;
export const TeamPlanStatusSchema = z.object({
[SubTypeEnum.standard]: TeamSubSchema.optional(),
standardConstants: TeamStandardSubPlanItemSchema.optional(),
totalPoints: z.int(),
usedPoints: z.int(),
datasetMaxSize: z.int()
});
export type TeamPlanStatusType = z.infer<typeof TeamPlanStatusSchema>;
export const ClientTeamPlanStatusSchema = TeamPlanStatusSchema.extend({
usedMember: z.int(),
usedAppAmount: z.int(),
usedDatasetSize: z.int(),
usedDatasetIndexSize: z.int(),
usedRegistrationCount: z.int()
});
export type ClientTeamPlanStatusType = z.infer<typeof ClientTeamPlanStatusSchema>;

View File

@ -8,6 +8,7 @@ import {
} from 'bullmq';
import { addLog } from '../system/log';
import { newQueueRedisConnection, newWorkerRedisConnection } from '../redis';
import { delay } from '@fastgpt/global/common/system/utils';
const defaultWorkerOpts: Omit<ConnectionOptions, 'connection'> = {
removeOnComplete: {
@ -25,6 +26,7 @@ export enum QueueNames {
// Delete Queue
datasetDelete = 'datasetDelete',
appDelete = 'appDelete',
// @deprecated
websiteSync = 'websiteSync'
}
@ -58,7 +60,7 @@ export function getQueue<DataType, ReturnType = void>(
// default error handler, to avoid unhandled exceptions
newQueue.on('error', (error) => {
addLog.error(`MQ Queue [${name}]: ${error.message}`, error);
addLog.error(`MQ Queue] error`, error);
});
queues.set(name, newQueue);
return newQueue;
@ -74,18 +76,59 @@ export function getWorker<DataType, ReturnType = void>(
return worker as Worker<DataType, ReturnType>;
}
const newWorker = new Worker<DataType, ReturnType>(name.toString(), processor, {
connection: newWorkerRedisConnection(),
...defaultWorkerOpts,
...opts
});
// default error handler, to avoid unhandled exceptions
newWorker.on('error', (error) => {
addLog.error(`MQ Worker [${name}]: ${error.message}`, error);
});
newWorker.on('failed', (jobId, error) => {
addLog.error(`MQ Worker [${name}]: ${error.message}`, error);
});
const createWorker = () => {
const newWorker = new Worker<DataType, ReturnType>(name.toString(), processor, {
connection: newWorkerRedisConnection(),
...defaultWorkerOpts,
// BullMQ Worker important settings
lockDuration: 600000, // 10 minutes for large file operations
stalledInterval: 30000, // Check for stalled jobs every 30s
maxStalledCount: 3, // Move job to failed after 1 stall (default behavior)
...opts
});
// Worker is ready to process jobs (fired on initial connection and after reconnection)
newWorker.on('ready', () => {
addLog.info(`[MQ Worker] ready`, { name });
});
// default error handler, to avoid unhandled exceptions
newWorker.on('error', async (error) => {
addLog.error(`[MQ Worker] error`, {
message: error.message,
data: { name }
});
});
// Critical: Worker has been closed - remove from pool and restart
newWorker.on('closed', async () => {
addLog.warn(`[MQ Worker] closed, attempting restart...`);
// Clean up: remove all listeners to prevent memory leaks
newWorker.removeAllListeners();
// Retry create new worker with infinite retries
while (true) {
try {
// Call getWorker to create a new worker (now workers.get(name) returns undefined)
const worker = createWorker();
workers.set(name, worker);
addLog.info(`[MQ Worker] restarted successfully`);
break;
} catch (error) {
addLog.error(`[MQ Worker] failed to restart, retrying...`, error);
await delay(1000);
}
}
});
newWorker.on('paused', async () => {
addLog.warn(`[MQ Worker] paused`);
await delay(1000);
newWorker.resume();
});
return newWorker;
};
const newWorker = createWorker();
workers.set(name, newWorker);
return newWorker;
}

View File

@ -1,5 +1,5 @@
import './init';
import { getGlobalRedisConnection } from '../../common/redis';
import { getAllKeysByPrefix, getGlobalRedisConnection } from '../../common/redis';
import type { SystemCacheKeyEnum } from './type';
import { randomUUID } from 'node:crypto';
import { initCache } from './init';
@ -18,11 +18,14 @@ export const refreshVersionKey = async (key: `${SystemCacheKeyEnum}`, id?: strin
const val = randomUUID();
const versionKey = id ? `${cachePrefix}${key}:${id}` : `${cachePrefix}${key}`;
if (id === '*') {
const pattern = `${cachePrefix}${key}:*`;
const keys = await redis.keys(pattern);
const pattern = `${cachePrefix}${key}`;
const keys = await getAllKeysByPrefix(pattern);
if (keys.length > 0) {
await redis.del(keys);
const pipeline = redis.pipeline();
pipeline.del(keys);
await pipeline.exec();
}
} else {
await redis.set(versionKey, val);

View File

@ -2,13 +2,12 @@ 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';
import { guessBase64ImageType } from '../utils';
import { guessBase64ImageType } from './utils';
import { readFromSecondary } from '../../mongo/utils';
import { addHours } from 'date-fns';
import { imageFileType } from '@fastgpt/global/common/file/constants';
import { retryFn } from '@fastgpt/global/common/system/utils';
import { UserError } from '@fastgpt/global/common/error/utils';
import { S3Sources } from '../../s3/type';
import { getS3AvatarSource } from '../../s3/sources/avatar';
import { isS3ObjectKey } from '../../s3/utils';
import path from 'path';

View File

@ -1,8 +1,98 @@
import axios from 'axios';
import { addLog } from '../../system/log';
import { serverRequestBaseUrl } from '../../api/serverRequest';
import { getFileContentTypeFromHeader, guessBase64ImageType } from '../utils';
import { retryFn } from '@fastgpt/global/common/system/utils';
import { getContentTypeFromHeader } from '../utils';
// 图片格式魔数映射表
const IMAGE_SIGNATURES: { type: string; magic: number[]; check?: (buffer: Buffer) => boolean }[] = [
{ type: 'image/jpeg', magic: [0xff, 0xd8, 0xff] },
{ type: 'image/png', magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
{ type: 'image/gif', magic: [0x47, 0x49, 0x46, 0x38] },
{
type: 'image/webp',
magic: [0x52, 0x49, 0x46, 0x46],
check: (buffer) => buffer.length >= 12 && buffer.slice(8, 12).toString('ascii') === 'WEBP'
},
{ type: 'image/bmp', magic: [0x42, 0x4d] },
{ type: 'image/tiff', magic: [0x49, 0x49, 0x2a, 0x00] },
{ type: 'image/tiff', magic: [0x4d, 0x4d, 0x00, 0x2a] },
{ type: 'image/svg+xml', magic: [0x3c, 0x73, 0x76, 0x67] },
{ type: 'image/x-icon', magic: [0x00, 0x00, 0x01, 0x00] }
];
// 有效的图片 MIME 类型
const VALID_IMAGE_TYPES = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
'image/svg+xml',
'image/tiff',
'image/x-icon',
'image/vnd.microsoft.icon',
'image/ico',
'image/heic',
'image/heif',
'image/avif'
]);
// Base64 首字符到图片类型的映射
const BASE64_PREFIX_MAP: Record<string, string> = {
'/': 'image/jpeg',
i: 'image/png',
R: 'image/gif',
U: 'image/webp',
Q: 'image/bmp',
P: 'image/svg+xml',
T: 'image/tiff',
J: 'image/jp2',
S: 'image/x-tga',
I: 'image/ief',
V: 'image/vnd.microsoft.icon',
W: 'image/vnd.wap.wbmp',
X: 'image/x-xbitmap',
Z: 'image/x-xpixmap',
Y: 'image/x-xwindowdump'
};
const DEFAULT_IMAGE_TYPE = 'image/jpeg';
export const isValidImageContentType = (contentType: string): boolean => {
if (!contentType) return false;
return VALID_IMAGE_TYPES.has(contentType);
};
export const detectImageTypeFromBuffer = (buffer: Buffer): string | undefined => {
if (!buffer || buffer.length === 0) return;
for (const { type, magic, check } of IMAGE_SIGNATURES) {
if (buffer.length < magic.length) continue;
const matches = magic.every((byte, index) => buffer[index] === byte);
if (matches && (!check || check(buffer))) {
return type;
}
}
return;
};
export const guessBase64ImageType = (str: string): string => {
if (!str || typeof str !== 'string') return DEFAULT_IMAGE_TYPE;
// 尝试从 base64 解码并检测文件头
try {
const buffer = Buffer.from(str, 'base64');
const detectedType = detectImageTypeFromBuffer(buffer);
if (detectedType) return detectedType;
} catch {}
// 回退到首字符映射
return BASE64_PREFIX_MAP[str.charAt(0)] || DEFAULT_IMAGE_TYPE;
};
export const getImageBase64 = async (url: string) => {
addLog.debug(`Load image to base64: ${url}`);
@ -16,10 +106,26 @@ export const getImageBase64 = async (url: string) => {
})
);
const base64 = Buffer.from(response.data, 'binary').toString('base64');
const imageType =
getFileContentTypeFromHeader(response.headers['content-type']) ||
guessBase64ImageType(base64);
const buffer = Buffer.from(response.data);
const base64 = buffer.toString('base64');
const headerContentType = getContentTypeFromHeader(response.headers['content-type']);
// 检测图片类型的优先级策略
const imageType = (() => {
// 1. 如果 Header 是有效的图片类型,直接使用
if (headerContentType && isValidImageContentType(headerContentType)) {
return headerContentType;
}
// 2. 使用文件头检测(适用于通用二进制类型或无效类型)
const detectedType = detectImageTypeFromBuffer(buffer);
if (detectedType) {
return detectedType;
}
// 3. 回退到 base64 推断
return guessBase64ImageType(base64);
})();
return {
completeBase64: `data:${imageType};base64,${base64}`,

View File

@ -17,37 +17,8 @@ export const removeFilesByPaths = (paths: string[]) => {
});
};
export const guessBase64ImageType = (str: string) => {
const imageTypeMap: Record<string, string> = {
'/': 'image/jpeg',
i: 'image/png',
R: 'image/gif',
U: 'image/webp',
Q: 'image/bmp',
P: 'image/svg+xml',
T: 'image/tiff',
J: 'image/jp2',
S: 'image/x-tga',
I: 'image/ief',
V: 'image/vnd.microsoft.icon',
W: 'image/vnd.wap.wbmp',
X: 'image/x-xbitmap',
Z: 'image/x-xpixmap',
Y: 'image/x-xwindowdump'
};
const defaultType = 'image/jpeg';
if (typeof str !== 'string' || str.length === 0) {
return defaultType;
}
const firstChar = str.charAt(0);
return imageTypeMap[firstChar] || defaultType;
};
export const getFileContentTypeFromHeader = (header: string): string | undefined => {
const contentType = header.split(';')[0];
return contentType;
export const getContentTypeFromHeader = (header: string): string | undefined => {
return header?.toLowerCase()?.split(';')?.[0]?.trim();
};
export const clearDirFiles = (dirPath: string) => {

View File

@ -54,10 +54,8 @@ export const NextEntry = ({
if (error instanceof ZodError) {
return jsonRes(res, {
code: 400,
error: {
message: 'Validation error',
details: error.message
},
message: 'Data validation error',
error,
url: req.url
});
}

View File

@ -158,6 +158,8 @@ export const pushTrack = {
}
});
},
// Admin cron job tracks
subscriptionDeleted: (data: {
teamId: string;
subscriptionType: string;
@ -185,5 +187,23 @@ export const pushTrack = {
expiredTime: data.expiredTime
}
});
},
auditLogCleanup: (data: { teamId: string; retentionDays: number }) => {
return createTrack({
event: TrackEnum.auditLogCleanup,
data: {
teamId: data.teamId,
retentionDays: data.retentionDays
}
});
},
chatHistoryCleanup: (data: { teamId: string; retentionDays: number }) => {
return createTrack({
event: TrackEnum.chatHistoryCleanup,
data: {
teamId: data.teamId,
retentionDays: data.retentionDays
}
});
}
};

View File

@ -31,26 +31,13 @@ export async function connectMongo(props: {
db.set('strictQuery', 'throw');
db.connection.on('error', async (error) => {
console.log('mongo error', error);
try {
if (db.connection.readyState !== 0) {
RemoveListeners();
await db.disconnect();
await delay(1000);
await connectMongo(props);
}
} catch (error) {}
console.error('mongo error', error);
});
db.connection.on('connected', async () => {
console.log('mongo connected');
});
db.connection.on('disconnected', async () => {
console.log('mongo disconnected');
try {
if (db.connection.readyState !== 0) {
RemoveListeners();
await db.disconnect();
await delay(1000);
await connectMongo(props);
}
} catch (error) {}
console.error('mongo disconnected');
});
await db.connect(url, {
@ -64,9 +51,9 @@ export async function connectMongo(props: {
maxIdleTimeMS: 300000, // 空闲连接超时: 5分钟,防止空闲连接长时间占用资源
retryWrites: true, // 重试写入: 重试写入失败的操作
retryReads: true, // 重试读取: 重试读取失败的操作
serverSelectionTimeoutMS: 10000 // 服务器选择超时: 10秒,防止副本集故障时长时间阻塞
serverSelectionTimeoutMS: 10000, // 服务器选择超时: 10秒,防止副本集故障时长时间阻塞
heartbeatFrequencyMS: 5000 // 5s 进行一次健康检查
});
console.log('mongo connected');
connectedCb?.();

View File

@ -3,27 +3,51 @@ import Redis from 'ioredis';
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
// Base Redis options for connection reliability
const REDIS_BASE_OPTION = {
// Retry strategy: exponential backoff with unlimited retries for stability
retryStrategy: (times: number) => {
// Never give up retrying to ensure worker keeps running
const delay = Math.min(times * 50, 2000); // Max 2s between retries
if (times > 10) {
addLog.error(`[Redis connection failed] attempt ${times}, will keep retrying...`);
} else {
addLog.warn(`Redis reconnecting... attempt ${times}, delay ${delay}ms`);
}
return delay; // Always return a delay to keep retrying
},
// Reconnect on specific errors (Redis master-slave switch, network issues)
reconnectOnError: (err: any) => {
const reconnectErrors = ['READONLY', 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET'];
const message = typeof err?.message === 'string' ? err.message : String(err ?? '');
const shouldReconnect = reconnectErrors.some((errType) => message.includes(errType));
if (shouldReconnect) {
addLog.warn(`Redis reconnecting due to error: ${message}`);
}
return shouldReconnect;
},
// Connection timeout
connectTimeout: 10000, // 10 seconds
// Enable offline queue to buffer commands when disconnected
enableOfflineQueue: true
};
export const newQueueRedisConnection = () => {
const redis = new Redis(REDIS_URL);
redis.on('connect', () => {
console.log('Redis connected');
});
redis.on('error', (error) => {
console.error('Redis connection error', error);
const redis = new Redis(REDIS_URL, {
...REDIS_BASE_OPTION,
// Limit retries for queue operations
maxRetriesPerRequest: 3
});
return redis;
};
export const newWorkerRedisConnection = () => {
const redis = new Redis(REDIS_URL, {
...REDIS_BASE_OPTION,
// BullMQ requires maxRetriesPerRequest: null for blocking operations
maxRetriesPerRequest: null
});
redis.on('connect', () => {
console.log('Redis connected');
});
redis.on('error', (error) => {
console.error('Redis connection error', error);
});
return redis;
};
@ -31,22 +55,44 @@ export const FASTGPT_REDIS_PREFIX = 'fastgpt:';
export const getGlobalRedisConnection = () => {
if (global.redisClient) return global.redisClient;
global.redisClient = new Redis(REDIS_URL, { keyPrefix: FASTGPT_REDIS_PREFIX });
global.redisClient = new Redis(REDIS_URL, {
...REDIS_BASE_OPTION,
keyPrefix: FASTGPT_REDIS_PREFIX,
maxRetriesPerRequest: 3
});
global.redisClient.on('connect', () => {
addLog.info('Redis connected');
addLog.info('[Global Redis] connected');
});
global.redisClient.on('error', (error) => {
addLog.error('Redis connection error', error);
addLog.error('[Global Redis] connection error', error);
});
global.redisClient.on('close', () => {
addLog.warn('[Global Redis] connection closed');
});
return global.redisClient;
};
export const getAllKeysByPrefix = async (key: string) => {
if (!key) return [];
const redis = getGlobalRedisConnection();
const keys = (await redis.keys(`${FASTGPT_REDIS_PREFIX}${key}:*`)).map((key) =>
key.replace(FASTGPT_REDIS_PREFIX, '')
);
return keys;
const prefix = FASTGPT_REDIS_PREFIX;
const pattern = `${prefix}${key}:*`;
let cursor = '0';
const batchSize = 1000; // SCAN 每次取多少
const results: string[] = [];
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', batchSize);
cursor = nextCursor;
for (const k of keys) {
results.push(k.replace(FASTGPT_REDIS_PREFIX, ''));
}
} while (cursor !== '0');
return results;
};

View File

@ -5,6 +5,7 @@ import { addLog } from '../system/log';
import { replaceSensitiveText } from '@fastgpt/global/common/string/tools';
import { UserError } from '@fastgpt/global/common/error/utils';
import { clearCookie } from '../../support/permission/auth/common';
import { ZodError } from 'zod';
export interface ResponseType<T = any> {
code: number;
@ -16,8 +17,9 @@ export interface ProcessedError {
code: number;
statusText: string;
message: string;
data?: any;
shouldClearCookie: boolean;
data?: any;
zodError?: any;
}
/**
@ -31,6 +33,7 @@ export function processError(params: {
defaultCode?: number;
}): ProcessedError {
const { error, url, defaultCode = 500 } = params;
let zodError;
const errResponseKey = typeof error === 'string' ? error : error?.message;
@ -65,6 +68,16 @@ export function processError(params: {
// 3. 根据错误类型记录不同级别的日志
if (error instanceof UserError) {
addLog.info(`Request error: ${url}, ${msg}`);
} else if (error instanceof ZodError) {
zodError = (() => {
try {
return JSON.parse(error.message);
} catch (error) {}
})();
addLog.error(`[Zod] Error in ${url}`, {
data: zodError
});
msg = error.message;
} else {
addLog.error(`System unexpected error: ${url}, ${msg}`, error);
}
@ -74,7 +87,8 @@ export function processError(params: {
code: defaultCode,
statusText: 'error',
message: replaceSensitiveText(msg),
shouldClearCookie: false
shouldClearCookie: false,
zodError
};
}
@ -103,7 +117,8 @@ export const jsonRes = <T = any>(
code: processedError.code,
statusText: processedError.statusText,
message: message || processedError.message,
data: processedError.data !== undefined ? processedError.data : null
data: processedError.data !== undefined ? processedError.data : null,
zodError: processedError.zodError
});
return;

View File

@ -1,4 +1,11 @@
import { Client, type RemoveOptions, type CopyConditions, S3Error } from 'minio';
import {
Client,
type RemoveOptions,
type CopyConditions,
S3Error,
InvalidObjectNameError,
InvalidXMLError
} from 'minio';
import {
type CreatePostPresignedUrlOptions,
type CreatePostPresignedUrlParams,
@ -17,6 +24,26 @@ import { type Readable } from 'node:stream';
import { type UploadFileByBufferParams, UploadFileByBufferSchema } from '../type';
import { parseFileExtensionFromUrl } from '@fastgpt/global/common/string/tools';
// Check if the error is a "file not found" type error, which should be treated as success
export const isFileNotFoundError = (error: any): boolean => {
if (error instanceof S3Error) {
// Handle various "not found" error codes
return (
error.code === 'NoSuchKey' ||
error.code === 'InvalidObjectName' ||
error.message === 'Not Found' ||
error.message ===
'The request signature we calculated does not match the signature you provided. Check your key and signing method.' ||
error.message.includes('Resource name contains bad components') ||
error.message.includes('Object name contains unsupported characters.')
);
}
if (error instanceof InvalidObjectNameError || error instanceof InvalidXMLError) {
return true;
}
return false;
};
export class S3BaseBucket {
private _client: Client;
private _externalClient: Client | undefined;
@ -94,7 +121,7 @@ export class S3BaseBucket {
temporary: false
}
});
await this.delete(from);
await this.removeObject(from);
}
async copy({
@ -120,24 +147,17 @@ export class S3BaseBucket {
return this.client.copyObject(bucket, to, `${bucket}/${from}`, options?.copyConditions);
}
async delete(objectKey: string, options?: RemoveOptions): Promise<void> {
try {
if (!objectKey) return Promise.resolve();
// 把连带的 parsed 数据一起删除
const fileParsedPrefix = `${path.dirname(objectKey)}/${path.basename(objectKey, path.extname(objectKey))}-parsed`;
await this.addDeleteJob({ prefix: fileParsedPrefix });
return await this.client.removeObject(this.bucketName, objectKey, options);
} catch (error) {
if (error instanceof S3Error) {
if (error.code === 'InvalidObjectName') {
addLog.warn(`${this.bucketName} delete object not found: ${objectKey}`, error);
return Promise.resolve();
}
async removeObject(objectKey: string, options?: RemoveOptions): Promise<void> {
return this.client.removeObject(this.bucketName, objectKey, options).catch((err) => {
if (isFileNotFoundError(err)) {
return Promise.resolve();
}
return Promise.reject(error);
}
addLog.error(`[S3 delete error]`, {
message: err.message,
data: { code: err.code, key: objectKey }
});
throw err;
});
}
// 列出文件

View File

@ -27,26 +27,7 @@ export async function clearExpiredMinioFiles() {
const bucket = global.s3BucketMap[bucketName];
if (bucket) {
await bucket.delete(file.minioKey);
if (!file.minioKey.includes('-parsed/')) {
try {
const dir = path.dirname(file.minioKey);
const basename = path.basename(file.minioKey);
const ext = path.extname(basename);
if (ext) {
const nameWithoutExt = path.basename(basename, ext);
const parsedPrefix = `${dir}/${nameWithoutExt}-parsed`;
await bucket.addDeleteJob({ prefix: parsedPrefix });
addLog.info(`Scheduled deletion of parsed images: ${parsedPrefix}`);
}
} catch (error) {
addLog.debug(`Failed to schedule parsed images deletion for ${file.minioKey}`);
}
}
await bucket.addDeleteJob({ key: file.minioKey });
await MongoS3TTL.deleteOne({ _id: file._id });
success++;
@ -57,12 +38,6 @@ export async function clearExpiredMinioFiles() {
addLog.warn(`Bucket not found: ${file.bucketName}`);
}
} catch (error) {
if (
error instanceof S3Error &&
error.message.includes('Object name contains unsupported characters.')
) {
await MongoS3TTL.deleteOne({ _id: file._id });
}
fail++;
addLog.error(`Failed to delete minio file: ${file.minioKey}`, error);
}

View File

@ -1,6 +1,8 @@
import { getQueue, getWorker, QueueNames } from '../bullmq';
import pLimit from 'p-limit';
import { retryFn } from '@fastgpt/global/common/system/utils';
import { addLog } from '../system/log';
import path from 'path';
import { batchRun } from '@fastgpt/global/common/system/utils';
import { isFileNotFoundError, type S3BaseBucket } from './buckets/base';
export type S3MQJobData = {
key?: string;
@ -9,81 +11,131 @@ export type S3MQJobData = {
bucketName: string;
};
const jobOption = {
attempts: 10,
removeOnFail: {
count: 10000, // 保留10000个失败任务
age: 14 * 24 * 60 * 60 // 14 days
},
removeOnComplete: true,
backoff: {
delay: 2000,
type: 'exponential'
}
};
export const addS3DelJob = async (data: S3MQJobData): Promise<void> => {
const queue = getQueue<S3MQJobData>(QueueNames.s3FileDelete);
await queue.add(
'delete-s3-files',
{ ...data },
{
attempts: 3,
removeOnFail: false,
removeOnComplete: true,
backoff: {
delay: 2000,
type: 'exponential'
}
const jobId = (() => {
if (data.key) {
return data.key;
}
);
if (data.keys) {
return undefined;
}
if (data.prefix) {
return data.prefix;
}
throw new Error('Invalid s3 delete job data');
})();
await queue.add('delete-s3-files', data, { jobId, ...jobOption });
};
export const prefixDel = async (bucket: S3BaseBucket, prefix: string) => {
addLog.debug(`[S3 delete] delete prefix: ${prefix}`);
let tasks: Promise<any>[] = [];
return new Promise<void>((resolve, reject) => {
let timer: NodeJS.Timeout;
const stream = bucket.listObjectsV2(prefix, true);
let settled = false;
const finish = (error?: any) => {
if (settled) return;
settled = true;
if (timer) {
clearTimeout(timer);
}
stream?.removeAllListeners?.();
stream?.destroy?.();
if (error) {
addLog.error(`[S3 delete] delete prefix failed`, error);
reject(error);
} else {
resolve();
}
};
// stream 可能会中断,没有触发 end 和 error导致 promise 不返回,需要增加定时器兜底。
timer = setTimeout(() => {
addLog.error(`[S3 delete] delete prefix timeout: ${prefix}`);
finish('Timeout');
}, 60000);
stream.on('data', (file) => {
if (!file.name) return;
tasks.push(bucket.removeObject(file.name));
});
stream.on('end', async () => {
if (tasks.length === 0) {
return finish();
}
if (timer) {
clearTimeout(timer);
}
const results = await Promise.allSettled(tasks);
const failed = results.some((r) => r.status === 'rejected');
if (failed) {
return finish('Some deletes failed');
}
finish();
});
stream.on('error', (err) => {
if (isFileNotFoundError(err)) {
return finish();
}
addLog.error(`[S3 delete] delete prefix: ${prefix} error`, err);
return finish(err);
});
stream.on('pause', () => {
addLog.warn(`[S3 delete] delete prefix: ${prefix} paused`);
stream.resume();
});
});
};
export const startS3DelWorker = async () => {
return getWorker<S3MQJobData>(
QueueNames.s3FileDelete,
async (job) => {
const { prefix, bucketName, key, keys } = job.data;
const limit = pLimit(10);
const bucket = s3BucketMap[bucketName];
let { prefix, bucketName, key, keys } = job.data;
const bucket = global.s3BucketMap[bucketName];
if (!bucket) {
return Promise.reject(`Bucket not found: ${bucketName}`);
addLog.error(`Bucket not found: ${bucketName}`);
return;
}
if (key) {
await bucket.delete(key);
keys = [key];
}
if (keys) {
const tasks: Promise<void>[] = [];
for (const key of keys) {
const p = limit(() => retryFn(() => bucket.delete(key)));
tasks.push(p);
}
await Promise.all(tasks);
addLog.debug(`[S3 delete] delete keys: ${keys.length}`);
await batchRun(keys, async (key) => {
await bucket.removeObject(key);
// Delete parsed
if (!key.includes('-parsed/')) {
const fileParsedPrefix = `${path.dirname(key)}/${path.basename(key, path.extname(key))}-parsed`;
await prefixDel(bucket, fileParsedPrefix);
}
});
}
if (prefix) {
const tasks: Promise<void>[] = [];
return new Promise<void>(async (resolve, reject) => {
const stream = bucket.listObjectsV2(prefix, true);
stream.on('data', async (file) => {
if (!file.name) return;
const p = limit(() =>
// 因为封装的 delete 方法里,包含前缀删除,这里不能再使用,避免循环。
retryFn(() => bucket.client.removeObject(bucket.bucketName, file.name))
);
tasks.push(p);
});
stream.on('end', async () => {
try {
const results = await Promise.allSettled(tasks);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length > 0) {
reject('Some deletes failed');
}
resolve();
} catch (err) {
reject(err);
}
});
stream.on('error', (err) => {
console.error('listObjects stream error', err);
reject(err);
});
});
await prefixDel(bucket, prefix);
}
},
{
concurrency: 1
concurrency: 6
}
);
};

View File

@ -42,7 +42,7 @@ class S3AvatarSource extends S3PublicBucket {
async deleteAvatar(avatar: string, session?: ClientSession): Promise<void> {
const key = avatar.slice(this.prefix.length);
await MongoS3TTL.deleteOne({ minioKey: key, bucketName: this.bucketName }, session);
await this.delete(key);
await this.removeObject(key);
}
async refreshAvatar(newAvatar?: string, oldAvatar?: string, session?: ClientSession) {

View File

@ -4,6 +4,7 @@ import { LogLevelEnum } from './log/constant';
import { connectionMongo } from '../mongo/index';
import { getMongoLog } from './log/schema';
import { getLogger } from '../otel/log';
import { getErrText } from '@fastgpt/global/common/error/utils';
export enum EventTypeEnum {
outLinkBot = '[Outlink bot]',
@ -151,7 +152,7 @@ export const addLog = {
error(msg: string, error?: any) {
this.log(LogLevelEnum.error, msg, {
...(error?.data && { data: error?.data }),
message: error?.message || error,
message: getErrText(error),
stack: error?.stack,
...(error?.config && {
config: {

View File

@ -176,7 +176,8 @@ class PgClass {
const time = Date.now() - start;
if (time > 300) {
addLog.warn(`pg query time: ${time}ms, sql: ${sql}`);
const safeSql = sql.replace(/'\[[^\]]*?\]'/g, "'[x]'");
addLog.warn(`pg query time: ${time}ms, sql: ${safeSql}`);
}
return res;

View File

@ -120,38 +120,40 @@ export const loadSystemModels = async (init = false, language = 'en') => {
]);
// Load system model from local
await Promise.all(
systemModels.map(async (model) => {
const mergeObject = (obj1: any, obj2: any) => {
if (!obj1 && !obj2) return undefined;
const formatObj1 = typeof obj1 === 'object' ? obj1 : {};
const formatObj2 = typeof obj2 === 'object' ? obj2 : {};
return { ...formatObj1, ...formatObj2 };
};
systemModels.forEach((model) => {
const mergeObject = (obj1: any, obj2: any) => {
if (!obj1 && !obj2) return undefined;
const formatObj1 = typeof obj1 === 'object' ? obj1 : {};
const formatObj2 = typeof obj2 === 'object' ? obj2 : {};
return { ...formatObj1, ...formatObj2 };
};
const dbModel = dbModels.find((item) => item.model === model.model);
const provider = getModelProvider(dbModel?.metadata?.provider || model.provider, language);
const dbModel = dbModels.find((item) => item.model === model.model);
const provider = getModelProvider(dbModel?.metadata?.provider || model.provider, language);
const modelData: any = {
...model,
...dbModel?.metadata,
provider: provider.id,
avatar: provider.avatar,
type: dbModel?.metadata?.type || model.type,
isCustom: false,
const modelData: any = {
...model,
...dbModel?.metadata,
provider: provider.id,
avatar: provider.avatar,
type: dbModel?.metadata?.type || model.type,
isCustom: false,
...(model.type === ModelTypeEnum.llm && dbModel?.metadata?.type === ModelTypeEnum.llm
? {
maxResponse: dbModel?.metadata?.maxResponse ?? model.maxTokens ?? 1000,
defaultConfig: mergeObject(model.defaultConfig, dbModel?.metadata?.defaultConfig),
fieldMap: mergeObject(model.fieldMap, dbModel?.metadata?.fieldMap),
maxTokens: undefined
}
: {})
};
pushModel(modelData);
})
);
...(model.type === ModelTypeEnum.llm && {
maxResponse: model.maxTokens || 4000
}),
...(model.type === ModelTypeEnum.llm && dbModel?.metadata?.type === ModelTypeEnum.llm
? {
maxResponse: dbModel?.metadata?.maxResponse ?? model.maxTokens ?? 4000,
defaultConfig: mergeObject(model.defaultConfig, dbModel?.metadata?.defaultConfig),
fieldMap: mergeObject(model.fieldMap, dbModel?.metadata?.fieldMap),
maxTokens: undefined
}
: {})
};
pushModel(modelData);
});
// Custom model(Not in system config)
dbModels.forEach((dbModel) => {
@ -236,8 +238,7 @@ export const loadSystemModels = async (init = false, language = 'en') => {
);
} catch (error) {
console.error('Load models error', error);
// @ts-ignore
global.systemModelList = undefined;
return Promise.reject(error);
}
};

View File

@ -3,6 +3,8 @@ import { getAIApi } from '../config';
import { countPromptTokens } from '../../../common/string/tiktoken/index';
import { EmbeddingTypeEnm } from '@fastgpt/global/core/ai/constants';
import { addLog } from '../../../common/system/log';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { retryFn } from '@fastgpt/global/common/system/utils';
type GetVectorProps = {
model: EmbeddingModelItemType;
@ -38,55 +40,70 @@ export async function getVectorsByText({ model, input, type, headers }: GetVecto
for (const chunk of chunks) {
// input text to vector
const result = await ai.embeddings
.create(
{
...model.defaultConfig,
...(type === EmbeddingTypeEnm.db && model.dbConfig),
...(type === EmbeddingTypeEnm.query && model.queryConfig),
model: model.model,
input: chunk
},
model.requestUrl
? {
path: model.requestUrl,
headers: {
...(model.requestAuth ? { Authorization: `Bearer ${model.requestAuth}` } : {}),
...headers
const result = await retryFn(() =>
ai.embeddings
.create(
{
...model.defaultConfig,
...(type === EmbeddingTypeEnm.db && model.dbConfig),
...(type === EmbeddingTypeEnm.query && model.queryConfig),
model: model.model,
input: chunk
},
model.requestUrl
? {
path: model.requestUrl,
headers: {
...(model.requestAuth ? { Authorization: `Bearer ${model.requestAuth}` } : {}),
...headers
}
}
}
: { headers }
)
.then(async (res) => {
if (!res.data) {
addLog.error('[Embedding] API is not responding', res);
return Promise.reject('Embedding API is not responding');
}
if (!res?.data?.[0]?.embedding) {
// @ts-ignore
const msg = res.data?.err?.message || 'Embedding API Error';
addLog.error('[Embedding] API Error', {
message: msg,
data: res
});
return Promise.reject(msg);
}
: { headers }
)
.then(async (res) => {
if (!res.data) {
addLog.error('[Embedding Error] not responding', {
message: '',
data: {
response: res,
model: model.model,
inputLength: chunk.length
}
});
return Promise.reject('Embedding API is not responding');
}
if (!res?.data?.[0]?.embedding) {
// @ts-ignore
const msg = res.data?.err?.message || '';
addLog.error('[Embedding Error]', {
message: msg,
data: {
response: res,
model: model.model,
inputLength: chunk.length
}
});
return Promise.reject('Embedding API is not responding');
}
const [tokens, vectors] = await Promise.all([
(async () => {
if (res.usage) return res.usage.total_tokens;
const [tokens, vectors] = await Promise.all([
(async () => {
if (res.usage) return res.usage.total_tokens;
const tokens = await Promise.all(chunk.map((item) => countPromptTokens(item)));
return tokens.reduce((sum, item) => sum + item, 0);
})(),
Promise.all(res.data.map((item) => formatVectors(item.embedding, model.normalization)))
]);
const tokens = await Promise.all(chunk.map((item) => countPromptTokens(item)));
return tokens.reduce((sum, item) => sum + item, 0);
})(),
Promise.all(
res.data.map((item) => formatVectors(item.embedding, model.normalization))
)
]);
return {
tokens,
vectors
};
});
return {
tokens,
vectors
};
})
);
totalTokens += result.tokens;
allVectors.push(...result.vectors);
@ -97,7 +114,13 @@ export async function getVectorsByText({ model, input, type, headers }: GetVecto
vectors: allVectors
};
} catch (error) {
addLog.error(`[Embedding] request error`, error);
addLog.error(`[Embedding Error]`, {
message: getErrText(error),
data: {
model: model.model,
inputLengths: formatInput.map((item) => item.length)
}
});
return Promise.reject(error);
}

View File

@ -628,12 +628,17 @@ const createChatCompletion = async ({
('iterator' in response || 'controller' in response);
const getEmptyResponseTip = () => {
addLog.warn(`LLM response empty`, {
baseUrl: userKey?.baseUrl,
requestBody: body
});
if (userKey?.baseUrl) {
addLog.warn(`User LLM response empty`, {
baseUrl: userKey?.baseUrl,
requestBody: body
});
return `您的 OpenAI key 没有响应: ${JSON.stringify(body)}`;
} else {
addLog.error(`LLM response empty`, {
message: '',
data: body
});
}
return i18nT('chat:LLM_model_response_empty');
};
@ -652,13 +657,18 @@ const createChatCompletion = async ({
getEmptyResponseTip
};
} catch (error) {
addLog.error(`LLM response error`, error);
addLog.warn(`LLM response error`, {
baseUrl: userKey?.baseUrl,
requestBody: body
});
if (userKey?.baseUrl) {
addLog.warn(`User ai api error`, {
message: getErrText(error),
baseUrl: userKey?.baseUrl,
data: body
});
return Promise.reject(`您的 OpenAI key 出错了: ${getErrText(error)}`);
} else {
addLog.error(`LLM response error`, {
message: getErrText(error),
data: body
});
}
return Promise.reject(error);
}

View File

@ -35,7 +35,7 @@ export function reRankRecall({
headers?: Record<string, string>;
}): Promise<ReRankCallResult> {
if (!model) {
return Promise.reject('[Rerank] No rerank model');
return Promise.reject('No rerank model');
}
if (documents.length === 0) {
return Promise.resolve({
@ -67,7 +67,7 @@ export function reRankRecall({
addLog.info('ReRank finish:', { time: Date.now() - start });
if (!data?.results || data?.results?.length === 0) {
addLog.error('[Rerank] Empty result', { data });
addLog.error('[Rerank Error]', { message: 'Empty result', data });
}
return {
@ -81,7 +81,7 @@ export function reRankRecall({
};
})
.catch((err) => {
addLog.error('[Rerank] request error', err);
addLog.error('[Rerank Error]', err);
return Promise.reject(err);
});

View File

@ -4,12 +4,10 @@ import {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { AppFolderTypeList } from '@fastgpt/global/core/app/constants';
import { MongoApp } from './schema';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { encryptSecretValue, storeSecretValue } from '../../common/secret/utils';
import { SystemToolSecretInputTypeEnum } from '@fastgpt/global/core/app/tool/systemTool/constants';
import { type ClientSession } from '../../common/mongo';
import { MongoEvaluation } from './evaluation/evalSchema';
import { removeEvaluationJob } from './evaluation/mq';
import { MongoChatItem } from '../chat/chatItemSchema';
@ -23,10 +21,15 @@ import { MongoChatSetting } from '../chat/setting/schema';
import { MongoResourcePermission } from '../../support/permission/schema';
import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant';
import { removeImageByPath } from '../../common/file/image/controller';
import { mongoSessionRun } from '../../common/mongo/sessionRun';
import { MongoAppLogKeys } from './logs/logkeysSchema';
import { MongoChatItemResponse } from '../chat/chatItemResponseSchema';
import { getS3ChatSource } from '../../common/s3/sources/chat';
import { MongoAppChatLog } from './logs/chatLogsSchema';
import { MongoAppRegistration } from '../../support/appRegistration/schema';
import { MongoMcpKey } from '../../support/mcp/schema';
import { MongoAppRecord } from './record/schema';
import { mongoSessionRun } from '../../common/mongo/sessionRun';
import { addLog } from '../../common/system/log';
export const beforeUpdateAppFormat = ({ nodes }: { nodes?: StoreNodeItemType[] }) => {
if (!nodes) return;
@ -136,113 +139,96 @@ export const getAppBasicInfoByIds = async ({ teamId, ids }: { teamId: string; id
}));
};
export const onDelOneApp = async ({
export const deleteAppDataProcessor = async ({
app,
teamId
}: {
app: AppSchema;
teamId: string;
}) => {
const appId = String(app._id);
// 1. 删除应用头像
await removeImageByPath(app.avatar);
// 2. 删除聊天记录和S3文件
await getS3ChatSource().deleteChatFilesByPrefix({ appId });
await MongoAppChatLog.deleteMany({ teamId, appId });
await MongoChatItemResponse.deleteMany({ appId });
await MongoChatItem.deleteMany({ appId });
await MongoChat.deleteMany({ appId });
// 3. 删除应用相关数据(使用事务)
{
// 删除分享链接
await MongoOutLink.deleteMany({ appId });
// 删除 OpenAPI 配置
await MongoOpenApi.deleteMany({ appId });
// 删除应用版本
await MongoAppVersion.deleteMany({ appId });
// 删除聊天输入引导
await MongoChatInputGuide.deleteMany({ appId });
// 删除精选应用记录
await MongoChatFavouriteApp.deleteMany({ teamId, appId });
// 从快捷应用中移除对应应用
await MongoChatSetting.updateMany({ teamId }, { $pull: { quickAppIds: { $in: [appId] } } });
// 删除权限记录
await MongoResourcePermission.deleteMany({
resourceType: PerResourceTypeEnum.app,
teamId,
resourceId: appId
});
// 删除日志密钥
await MongoAppLogKeys.deleteMany({ appId });
// 删除应用注册记录
await MongoAppRegistration.deleteMany({ appId });
// 删除应用从MCP key apps数组中移除
await MongoMcpKey.updateMany({ teamId, 'apps.appId': appId }, { $pull: { apps: { appId } } });
// 删除应用本身
await MongoApp.deleteOne({ _id: appId });
}
};
export const deleteAppsImmediate = async ({
teamId,
appId,
session
appIds
}: {
teamId: string;
appId: string;
session?: ClientSession;
appIds: string[];
}) => {
const apps = await findAppAndAllChildren({
teamId,
appId,
fields: '_id avatar'
});
const deletedAppIds = apps
.filter((app) => !AppFolderTypeList.includes(app.type))
.map((app) => String(app._id));
// Remove eval job
const evalJobs = await MongoEvaluation.find(
{
appId: { $in: apps.map((app) => app._id) }
teamId,
appId: { $in: appIds }
},
'_id'
).lean();
await Promise.all(evalJobs.map((evalJob) => removeEvaluationJob(evalJob._id)));
const del = async (app: AppSchema, session: ClientSession) => {
const appId = String(app._id);
// 删除分享链接
await MongoOutLink.deleteMany({
appId
}).session(session);
// Openapi
await MongoOpenApi.deleteMany({
appId
}).session(session);
// delete version
await MongoAppVersion.deleteMany({
appId
}).session(session);
await MongoChatInputGuide.deleteMany({
appId
}).session(session);
// 删除精选应用记录
await MongoChatFavouriteApp.deleteMany({
teamId,
appId
}).session(session);
// 从快捷应用中移除对应应用
await MongoChatSetting.updateMany(
{ teamId },
{ $pull: { quickAppIds: { id: String(appId) } } }
).session(session);
// Del permission
await MongoResourcePermission.deleteMany({
resourceType: PerResourceTypeEnum.app,
teamId,
resourceId: appId
}).session(session);
await MongoAppLogKeys.deleteMany({
appId
}).session(session);
// delete app
await MongoApp.deleteOne(
{
_id: appId
},
{ session }
);
// Delete avatar
await removeImageByPath(app.avatar, session);
};
// Delete chats
for await (const app of apps) {
const appId = String(app._id);
await getS3ChatSource().deleteChatFilesByPrefix({ appId });
await MongoChatItemResponse.deleteMany({
appId
});
await MongoChatItem.deleteMany({
appId
});
await MongoChat.deleteMany({
appId
});
await getS3ChatSource().deleteChatFilesByPrefix({ appId });
}
for await (const app of apps) {
if (session) {
await del(app, session);
}
await mongoSessionRun((session) => del(app, session));
}
return deletedAppIds;
// Remove app record
await MongoAppRecord.deleteMany({ teamId, appId: { $in: appIds } });
};
export const updateParentFoldersUpdateTime = ({ parentId }: { parentId?: string | null }) => {
mongoSessionRun(async (session) => {
const existsId = new Set<string>();
while (true) {
if (!parentId || existsId.has(parentId)) return;
existsId.add(parentId);
const parentApp = await MongoApp.findById(parentId, 'parentId updateTime');
if (!parentApp) return;
parentApp.updateTime = new Date();
await parentApp.save({ session });
// 递归更新上层
parentId = parentApp.parentId;
}
}).catch((err) => {
addLog.error('updateParentFoldersUpdateTime error', err);
});
};

View File

@ -0,0 +1,41 @@
import { getQueue, getWorker, QueueNames } from '../../../common/bullmq';
import { appDeleteProcessor } from './processor';
export type AppDeleteJobData = {
teamId: string;
appId: string;
};
// 创建工作进程
export const initAppDeleteWorker = () => {
return getWorker<AppDeleteJobData>(QueueNames.appDelete, appDeleteProcessor, {
concurrency: 1, // 确保同时只有1个删除任务
removeOnFail: {
age: 30 * 24 * 60 * 60 // 保留30天失败记录
}
});
};
// 添加删除任务
export const addAppDeleteJob = (data: AppDeleteJobData) => {
// 创建删除队列
const appDeleteQueue = getQueue<AppDeleteJobData>(QueueNames.appDelete, {
defaultJobOptions: {
attempts: 10,
backoff: {
type: 'exponential',
delay: 5000
},
removeOnComplete: true,
removeOnFail: { age: 30 * 24 * 60 * 60 } // 保留30天失败记录
}
});
const jobId = `${String(data.teamId)}:${String(data.appId)}`;
// Use jobId to automatically prevent duplicate deletion tasks (BullMQ feature)
return appDeleteQueue.add('delete_app', data, {
jobId,
delay: 1000 // Delay 1 second to ensure API response completes
});
};

View File

@ -0,0 +1,77 @@
import type { Processor } from 'bullmq';
import type { AppDeleteJobData } from './index';
import { findAppAndAllChildren, deleteAppDataProcessor } from '../controller';
import { addLog } from '../../../common/system/log';
import { batchRun } from '@fastgpt/global/common/system/utils';
import type { AppSchema } from '@fastgpt/global/core/app/type';
import { MongoApp } from '../schema';
const deleteApps = async ({ teamId, apps }: { teamId: string; apps: AppSchema[] }) => {
const results = await batchRun(
apps,
async (app) => {
await deleteAppDataProcessor({ app, teamId });
},
3
);
return results.flat();
};
export const appDeleteProcessor: Processor<AppDeleteJobData> = async (job) => {
const { teamId, appId } = job.data;
const startTime = Date.now();
addLog.info(`[App Delete] Start deleting app: ${appId} for team: ${teamId}`);
try {
// 1. 查找应用及其所有子应用
const apps = await findAppAndAllChildren({
teamId,
appId
});
if (!apps || apps.length === 0) {
addLog.warn(`[App Delete] App not found: ${appId}`);
return;
}
// 2. 安全检查:确保所有要删除的应用都已标记为 deleteTime
const markedForDelete = await MongoApp.find(
{
_id: { $in: apps.map((app) => app._id) },
teamId,
deleteTime: { $ne: null }
},
{ _id: 1 }
).lean();
if (markedForDelete.length !== apps.length) {
addLog.warn(
`[App Delete] Safety check: ${markedForDelete.length}/${apps.length} apps marked for deletion`,
{
markedAppIds: markedForDelete.map((app) => app._id),
totalAppIds: apps.map((app) => app._id)
}
);
}
const childrenLen = apps.length - 1;
const appIds = apps.map((app) => app._id);
// 3. 执行真正的删除操作(只删除已经标记为 deleteTime 的数据)
await deleteApps({
teamId,
apps
});
addLog.info(`[App Delete] Successfully deleted app: ${appId} and ${childrenLen} children`, {
duration: Date.now() - startTime,
totalApps: appIds.length,
appIds
});
} catch (error: any) {
addLog.error(`[App Delete] Failed to delete app: ${appId}`, error);
throw error;
}
};

View File

@ -0,0 +1,45 @@
import {
TeamCollectionName,
TeamMemberCollectionName
} from '@fastgpt/global/support/user/team/constant';
import { getMongoModel, Schema } from '../../../common/mongo';
import { AppCollectionName } from '../schema';
import type { AppRecordType } from './type';
export const AppRecordCollectionName = 'app_records';
const AppRecordSchema = new Schema(
{
tmbId: {
type: Schema.Types.ObjectId,
ref: TeamMemberCollectionName,
required: true
},
teamId: {
type: Schema.Types.ObjectId,
ref: TeamCollectionName,
required: true
},
appId: {
type: Schema.Types.ObjectId,
ref: AppCollectionName,
required: true
},
lastUsedTime: {
type: Date,
default: () => new Date()
}
},
{
timestamps: false
}
);
AppRecordSchema.index({ tmbId: 1, lastUsedTime: -1 }); // 查询用户最近使用的应用
AppRecordSchema.index({ tmbId: 1, appId: 1 }, { unique: true }); // 防止重复记录
AppRecordSchema.index({ teamId: 1, appId: 1 }); // 用于清理权限失效的记录
export const MongoAppRecord = getMongoModel<AppRecordType>(
AppRecordCollectionName,
AppRecordSchema
);

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
export const AppRecordSchemaZod = z.object({
_id: z.string(),
tmbId: z.string(),
teamId: z.string(),
appId: z.string(),
lastUsedTime: z.date()
});
// TypeScript types inferred from Zod schemas
export type AppRecordType = z.infer<typeof AppRecordSchemaZod>;

View File

@ -0,0 +1,27 @@
import { MongoAppRecord } from './schema';
import { addLog } from '../../../common/system/log';
export const recordAppUsage = async ({
appId,
tmbId,
teamId
}: {
appId: string;
tmbId: string;
teamId: string;
}) => {
await MongoAppRecord.updateOne(
{ tmbId, appId },
{
$set: {
teamId,
lastUsedTime: new Date()
}
},
{
upsert: true
}
).catch((error) => {
addLog.error('recordAppUsage error', error);
});
};

View File

@ -119,6 +119,12 @@ const AppSchema = new Schema(
inited: Boolean,
teamTags: {
type: [String]
},
// 软删除标记字段
deleteTime: {
type: Date,
default: null // null表示未删除有值表示删除时间
}
},
{
@ -138,5 +144,6 @@ AppSchema.index(
);
// Admin count
AppSchema.index({ type: 1 });
AppSchema.index({ deleteTime: 1 });
export const MongoApp = getMongoModel<AppType>(AppCollectionName, AppSchema);

View File

@ -95,7 +95,13 @@ const ChatSchema = new Schema({
hasGoodFeedback: Boolean,
hasBadFeedback: Boolean,
hasUnreadGoodFeedback: Boolean,
hasUnreadBadFeedback: Boolean
hasUnreadBadFeedback: Boolean,
deleteTime: {
type: Date,
default: null,
select: false
}
});
try {
@ -103,13 +109,16 @@ try {
ChatSchema.index({ chatId: 1 });
// get user history
ChatSchema.index({ tmbId: 1, appId: 1, top: -1, updateTime: -1 });
ChatSchema.index({ tmbId: 1, appId: 1, deleteTime: 1, top: -1, updateTime: -1 });
// get share chat history
ChatSchema.index({ shareId: 1, outLinkUid: 1, updateTime: -1 });
// delete by appid; clear history; init chat; update chat; auth chat; get chat;
ChatSchema.index({ appId: 1, chatId: 1 });
/* get chat logs */
// 1. No feedback filter
ChatSchema.index({ teamId: 1, appId: 1, source: 1, tmbId: 1, updateTime: -1 });
ChatSchema.index({ teamId: 1, appId: 1, source: 1, tmbId: 1, deleteTime: 1, updateTime: -1 });
/* 反馈过滤的索引 */
// 2. Has good feedback filter
@ -120,11 +129,13 @@ try {
source: 1,
tmbId: 1,
hasGoodFeedback: 1,
deleteTime: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasGoodFeedback: true
hasGoodFeedback: true,
deleteTime: null
}
}
);
@ -136,11 +147,13 @@ try {
source: 1,
tmbId: 1,
hasBadFeedback: 1,
deleteTime: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasBadFeedback: true
hasBadFeedback: true,
deleteTime: null
}
}
);
@ -152,11 +165,13 @@ try {
source: 1,
tmbId: 1,
hasUnreadGoodFeedback: 1,
deleteTime: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasUnreadGoodFeedback: true
hasUnreadGoodFeedback: true,
deleteTime: null
}
}
);
@ -168,19 +183,19 @@ try {
source: 1,
tmbId: 1,
hasUnreadBadFeedback: 1,
deleteTime: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasUnreadBadFeedback: true
hasUnreadBadFeedback: true,
deleteTime: null
}
}
);
// get share chat history
ChatSchema.index({ shareId: 1, outLinkUid: 1, updateTime: -1 });
// timer, clear history
ChatSchema.index({ updateTime: -1, teamId: 1 });
ChatSchema.index({ teamId: 1, updateTime: -1 });
} catch (error) {
console.log(error);

View File

@ -35,7 +35,6 @@ export type Props = {
nodes: StoreNodeItemType[];
appChatConfig?: AppChatConfigType;
variables?: Record<string, any>;
isUpdateUseTime: boolean;
newTitle: string;
source: `${ChatSourceEnum}`;
sourceName?: string;
@ -219,7 +218,6 @@ export async function saveChat(props: Props) {
nodes,
appChatConfig,
variables,
isUpdateUseTime,
newTitle,
source,
sourceName,
@ -314,6 +312,9 @@ export async function saveChat(props: Props) {
outLinkUid,
metadata: metadataUpdate,
updateTime: new Date()
},
$setOnInsert: {
createTime: new Date()
}
},
{
@ -390,18 +391,6 @@ export async function saveChat(props: Props) {
} catch (error) {
addLog.error('Push chat log error', error);
}
if (isUpdateUseTime) {
await MongoApp.updateOne(
{ _id: appId },
{
updateTime: new Date()
},
{
...writePrimary
}
).catch();
}
} catch (error) {
addLog.error(`update chat history error`, error);
}

View File

@ -68,7 +68,7 @@ export async function delDatasetRelevantData({
datasets,
session
}: {
datasets: DatasetSchemaType[];
datasets: { _id: string; teamId: string }[];
session: ClientSession;
}) {
if (!datasets.length) return;
@ -115,24 +115,6 @@ export async function delDatasetRelevantData({
// Delete vector data
await deleteDatasetDataVector({ teamId, datasetIds });
for (const datasetId of datasetIds) {
// Delete dataset_data_texts in batches by datasetId
await MongoDatasetDataText.deleteMany({
teamId,
datasetId
}).maxTimeMS(300000); // Reduce timeout for single batch
// Delete dataset_datas in batches by datasetId
await MongoDatasetData.deleteMany({
teamId,
datasetId
}).maxTimeMS(300000);
}
// Delete source: 兼容旧版的图片
await delCollectionRelatedSource({ collections });
// Delete vector data
await deleteDatasetDataVector({ teamId, datasetIds });
// delete collections
await MongoDatasetCollection.deleteMany({
teamId,

View File

@ -31,11 +31,11 @@ export const addDatasetDeleteJob = (data: DatasetDeleteJobData) => {
}
});
const jobId = `${data.teamId}:${data.datasetId}`;
const jobId = `${String(data.teamId)}:${String(data.datasetId)}`;
// 使用去重机制,避免重复删除
return datasetDeleteQueue.add(jobId, data, {
deduplication: { id: jobId },
return datasetDeleteQueue.add('delete_dataset', data, {
jobId,
delay: 1000 // 延迟1秒执行确保API响应完成
});
};

View File

@ -1,8 +1,7 @@
import type { Processor } from 'bullmq';
import type { DatasetDeleteJobData } from './index';
import { addDatasetDeleteJob, type DatasetDeleteJobData } from './index';
import { delDatasetRelevantData, findDatasetAndAllChildren } from '../controller';
import { addLog } from '../../../common/system/log';
import type { DatasetSchemaType } from '@fastgpt/global/core/dataset/type';
import { MongoDatasetCollectionTags } from '../tag/schema';
import { removeDatasetSyncJobScheduler } from '../datasetSync';
import { mongoSessionRun } from '../../../common/mongo/sessionRun';
@ -12,36 +11,54 @@ import { MongoDatasetTraining } from '../training/schema';
export const deleteDatasetsImmediate = async ({
teamId,
datasets
datasetIds
}: {
teamId: string;
datasets: DatasetSchemaType[];
datasetIds: string[];
}) => {
const datasetIds = datasets.map((d) => d._id);
// delete training data
MongoDatasetTraining.deleteMany({
await MongoDatasetTraining.deleteMany({
teamId,
datasetId: { $in: datasetIds }
});
// Remove cron job
await Promise.all(
datasetIds.map((id) => {
return removeDatasetSyncJobScheduler(id);
})
);
};
// Clear a team datasets
export const deleteTeamAllDatasets = async (teamId: string) => {
const datasets = await MongoDataset.find(
{
teamId
},
{ _id: 1, parentId: 1 }
);
await deleteDatasetsImmediate({
teamId,
datasetIds: datasets.map((d) => d._id)
});
await Promise.all(
datasets.map((dataset) => {
// 只处理已标记删除的数据集
if (datasetIds.includes(dataset._id)) {
return removeDatasetSyncJobScheduler(dataset._id);
}
if (dataset.parentId) return;
return addDatasetDeleteJob({
teamId,
datasetId: dataset._id
});
})
);
};
export const deleteDatasets = async ({
// 批量删除函数
const deleteDatasets = async ({
teamId,
datasets
}: {
teamId: string;
datasets: DatasetSchemaType[];
datasets: { _id: string; avatar: string; teamId: string }[];
}) => {
const datasetIds = datasets.map((d) => d._id);
@ -81,7 +98,8 @@ export const datasetDeleteProcessor: Processor<DatasetDeleteJobData> = async (jo
// 1. 查找知识库及其所有子知识库
const datasets = await findDatasetAndAllChildren({
teamId,
datasetId
datasetId,
fields: '_id teamId avatar'
});
if (!datasets || datasets.length === 0) {

View File

@ -64,6 +64,7 @@ export type ChatResponse = DispatchNodeResultType<
export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResponse> => {
let {
res,
checkIsStopping,
requestOrigin,
stream = false,
retainDatasetCite = true,
@ -201,7 +202,7 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResp
requestOrigin
},
userKey: externalProvider.openaiAccount,
isAborted: () => res?.closed,
isAborted: checkIsStopping,
onReasoning({ text }) {
if (!aiChatReasoning) return;
workflowStreamResponse?.({

View File

@ -18,6 +18,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<RunTo
const { messages, toolNodes, toolModel, childrenInteractiveParams, ...workflowProps } = props;
const {
res,
checkIsStopping,
requestOrigin,
runtimeNodes,
runtimeEdges,
@ -129,7 +130,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<RunTo
retainDatasetCite,
useVision: aiChatVision
},
isAborted: () => res?.closed,
isAborted: checkIsStopping,
userKey: externalProvider.openaiAccount,
onReasoning({ text }) {
if (!aiChatReasoning) return;

View File

@ -59,10 +59,11 @@ import { TeamErrEnum } from '@fastgpt/global/common/error/code/team';
import { i18nT } from '../../../../web/i18n/utils';
import { clone } from 'lodash';
import { validateFileUrlDomain } from '../../../common/security/fileUrlValidator';
import { delAgentRuntimeStopSign, shouldWorkflowStop } from './workflowStatus';
type Props = Omit<
ChatDispatchProps,
'workflowDispatchDeep' | 'timezone' | 'externalProvider' | 'cloneVariables'
'checkIsStopping' | 'workflowDispatchDeep' | 'timezone' | 'externalProvider' | 'cloneVariables'
> & {
runtimeNodes: RuntimeNodeItemType[];
runtimeEdges: RuntimeEdgeItemType[];
@ -87,7 +88,17 @@ export async function dispatchWorkFlow({
concatUsage,
...data
}: Props & WorkflowUsageProps): Promise<DispatchFlowResponse> {
const { res, stream, runningUserInfo, runningAppInfo, lastInteractive, histories, query } = data;
const {
res,
stream,
runningUserInfo,
runningAppInfo,
lastInteractive,
histories,
query,
chatId,
apiVersion
} = data;
// Check url valid
const invalidInput = query.some((item) => {
@ -101,6 +112,8 @@ export async function dispatchWorkFlow({
addLog.info('[Workflow run] Invalid file url');
return Promise.reject(new UserError('Invalid file url'));
}
/* Init function */
// Check point
await checkTeamAIPoints(runningUserInfo.teamId);
@ -120,7 +133,22 @@ export async function dispatchWorkFlow({
});
}
return usageId;
})()
})(),
// Add preview url to chat items
await addPreviewUrlToChatItems(histories, 'chatFlow'),
// Add preview url to query
...query.map(async (item) => {
if (item.type !== ChatItemValueTypeEnum.file || !item.file?.key) return;
item.file.url = await getS3ChatSource().createGetChatFileURL({
key: item.file.key,
external: true
});
}),
// Remove stopping sign
delAgentRuntimeStopSign({
appId: runningAppInfo.id,
chatId
})
]);
let streamCheckTimer: NodeJS.Timeout | null = null;
@ -152,16 +180,6 @@ export async function dispatchWorkFlow({
}
}
// Add preview url to chat items
await addPreviewUrlToChatItems(histories, 'chatFlow');
for (const item of query) {
if (item.type !== ChatItemValueTypeEnum.file || !item.file?.key) continue;
item.file.url = await getS3ChatSource().createGetChatFileURL({
key: item.file.key,
external: true
});
}
// Get default variables
const cloneVariables = clone(data.variables);
const defaultVariables = {
@ -173,12 +191,34 @@ export async function dispatchWorkFlow({
timezone
}))
};
// MCP
let mcpClientMemory = {} as Record<string, MCPClient>;
// Stop sign(没有 apiVersion说明不会有暂停)
let stopping = false;
const checkIsStopping = (): boolean => {
if (apiVersion === 'v2') {
return stopping;
}
if (apiVersion === 'v1') {
if (!res) return false;
return res.closed || !!res.errored;
}
return false;
};
const checkStoppingTimer =
apiVersion === 'v2'
? setInterval(async () => {
stopping = await shouldWorkflowStop({
appId: runningAppInfo.id,
chatId
});
}, 100)
: undefined;
// Init some props
return runWorkflow({
...data,
checkIsStopping,
query,
histories,
timezone,
@ -189,15 +229,24 @@ export async function dispatchWorkFlow({
concatUsage,
mcpClientMemory,
cloneVariables
}).finally(() => {
}).finally(async () => {
if (streamCheckTimer) {
clearInterval(streamCheckTimer);
}
if (checkStoppingTimer) {
clearInterval(checkStoppingTimer);
}
// Close mcpClient connections
Object.values(mcpClientMemory).forEach((client) => {
client.closeConnection();
});
// 工作流完成后删除 Redis 记录
await delAgentRuntimeStopSign({
appId: runningAppInfo.id,
chatId
});
});
}
@ -210,14 +259,14 @@ type RunWorkflowProps = ChatDispatchProps & {
};
export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowResponse> => {
let {
res,
apiVersion,
checkIsStopping,
runtimeNodes = [],
runtimeEdges = [],
histories = [],
variables = {},
externalProvider,
retainDatasetCite = true,
version = 'v1',
responseDetail = true,
responseAllData = true,
usageId,
@ -328,10 +377,6 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR
});
}
get connectionIsActive(): boolean {
return !res?.closed && !res?.errored;
}
// Add active node to queue (if already in the queue, it will not be added again)
addActiveNode(nodeId: string) {
if (this.activeRunQueue.has(nodeId)) {
@ -585,7 +630,7 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR
})();
// Response node response
if (version === 'v2' && !data.isToolCall && isRootRuntime && formatResponseData) {
if (apiVersion === 'v2' && !data.isToolCall && isRootRuntime && formatResponseData) {
data.workflowStreamResponse?.({
event: SseResponseEventEnum.flowNodeResponse,
data: responseAllData
@ -813,8 +858,8 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR
});
return;
}
if (!this.connectionIsActive) {
addLog.warn('Request is closed/errored', {
if (checkIsStopping()) {
addLog.warn('Workflow stopped', {
appId: data.runningAppInfo.id,
nodeId: node.nodeId,
nodeName: node.name

View File

@ -0,0 +1,79 @@
import { addLog } from '../../../common/system/log';
import { getGlobalRedisConnection } from '../../../common/redis/index';
import { delay } from '@fastgpt/global/common/system/utils';
const WORKFLOW_STATUS_PREFIX = 'agent_runtime_stopping';
const TTL = 60; // 1分钟
export const StopStatus = 'STOPPING';
export type WorkflowStatusParams = {
appId: string;
chatId: string;
};
// 获取工作流状态键
export const getRuntimeStatusKey = (params: WorkflowStatusParams): string => {
return `${WORKFLOW_STATUS_PREFIX}:${params.appId}:${params.chatId}`;
};
// 暂停任务
export const setAgentRuntimeStop = async (params: WorkflowStatusParams): Promise<void> => {
const redis = getGlobalRedisConnection();
const key = getRuntimeStatusKey(params);
await redis.set(key, 1, 'EX', TTL);
};
// 删除任务状态
export const delAgentRuntimeStopSign = async (params: WorkflowStatusParams): Promise<void> => {
const redis = getGlobalRedisConnection();
const key = getRuntimeStatusKey(params);
await redis.del(key).catch((err) => {
addLog.error(`[Agent Runtime Stop] Delete stop sign error`, err);
});
};
// 检查工作流是否应该停止
export const shouldWorkflowStop = (params: WorkflowStatusParams): Promise<boolean> => {
const redis = getGlobalRedisConnection();
const key = getRuntimeStatusKey(params);
return redis
.get(key)
.then((res) => !!res)
.catch(() => false);
};
/**
* ()
* @param params
* @param timeout (),5
* @param pollInterval (),50
* @returns true=, false=
*/
export const waitForWorkflowComplete = async ({
appId,
chatId,
timeout = 5000,
pollInterval = 50
}: {
appId: string;
chatId: string;
timeout?: number;
pollInterval?: number;
}) => {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const sign = await shouldWorkflowStop({ appId, chatId });
// 如果没有暂停中的标志,则认为已经完成任务了。
if (!sign) {
return;
}
// 等待下一次轮询
await delay(pollInterval);
}
return;
};

View File

@ -29,6 +29,8 @@ const PromotionRecordSchema = new Schema({
}
});
PromotionRecordSchema.index({ userId: 1 });
export const MongoPromotionRecord = getMongoModel<PromotionRecordType>(
'promotionRecord',
PromotionRecordSchema

View File

@ -43,19 +43,21 @@ const OutLinkSchema = new Schema({
type: Date
},
responseDetail: {
showRunningStatus: {
type: Boolean,
default: false
},
showNodeStatus: {
showCite: {
type: Boolean,
default: true
default: false
},
// showFullText: {
// type: Boolean
// },
showRawSource: {
type: Boolean
showFullText: {
type: Boolean,
default: false
},
canDownloadSource: {
type: Boolean,
default: false
},
limit: {
maxUsagePoints: {

View File

@ -1,14 +1,14 @@
import { Schema, getMongoLogModel } from '../../../common/mongo';
import { type OperationLogSchema } from '@fastgpt/global/support/user/audit/type';
import { type TeamAuditSchemaType } from '@fastgpt/global/support/user/audit/type';
import { AdminAuditEventEnum, AuditEventEnum } from '@fastgpt/global/support/user/audit/constants';
import {
TeamCollectionName,
TeamMemberCollectionName
} from '@fastgpt/global/support/user/team/constant';
export const OperationLogCollectionName = 'operationLogs';
export const TeamAuditCollectionName = 'operationLogs';
const OperationLogSchema = new Schema({
const TeamAuditSchema = new Schema({
tmbId: {
type: Schema.Types.ObjectId,
ref: TeamMemberCollectionName,
@ -34,9 +34,10 @@ const OperationLogSchema = new Schema({
}
});
OperationLogSchema.index({ teamId: 1, tmbId: 1, event: 1 });
TeamAuditSchema.index({ teamId: 1, tmbId: 1, event: 1 });
TeamAuditSchema.index({ timestamp: 1, teamId: 1 });
export const MongoOperationLog = getMongoLogModel<OperationLogSchema>(
OperationLogCollectionName,
OperationLogSchema
export const MongoTeamAudit = getMongoLogModel<TeamAuditSchemaType>(
TeamAuditCollectionName,
TeamAuditSchema
);

View File

@ -1,7 +1,7 @@
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants';
import { i18nT } from '../../../../web/i18n/utils';
import { MongoOperationLog } from './schema';
import { MongoTeamAudit } from './schema';
import type {
AdminAuditEventEnum,
AuditEventEnum,
@ -86,7 +86,7 @@ export function addAuditLog<T extends AuditEventEnum | AdminAuditEventEnum>({
params?: any;
}) {
retryFn(() =>
MongoOperationLog.create({
MongoTeamAudit.create({
tmbId: tmbId,
teamId: teamId,
event,

View File

@ -1,4 +0,0 @@
export type SystemMsgModalValueType = {
id: string;
content: string;
};

View File

@ -11,7 +11,7 @@ import {
SubModeEnum,
SubTypeEnum
} from '@fastgpt/global/support/wallet/sub/constants';
import type { TeamSubSchema } from '@fastgpt/global/support/wallet/sub/type';
import type { TeamSubSchemaType } from '@fastgpt/global/support/wallet/sub/type';
export const subCollectionName = 'team_subscriptions';
@ -67,18 +67,12 @@ const SubSchema = new Schema({
customDomain: Number,
// stand sub and extra points sub. Plan total points
totalPoints: {
type: Number
},
surplusPoints: {
// plan surplus points
type: Number
},
totalPoints: Number,
// plan surplus points
surplusPoints: Number,
// extra dataset size
currentExtraDatasetSize: {
type: Number
}
currentExtraDatasetSize: Number
});
try {
@ -106,4 +100,4 @@ try {
console.log(error);
}
export const MongoTeamSub = getMongoModel<TeamSubSchema>(subCollectionName, SubSchema);
export const MongoTeamSub = getMongoModel<TeamSubSchemaType>(subCollectionName, SubSchema);

View File

@ -7,8 +7,8 @@ import {
import { MongoTeamSub } from './schema';
import {
type TeamPlanStatusType,
type TeamSubSchema
} from '@fastgpt/global/support/wallet/sub/type.d';
type TeamSubSchemaType
} from '@fastgpt/global/support/wallet/sub/type';
import dayjs from 'dayjs';
import { type ClientSession } from '../../../common/mongo';
import { addMonths } from 'date-fns';
@ -29,7 +29,7 @@ export const getStandardPlanConfig = (level: `${StandardSubLevelEnum}`) => {
return global.subPlans?.standard?.[level];
};
export const sortStandPlans = (plans: TeamSubSchema[]) => {
export const sortStandPlans = (plans: TeamSubSchemaType[]) => {
return plans.sort(
(a, b) =>
standardSubLevelMap[b.currentSubLevel].weight - standardSubLevelMap[a.currentSubLevel].weight

View File

@ -8,7 +8,17 @@ import { imageBaseUrl } from '@fastgpt/global/common/file/image/constants';
export const useUploadAvatar = (
api: (params: { filename: string }) => Promise<CreatePostPresignedUrlResult>,
{ onSuccess }: { onSuccess?: (avatar: string) => void } = {}
{
onSuccess,
maxW = 300,
maxH = 300,
maxSize = 1024 * 500 // 500KB
}: {
onSuccess?: (avatar: string) => void;
maxW?: number;
maxH?: number;
maxSize?: number;
} = {}
) => {
const { toast } = useToast();
const { t } = useTranslation();
@ -32,8 +42,9 @@ export const useUploadAvatar = (
const compressed = base64ToFile(
await compressBase64Img({
base64Img: await fileToBase64(file),
maxW: 300,
maxH: 300
maxW,
maxH,
maxSize
}),
file.name
);

View File

@ -6,7 +6,11 @@ import MyIcon from '../Icon';
import { iconPaths } from '../Icon/constants';
import MyImage from '../Image/MyImage';
const Avatar = ({ w = '30px', src, ...props }: ImageProps) => {
const Avatar = ({
w = '30px',
src,
...props
}: Omit<ImageProps, 'src'> & { src?: string | null }) => {
// @ts-ignore
const isIcon = !!iconPaths[src as any];

View File

@ -1,5 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M12.6262 3.29289C13.0167 2.90237 13.6499 2.90237 14.0404 3.29289C14.4309 3.68342 14.4309 4.31658 14.0404 4.70711L6.70707 12.0404C6.31654 12.431 5.68338 12.431 5.29285 12.0404L1.95952 8.70711C1.56899 8.31658 1.56899 7.68342 1.95952 7.29289C2.35004 6.90237 2.98321 6.90237 3.37373 7.29289L5.99996 9.91912L12.6262 3.29289Z"
fill="#3370FF" />
<svg viewBox="0 0 16 16" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.6261 3.29289C13.0166 2.90237 13.6498 2.90237 14.0403 3.29289C14.4308 3.68342 14.4308 4.31658 14.0403 4.70711L6.70694 12.0404C6.31642 12.431 5.68325 12.431 5.29273 12.0404L1.9594 8.70711C1.56887 8.31658 1.56887 7.68342 1.9594 7.29289C2.34992 6.90237 2.98309 6.90237 3.37361 7.29289L5.99984 9.91912L12.6261 3.29289Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 475 B

After

Width:  |  Height:  |  Size: 416 B

View File

@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React from 'react';
import { useTranslation } from 'next-i18next';
import MyIcon from '../Icon';
import { useRequest2 } from '../../../hooks/useRequest';
@ -11,9 +11,9 @@ import {
HStack,
Box,
Button,
PopoverArrow,
Portal
PopoverArrow
} from '@chakra-ui/react';
import { useMemoEnhance } from '../../../hooks/useMemoEnhance';
const PopoverConfirm = ({
content,
@ -32,13 +32,13 @@ const PopoverConfirm = ({
Trigger: React.ReactNode;
placement?: PlacementWithLogical;
offset?: [number, number];
onConfirm: () => any;
onConfirm: () => Promise<any> | any;
confirmText?: string;
cancelText?: string;
}) => {
const { t } = useTranslation();
const map = useMemo(() => {
const map = useMemoEnhance(() => {
const map = {
info: {
variant: 'primary',
@ -56,7 +56,7 @@ const PopoverConfirm = ({
const firstFieldRef = React.useRef(null);
const { onOpen, onClose, isOpen } = useDisclosure();
const { runAsync: onclickConfirm, loading } = useRequest2(onConfirm, {
const { runAsync: onclickConfirm, loading } = useRequest2(async () => onConfirm(), {
onSuccess: onClose
});
@ -90,7 +90,14 @@ const PopoverConfirm = ({
</HStack>
<HStack mt={2} justifyContent={'flex-end'}>
{showCancel && (
<Button variant={'whiteBase'} size="sm" onClick={onClose}>
<Button
variant={'whiteBase'}
size="sm"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
{cancelText || t('common:Cancel')}
</Button>
)}

View File

@ -17,12 +17,14 @@ export function useLinkedScroll<
pageSize = 10,
params = {},
currentData,
defaultScroll = 'top'
defaultScroll = 'top',
showErrorToast = true
}: {
pageSize?: number;
params?: Record<string, any>;
currentData?: { id: string; anchor?: any };
defaultScroll?: 'top' | 'bottom';
showErrorToast?: boolean;
}
) {
const { t } = useTranslation();
@ -105,7 +107,8 @@ export function useLinkedScroll<
onFinally() {
isInit.current = true;
},
manual: false
manual: false,
errorToast: showErrorToast ? undefined : ''
}
);
useEffect(() => {
@ -153,7 +156,8 @@ export function useLinkedScroll<
return response;
},
{
refreshDeps: [hasMorePrev, isLoading, params, pageSize]
refreshDeps: [hasMorePrev, isLoading, params, pageSize],
errorToast: showErrorToast ? undefined : ''
}
);
@ -188,7 +192,8 @@ export function useLinkedScroll<
return response;
},
{
refreshDeps: [hasMoreNext, isLoading, params, pageSize]
refreshDeps: [hasMoreNext, isLoading, params, pageSize],
errorToast: showErrorToast ? undefined : ''
}
);

View File

@ -269,7 +269,7 @@ export function useScrollPagination<
} catch (error: any) {
if (showErrorToast) {
toast({
title: getErrText(error, t('common:core.chat.error.data_error')),
title: t(getErrText(error, t('common:core.chat.error.data_error'))),
status: 'error'
});
}

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