V4.14.4 features (#6036)
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

* feat: add query optimize and bill (#6021)

* add query optimize and bill

* perf: query extension

* fix: embe model

* remove log

* remove log

* fix: test

---------

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

* feat: notice (#6013)

* feat: record user's language

* feat: notice points/dataset indexes; support count limit; update docker-compose.yml

* fix: ts error

* feat: send auth code i18n

* chore: dataset notice limit

* chore: adjust

* fix: ts

* fix: countLimit race condition; i18n en-prefix locale fallback to en

---------

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

* perf: comment

* perf: send inform code

* fix: type error (#6029)

* feat: add ip region for chat logs (#6010)

* feat: add ip region for chat logs

* refactor: use Geolite2.mmdb

* fix: export chat logs

* fix: return location directly

* test: add unit test

* perf: log show ip data

* adjust commercial plans (#6008)

* plan frontend

* plan limit

* coupon

* discount coupon

* fix

* type

* fix audit

* type

* plan name

* legacy plan

* track

* feat: add discount coupon

* fix

* fix discount coupon

* openapi

* type

* type

* env

* api type

* fix

* fix: simple agent plugin input & agent dashboard card (#6034)

* refactor: remove gridfs (#6031)

* fix: replace gridfs multer operations with s3 compatible ops

* wip: s3 features

* refactor: remove gridfs

* fix

* perf: mock test

* doc

* doc

* doc

* fix: test

* fix: s3

* fix: mock s3

* remove invalid config

* fix: init query extension

* initv4144 (#6037)

* chore: initv4144

* fix

* version

* fix: new plans (#6039)

* fix: new plans

* qr modal tip

* fix: buffer raw text filename (#6040)

* fix: initv4144 (#6041)

* fix: pay refresh (#6042)

* fix: migration shell

* rename collection

* clear timerlock

* clear timerlock

* perf: faq

* perf: bill schema

* fix: openapi

* doc

* fix: share var render

* feat: delete dataset queue

* plan usage display (#6043)

* plan usage display

* text

* fix

* fix: ts

* perf: remove invalid code

* perf: init shell

* doc

* perf: rename field

* perf: avatar presign

* init

* custom plan text (#6045)

* fix plans

* fix

* fixed

* computed

---------

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

* init shell

* plan text & price page back button (#6046)

* init

* index

* delete dataset

* delete dataset

* perf: delete dataset

* init

---------

Co-authored-by: YeYuheng <57035043+YYH211@users.noreply.github.com>
Co-authored-by: xxyyh <2289112474@qq>
Co-authored-by: Finley Ge <32237950+FinleyGe@users.noreply.github.com>
Co-authored-by: Roy <whoeverimf5@gmail.com>
Co-authored-by: heheer <heheer@sealos.io>
This commit is contained in:
Archer 2025-12-08 01:44:15 +08:00 committed by GitHub
parent 9d72f238c0
commit 2ccb5b50c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
247 changed files with 7342 additions and 3819 deletions

View File

@ -1,384 +0,0 @@
# Lexical Editor 文本解析一致性分析报告
## 执行摘要
通过对 `textToEditorState``editorStateToText` 函数的全面分析,发现了 **3 个确认的不一致性问题**,会导致用户保存后重新加载时看到与编辑器显示不同的内容。
### 严重问题总览
| 问题 | 严重性 | 影响 | 位置 |
|------|--------|------|------|
| 列表项尾部空格丢失 | 🔴 高 | 用户有意添加的空格被删除 | utils.ts:255 |
| 有序列表序号重置 | 🔴 高 | 自定义序号变成连续序号 | utils.ts:257 |
| 列表项内换行不对称 | 🟡 中 | 编辑器支持但无法往返 | processListItem |
---
## 问题 1: 列表项尾部空格丢失 🔴(已处理)
### 问题描述
`processListItem` 函数中使用了 `trim()` 处理列表项文本:
```typescript
// utils.ts:255
const itemTextString = itemText.join('').trim();
```
### 不一致性演示
**用户输入:**
```
- hello world
```
(注意 "world" 后面有 2 个空格)
**EditorState:**
```json
{
"type": "listitem",
"children": [
{ "type": "text", "text": "hello world " }
]
}
```
**输出文本:**
```
- hello world
```
(尾部空格被 trim 删除)
**重新加载:**
```
- hello world
```
(用户的空格永久丢失)
### 影响分析
- **用户体验**: 用户有意添加的尾部空格(可能用于格式对齐)会丢失
- **数据完整性**: 每次保存/加载循环都会丢失尾部空格
- **严重程度**: 高 - 直接影响用户输入的完整性
### 解决方案
**方案 1: 移除 trim()**
```typescript
const itemTextString = itemText.join(''); // 不使用 trim
```
**方案 2: 只移除前导空格**
```typescript
const itemTextString = itemText.join('').trimStart(); // 只移除开头空格
```
**推荐**: 方案 1,完全保留用户输入的空格
---
## 问题 2: 有序列表序号重置 🔴
### 问题描述
在输出有序列表时,使用 `index + 1` 而不是列表项自身的 `value`:
```typescript
// utils.ts:257
const prefix = listType === 'bullet' ? '- ' : `${index + 1}. `;
```
但在解析时,`numberValue` 被正确提取并存储到 `listItem.value`
### 不一致性演示
**用户输入:**
```
1. first
2. second
5. fifth
10. tenth
```
**解析 (textToEditorState):**
```javascript
items = [
{ numberValue: 1, text: "first" },
{ numberValue: 2, text: "second" },
{ numberValue: 5, text: "fifth" },
{ numberValue: 10, text: "tenth" }
]
```
**EditorState:**
```json
[
{ "value": 1, "text": "first" },
{ "value": 2, "text": "second" },
{ "value": 5, "text": "fifth" },
{ "value": 10, "text": "tenth" }
]
```
**输出文本 (editorStateToText):**
```
1. first (index=0, 0+1=1) ✓
2. second (index=1, 1+1=2) ✓
3. fifth (index=2, 2+1=3) ✗ 应该是 5
4. tenth (index=3, 3+1=4) ✗ 应该是 10
```
**重新加载:**
用户的自定义序号 5 和 10 永久丢失,变成连续的 3 和 4。
### 影响分析
- **用户体验**: 用户有意设置的序号被强制改为连续序号
- **数据完整性**: 有序列表的语义丢失(如章节编号 1.1, 1.2, 2.1)
- **严重程度**: 高 - 改变了用户的语义表达
### 解决方案
```typescript
// utils.ts:257
const prefix = listType === 'bullet'
? '- '
: `${listItem.value || index + 1}. `;
```
使用 `listItem.value` 而不是 `index + 1`,保留原始序号。
---
## 问题 3: 列表项内换行不对称 🟡
### 问题描述
Lexical 编辑器允许在列表项内插入换行符 (`linebreak` 节点),但 `textToEditorState` 无法将包含换行的文本重新解析为列表项内换行。
### 不一致性演示
**用户在编辑器中操作:**
```
1. 输入: "- item1"
2. 按 Shift+Enter (插入软换行)
3. 继续输入: "continued content"
```
**EditorState:**
```json
{
"type": "listitem",
"children": [
{ "type": "text", "text": "item1" },
{ "type": "linebreak" },
{ "type": "text", "text": "continued content" }
]
}
```
**输出文本 (editorStateToText):**
```
- item1
continued content
```
**重新加载 (textToEditorState):**
```
行1: "- item1" → 列表项
行2: "continued content" → 段落 (不再是列表项的一部分!)
```
**最终结构变化:**
```
原来: 1个列表项(包含换行)
现在: 1个列表项 + 1个段落
```
### 影响分析
- **结构完整性**: 列表项的内部结构在保存/加载后改变
- **语义丢失**: 原本属于列表项的内容变成了独立段落
- **严重程度**: 中 - 影响文档结构,但可能符合 Markdown 语义
### 解决方案
**方案 1: 在输出时将换行转为空格**
```typescript
if (child.type === 'linebreak') {
itemText.push(' '); // 使用空格而不是 \n
}
```
**方案 2: 在编辑器中禁止列表项内换行**
- 配置 Lexical 不允许在列表项内插入 linebreak
- 用户只能通过创建新列表项来换行
**方案 3: 支持 Markdown 风格的列表项多行**
```typescript
// 识别缩进的行为列表项的继续内容
parseTextLine:
if (line.startsWith(' ') && prevLine.wasListItem) {
// 作为列表项的继续内容
}
```
**推荐**: 方案 1 (最简单) 或方案 2 (最明确)
---
## 其他潜在问题
### 问题 4: 变量节点保存后变成普通文本 🟡
**现象**:
```
EditorState: { type: 'variableLabel', variableKey: '{{var1}}' }
输出文本: "{{var1}}"
重新加载: { type: 'text', text: "{{var1}}" }
```
**影响**: 变量节点的功能性丢失
**分析**: 这可能是设计决策 - 变量只在编辑会话中有效,保存到文本后变成普通占位符。如果需要保持变量功能,应该使用其他存储格式(如 JSON)而不是纯文本。
### 问题 5: 非连续缩进级别可能导致结构错误 🟡
**现象**:
```
输入:
- level 0
- level 2 (跳过 level 1)
- level 1
```
**问题**: `buildListStructure` 可能无法正确处理非连续的缩进级别
**影响**: 列表嵌套结构可能不符合预期
**建议**: 规范化缩进级别,或在文档中说明只支持连续缩进
---
## 正常运作的部分 ✅
经过分析,以下功能**正常运作**,不存在一致性问题:
1. **空行处理** - 空行被正确保留和还原
2. **段落前导空格** - 修复后完全保留
3. **列表和段落边界** - 正确识别和分离
4. **特殊字符在段落中** - 只有行首的 `- ``\d+. ` 被识别为列表
5. **混合列表类型** - bullet 和 number 列表正确分离
6. **列表缩进** - 使用 TabStr 统一为 2 个空格
---
## 测试用例建议
### 测试用例 1: 列表项尾部空格
```typescript
const input = "- hello world "; // 2个尾部空格
const state = textToEditorState(input, true);
const editor = createEditorWithState(state);
const output = editorStateToText(editor);
expect(output).toBe("- hello world "); // 应保留空格
```
### 测试用例 2: 自定义列表序号
```typescript
const input = "1. first\n5. fifth\n10. tenth";
const state = textToEditorState(input, true);
const editor = createEditorWithState(state);
const output = editorStateToText(editor);
expect(output).toBe("1. first\n5. fifth\n10. tenth"); // 应保留序号
```
### 测试用例 3: 列表项换行
```typescript
// 在编辑器中创建列表项并插入 linebreak
const editor = createEditor();
// ... 创建列表项
// ... 插入 linebreak
const output = editorStateToText(editor);
const reloadedState = textToEditorState(output, true);
const reloadedEditor = createEditorWithState(reloadedState);
// 验证结构是否一致
expect(getStructure(editor)).toEqual(getStructure(reloadedEditor));
```
### 测试用例 4: 往返对称性
```typescript
const testCases = [
"simple text",
" indented text",
"- bullet list\n - nested",
"1. first\n2. second\n5. fifth",
"text\n\n\nwith\n\nempty\n\nlines",
"- item ", // 尾部空格
];
testCases.forEach(input => {
const state = textToEditorState(input, true);
const editor = createEditorWithState(state);
const output = editorStateToText(editor);
expect(output).toBe(input); // 应完全一致
});
```
---
## 修复优先级建议
### P0 - 立即修复
1. ✅ **列表项尾部空格丢失** - 影响数据完整性
2. ✅ **有序列表序号重置** - 影响语义表达
### P1 - 高优先级
3. ⚠️ **列表项内换行不对称** - 影响结构一致性
### P2 - 按需修复
4. 📝 **变量节点** - 根据产品需求决定
5. 📝 **非连续缩进** - 文档说明或规范化处理
---
## 代码修改建议
### 修改 1: 保留列表项空格
```diff
// utils.ts:255
- const itemTextString = itemText.join('').trim();
+ const itemTextString = itemText.join('');
```
### 修改 2: 使用原始列表序号
```diff
// utils.ts:257
- const prefix = listType === 'bullet' ? '- ' : `${index + 1}. `;
+ const prefix = listType === 'bullet' ? '- ' : `${listItem.value || index + 1}. `;
```
### 修改 3: 处理列表项换行(方案1)
```diff
// utils.ts:242
if (child.type === 'linebreak') {
- itemText.push('\n');
+ itemText.push(' '); // 转为空格而不是换行
}
```
---
## 总结
通过全面分析,确认了 **3 个会导致编辑器显示与解析文本不一致的问题**:
1. 🔴 列表项尾部空格丢失 → 修复: 移除 trim()
2. 🔴 有序列表序号重置 → 修复: 使用 listItem.value
3. 🟡 列表项内换行不对称 → 修复: 转换为空格或禁止
其他方面(空行、前导空格、边界处理)都运作正常。
建议优先修复前两个 P0 问题,确保用户数据的完整性和语义准确性。

View File

@ -608,136 +608,6 @@ function App({ Component, pageProps }: AppPropsWithLayout) {
---
### 🔴 H9. instrumentation.ts 初始化失败未处理,导致静默失败
**位置**: `projects/app/src/instrumentation.ts:81-84`
**问题描述**:
```typescript
} catch (error) {
console.log('Init system error', error);
exit(1);
}
```
- 初始化失败直接退出进程
- 部分初始化错误被 `.catch()` 吞没
- 缺少初始化状态检查
**风险等级**: 🔴 高危
**影响**:
- 应用启动失败但无明确错误信息
- 部分服务未初始化导致运行时错误
- 调试困难
**建议方案**:
```typescript
// 1. 详细的初始化错误处理
export async function register() {
const initSteps: Array<{
name: string;
fn: () => Promise<void>;
required: boolean;
}> = [];
try {
if (process.env.NEXT_RUNTIME !== 'nodejs') {
return;
}
const results = {
success: [] as string[],
failed: [] as Array<{ name: string; error: any }>
};
// 阶段 1: 基础连接 (必需)
try {
console.log('Connecting to MongoDB...');
await connectMongo({ db: connectionMongo, url: MONGO_URL });
results.success.push('MongoDB Main');
} catch (error) {
console.error('Fatal: MongoDB connection failed', error);
throw error;
}
try {
await connectMongo({ db: connectionLogMongo, url: MONGO_LOG_URL });
results.success.push('MongoDB Log');
} catch (error) {
console.warn('Non-fatal: MongoDB Log connection failed', error);
results.failed.push({ name: 'MongoDB Log', error });
}
// 阶段 2: 系统初始化 (必需)
try {
console.log('Initializing system config...');
await Promise.all([
getInitConfig(),
initVectorStore(),
initRootUser(),
loadSystemModels()
]);
results.success.push('System Config');
} catch (error) {
console.error('Fatal: System initialization failed', error);
throw error;
}
// 阶段 3: 可选服务
await Promise.allSettled([
preLoadWorker().catch(e => {
console.warn('Worker preload failed (non-fatal)', e);
results.failed.push({ name: 'Worker Preload', error: e });
}),
getSystemTools().catch(e => {
console.warn('System tools init failed (non-fatal)', e);
results.failed.push({ name: 'System Tools', error: e });
}),
initSystemPluginGroups().catch(e => {
console.warn('Plugin groups init failed (non-fatal)', e);
results.failed.push({ name: 'Plugin Groups', error: e });
})
]);
// 阶段 4: 后台任务
startCron();
startTrainingQueue(true);
trackTimerProcess();
console.log('Init system success', {
success: results.success,
failed: results.failed.map(f => f.name)
});
} catch (error) {
console.error('Init system critical error', error);
console.error('Stack:', error.stack);
// 发送告警通知
if (process.env.ERROR_WEBHOOK_URL) {
try {
await fetch(process.env.ERROR_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'INIT_ERROR',
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
})
});
} catch (webhookError) {
console.error('Failed to send error webhook', webhookError);
}
}
exit(1);
}
}
```
---
## 二、中危问题 (Medium Priority)
### 🟡 M1. Next.js 未启用 SWC 编译优化完整特性

View File

@ -934,28 +934,6 @@ export const authCertWithCSRF = async (props: AuthModeType) => {
## 新增中危问题 (Additional Medium Priority)
### 🟡 M20. 向量查询缓存策略过于激进
**位置**: `packages/service/common/vectorDB/controller.ts:29-35`
**问题描述**:
```typescript
const onDelCache = throttle((teamId: string) => delRedisCache(getChcheKey(teamId)), 30000, {
leading: true,
trailing: true
});
```
- 删除操作使用 throttle,30 秒内只执行一次
- 可能导致缓存计数不准确
- 未考虑高频删除场景
**建议**:
- 删除操作直接更新缓存
- 定期全量同步缓存和数据库
- 添加缓存一致性校验
---
### 🟡 M21. 训练队列缺少优先级机制
**位置**: `packages/service/common/bullmq/index.ts:20-26`

View File

@ -4,18 +4,47 @@ description: 'FastGPT V4.14.4 更新说明'
---
## 更新指南
### 1. 更新镜像:
### 2. 执行升级脚本
从任意终端,发起 1 个 HTTP 请求。其中 `{{rootkey}}` 替换成环境变量里的 `rootkey``{{host}}` 替换成**FastGPT 域名**。
```bash
curl --location --request POST 'https://{{host}}/api/admin/initv4144' \
--header 'rootkey: {{rootkey}}' \
--header 'Content-Type: application/json'
```
将 4.14.3 中,遗留的 Dataset/local 接口上传的文件,也迁移到 S3 中。
## 🚀 新增内容
1. 工具调用支持配置流输出
2. AI 积分告警通知。
3. 对话日志支持展示 IP 地址归属地。
4. 通过 API 上传本地文件至知识库,保存至 S3。同时将旧版 Gridfs 代码全部移除。
5. 新版订阅套餐逻辑。
## ⚙️ 优化
1. 增加 S3 上传文件超时时长为 5 分钟。
2. 问题优化采用 JinaAI 的边际收益公式,获取最大边际收益的检索词。
3. 用户通知,支持中英文,以及优化模板。
4. 删除知识库采用队列异步删除模式。
## 🐛 修复
1. 循环节点数组,取消过滤空内容。
2. 工作流工具,未传递自定义 DataId导致测试运行时查看知识库提示无权限。
3. 对话 Agent 工具配置中,非必填的布尔和数字类型无法直接确认。
4. 工作台卡片在名字过长时错位。
5. 分享链接中url query 中携带全局变量时,前端 UI 不会加载该值。
6. window 下判断 CSV 文件异常。
## 插件

View File

@ -118,7 +118,7 @@
"document/content/docs/upgrading/4-14/4141.mdx": "2025-11-19T10:15:27+08:00",
"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-01T21:46:30+08:00",
"document/content/docs/upgrading/4-14/4144.mdx": "2025-12-07T14:24:15+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

@ -11,6 +11,7 @@ export enum TeamErrEnum {
datasetAmountNotEnough = 'datasetAmountNotEnough',
appAmountNotEnough = 'appAmountNotEnough',
pluginAmountNotEnough = 'pluginAmountNotEnough',
appFolderAmountNotEnough = 'appFolderAmountNotEnough',
websiteSyncNotEnough = 'websiteSyncNotEnough',
reRankNotEnough = 'reRankNotEnough',
groupNameEmpty = 'groupNameEmpty',
@ -65,6 +66,10 @@ const teamErr = [
statusText: TeamErrEnum.pluginAmountNotEnough,
message: i18nT('common:code_error.team_error.plugin_amount_not_enough')
},
{
statusText: TeamErrEnum.appFolderAmountNotEnough,
message: i18nT('common:code_error.team_error.app_folder_amount_not_enough')
},
{
statusText: TeamErrEnum.websiteSyncNotEnough,
message: i18nT('common:code_error.team_error.website_sync_not_enough')

View File

@ -1,20 +1,8 @@
import { i18nT } from '../../../web/i18n/utils';
/* mongo fs bucket */
export enum BucketNameEnum {
dataset = 'dataset',
chat = 'chat'
}
export const bucketNameMap = {
[BucketNameEnum.dataset]: {
label: i18nT('file:bucket_file'),
previewExpireMinutes: 30 // 30 minutes
},
[BucketNameEnum.chat]: {
label: i18nT('file:bucket_chat'),
previewExpireMinutes: 7 * 24 * 60 // 7 days
}
};
export const EndpointUrl = `${process.env.FILE_DOMAIN || process.env.FE_DOMAIN || ''}${process.env.NEXT_PUBLIC_BASE_URL || ''}`;
export const ReadFileBaseUrl = `${EndpointUrl}/api/common/file/read`;

View File

@ -9,5 +9,6 @@ export enum TrackEnum {
datasetSearch = 'datasetSearch',
readSystemAnnouncement = 'readSystemAnnouncement',
clickOperationalAd = 'clickOperationalAd',
closeOperationalAd = 'closeOperationalAd'
closeOperationalAd = 'closeOperationalAd',
teamChatQPM = 'teamChatQPM'
}

View File

@ -184,7 +184,7 @@ export const sliceStrStartEnd = (str: string, start: number, end: number) => {
return `${startContent}${overSize ? `\n\n...[hide ${str.length - start - end} chars]...\n\n` : ''}${endContent}`;
};
/*
/*
Parse file extension from url
Test
1. https://xxx.com/file.pdf?token=123
@ -201,3 +201,32 @@ export const parseFileExtensionFromUrl = (url = '') => {
const extension = fileName.split('.').pop();
return (extension || '').toLowerCase();
};
export const formatNumberWithUnit = (num: number, locale: string = 'zh-CN'): string => {
if (num === 0) return '0';
if (!num || isNaN(num)) return '-';
const absNum = Math.abs(num);
const isNegative = num < 0;
const prefix = isNegative ? '-' : '';
if (locale === 'zh-CN') {
if (absNum >= 10000) {
const value = absNum / 10000;
const formatted = Number(value.toFixed(2)).toString();
return `${prefix}${formatted}`;
}
return num.toLocaleString(locale);
} else {
if (absNum >= 1000000) {
const value = absNum / 1000000;
const formatted = Number(value.toFixed(2)).toString();
return `${prefix}${formatted}M`;
}
if (absNum >= 1000) {
const value = absNum / 1000;
const formatted = Number(value.toFixed(2)).toString();
return `${prefix}${formatted}K`;
}
return num.toLocaleString(locale);
}
};

View File

@ -65,6 +65,7 @@ export type FastGPTFeConfigsType = {
show_compliance_copywriting?: boolean;
show_aiproxy?: boolean;
show_coupon?: boolean;
show_discount_coupon?: boolean;
concatMd?: string;
show_dataset_feishu?: boolean;

View File

@ -13,7 +13,8 @@ export enum AppLogKeysEnum {
ANNOTATED_COUNT = 'annotatedCount',
POINTS = 'points',
RESPONSE_TIME = 'responseTime',
ERROR_COUNT = 'errorCount'
ERROR_COUNT = 'errorCount',
REGION = 'region'
}
export const AppLogKeysEnumMap = {
@ -29,7 +30,8 @@ export const AppLogKeysEnumMap = {
[AppLogKeysEnum.ANNOTATED_COUNT]: i18nT('app:logs_keys_annotatedCount'),
[AppLogKeysEnum.POINTS]: i18nT('app:logs_keys_points'),
[AppLogKeysEnum.RESPONSE_TIME]: i18nT('app:logs_keys_responseTime'),
[AppLogKeysEnum.ERROR_COUNT]: i18nT('app:logs_keys_errorCount')
[AppLogKeysEnum.ERROR_COUNT]: i18nT('app:logs_keys_errorCount'),
[AppLogKeysEnum.REGION]: i18nT('app:logs_keys_region')
};
export const DefaultAppLogKeys = [
@ -45,7 +47,8 @@ export const DefaultAppLogKeys = [
{ key: AppLogKeysEnum.ANNOTATED_COUNT, enable: false },
{ key: AppLogKeysEnum.POINTS, enable: false },
{ key: AppLogKeysEnum.RESPONSE_TIME, enable: false },
{ key: AppLogKeysEnum.ERROR_COUNT, enable: false }
{ key: AppLogKeysEnum.ERROR_COUNT, enable: false },
{ key: AppLogKeysEnum.REGION, enable: true }
];
export enum AppLogTimespanEnum {

View File

@ -59,7 +59,6 @@ export const getHistoryPreview = (
return `![Input an image](${item.file.url.slice(0, 100)}...)`;
return '';
})
.filter(Boolean)
.join('\n') || ''
);
} else if (item.obj === ChatRoleEnum.AI) {

View File

@ -83,6 +83,9 @@ export type DatasetSchemaType = {
apiDatasetServer?: ApiDatasetServerType;
// 软删除字段
deleteTime?: Date | null;
// abandon
autoSync?: boolean;
externalReadUrl?: string;

View File

@ -3,6 +3,7 @@ import { ChatPath } from './core/chat';
import { ApiKeyPath } from './support/openapi';
import { TagsMap } from './tag';
import { PluginPath } from './core/plugin';
import { WalletPath } from './support/wallet';
export const openAPIDocument = createDocument({
openapi: '3.1.0',
@ -14,7 +15,8 @@ export const openAPIDocument = createDocument({
paths: {
...ChatPath,
...ApiKeyPath,
...PluginPath
...PluginPath,
...WalletPath
},
servers: [{ url: '/api' }],
'x-tagGroups': [
@ -33,6 +35,10 @@ export const openAPIDocument = createDocument({
{
name: 'ApiKey',
tags: [TagsMap.apiKey]
},
{
name: '支付',
tags: [TagsMap.walletBill, TagsMap.walletDiscountCoupon]
}
]
});

View File

@ -0,0 +1,96 @@
import { z } from 'zod';
import { ObjectIdSchema } from '../../../../common/type/mongo';
import {
BillTypeEnum,
BillStatusEnum,
BillPayWayEnum
} from '../../../../support/wallet/bill/constants';
import { StandardSubLevelEnum, SubModeEnum } from '../../../../support/wallet/sub/constants';
import { PaginationSchema } from '../../../api';
import { BillSchema } from '../../../../support/wallet/bill/type';
// Bill list
export const BillListQuerySchema = PaginationSchema.safeExtend({
type: z.enum(BillTypeEnum).optional().meta({ description: '订单类型筛选' })
});
export type GetBillListQueryType = z.infer<typeof BillListQuerySchema>;
export const BillListResponseSchema = z.object({
total: z.number(),
list: z.array(BillSchema)
});
export type GetBillListResponseType = z.infer<typeof BillListResponseSchema>;
// Create
export const CreateStandPlanBillSchema = z
.object({
type: z.literal(BillTypeEnum.standSubPlan).meta({ description: '订单类型:标准订阅套餐' }),
level: z.enum(StandardSubLevelEnum).meta({ description: '标准订阅等级' }),
subMode: z.enum(SubModeEnum).meta({ description: '订阅周期' }),
discountCouponId: z.string().optional().meta({ description: '优惠券 ID' })
})
.meta({ description: '标准订阅套餐订单创建参数' });
export const CreateExtractPointsBillSchema = z
.object({
type: z.literal(BillTypeEnum.extraPoints).meta({ description: '订单类型:额外积分' }),
extraPoints: z.int().min(0).meta({ description: '额外积分数量' }),
month: z.int().min(1).max(12).meta({ description: '订阅月数' }),
discountCouponId: z.string().optional().meta({ description: '优惠券 ID未使用' })
})
.meta({ description: '额外积分订单创建参数' });
export const CreateExtractDatasetBillSchema = z
.object({
type: z.literal(BillTypeEnum.extraDatasetSub).meta({ description: '订单类型:额外数据集存储' }),
extraDatasetSize: z.int().min(0).meta({ description: '额外数据集大小' }),
month: z.int().min(1).max(12).meta({ description: '订阅月数' }),
discountCouponId: z.string().optional().meta({ description: '优惠券 ID未使用' })
})
.meta({ description: '额外数据集存储订单创建参数' });
export const CreateBillPropsSchema = z.discriminatedUnion('type', [
CreateStandPlanBillSchema,
CreateExtractPointsBillSchema,
CreateExtractDatasetBillSchema
]);
export type CreateBillPropsType = z.infer<typeof CreateBillPropsSchema>;
export const UpdatePaymentPropsSchema = z.object({
billId: ObjectIdSchema,
payWay: z.enum(BillPayWayEnum)
});
export type UpdatePaymentPropsType = z.infer<typeof UpdatePaymentPropsSchema>;
export const UpdateBillResponseSchema = z
.object({
qrCode: z.string().optional().meta({ description: '支付二维码 URL' }),
iframeCode: z.string().optional().meta({ description: '支付 iframe 代码' }),
markdown: z.string().optional().meta({ description: 'Markdown 格式的支付信息' })
})
.refine((data) => data.qrCode || data.iframeCode || data.markdown, {
message: 'At least one of qrCode, iframeCode, or markdown must be provided'
});
export type UpdateBillResponseType = z.infer<typeof UpdateBillResponseSchema>;
export const CreateBillResponseSchema = UpdateBillResponseSchema.safeExtend({
billId: z.string().meta({ description: '订单 ID' }),
readPrice: z.number().min(0).meta({ description: '实际支付价格' }),
payment: z.enum(BillPayWayEnum).meta({ description: '支付方式' })
});
export type CreateBillResponseType = z.infer<typeof CreateBillResponseSchema>;
// Check pay result
export const CheckPayResultResponseSchema = z.object({
status: z.enum(BillStatusEnum),
description: z.string().optional()
});
export type CheckPayResultResponseType = z.infer<typeof CheckPayResultResponseSchema>;
// Bill detail
export const BillDetailResponseSchema = BillSchema.safeExtend({
couponName: z.string().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>;

View File

@ -0,0 +1,164 @@
import { z } from 'zod';
import type { OpenAPIPath } from '../../../type';
import {
CreateBillPropsSchema,
CreateBillResponseSchema,
UpdatePaymentPropsSchema,
UpdateBillResponseSchema,
CheckPayResultResponseSchema,
BillDetailResponseSchema,
BillListQuerySchema,
CancelBillPropsSchema
} from './api';
import { TagsMap } from '../../../tag';
import { ObjectIdSchema } from '../../../../common/type/mongo';
export const BillPath: OpenAPIPath = {
'/support/wallet/bill/create': {
post: {
summary: '创建订单',
description: '创建订单订单,支持标准订阅套餐、额外积分、额外数据集存储三种类型',
tags: [TagsMap.walletBill],
requestBody: {
content: {
'application/json': {
schema: CreateBillPropsSchema
}
}
},
responses: {
200: {
description: '成功创建订单',
content: {
'application/json': {
schema: CreateBillResponseSchema
}
}
}
}
}
},
'/support/wallet/bill/pay/updatePayment': {
post: {
summary: '更新支付方式',
description: '为未支付的订单更新支付方式,返回新的支付二维码或链接',
tags: [TagsMap.walletBill],
requestBody: {
content: {
'application/json': {
schema: UpdatePaymentPropsSchema
}
}
},
responses: {
200: {
description: '成功更新支付方式',
content: {
'application/json': {
schema: UpdateBillResponseSchema
}
}
}
}
}
},
'/support/wallet/bill/pay/checkPayResult': {
get: {
summary: '检查支付结果',
description: '检查订单的支付状态,用于轮询支付结果',
tags: [TagsMap.walletBill],
requestParams: {
query: z.object({
payId: ObjectIdSchema.meta({
description: '订单 ID'
})
})
},
responses: {
200: {
description: '成功获取支付结果',
content: {
'application/json': {
schema: CheckPayResultResponseSchema
}
}
}
}
}
},
'/support/wallet/bill/detail': {
get: {
summary: '获取订单详情',
description: '根据订单 ID 获取订单详细信息,包括优惠券名称等',
tags: [TagsMap.walletBill],
requestParams: {
query: z.object({
billId: ObjectIdSchema.meta({
description: '订单 ID'
})
})
},
responses: {
200: {
description: '成功获取订单详情',
content: {
'application/json': {
schema: BillDetailResponseSchema.nullable()
}
}
}
}
}
},
'/support/wallet/bill/list': {
post: {
summary: '获取订单列表',
description: '分页获取团队的订单列表,支持按类型筛选',
tags: [TagsMap.walletBill],
requestBody: {
content: {
'application/json': {
schema: BillListQuerySchema
}
}
},
responses: {
200: {
description: '成功获取订单列表',
content: {
'application/json': {
schema: z.object({
list: z.array(BillDetailResponseSchema),
total: z.number().meta({ description: '总数' })
})
}
}
}
}
}
},
'/support/wallet/bill/cancel': {
post: {
summary: '取消订单',
description: '取消未支付的订单,如果使用了优惠券会自动返还',
tags: [TagsMap.walletBill],
requestBody: {
content: {
'application/json': {
schema: CancelBillPropsSchema
}
}
},
responses: {
200: {
description: '成功取消订单',
content: {
'application/json': {
schema: z.null()
}
}
}
}
}
}
};

View File

@ -0,0 +1,33 @@
import { z } from 'zod';
import { ObjectIdSchema } from '../../../../common/type/mongo';
import {
DiscountCouponStatusEnum,
DiscountCouponTypeEnum
} from '../../../../support/wallet/sub/discountCoupon/constants';
export const DiscountCouponSchema = z.object({
_id: ObjectIdSchema.meta({ description: '优惠券 ID' }),
teamId: ObjectIdSchema.meta({ description: '团队 ID' }),
type: z.enum(Object.values(DiscountCouponTypeEnum)).meta({ description: '优惠券类型' }),
startTime: z.coerce.date().optional().meta({ description: '生效时间' }),
expiredTime: z.coerce.date().meta({ description: '过期时间' }),
usedAt: z.coerce.date().optional().meta({ description: '使用时间' }),
createTime: z.coerce.date().meta({ description: '创建时间' })
});
export type DiscountCouponSchemaType = z.infer<typeof DiscountCouponSchema>;
export const DiscountCouponItemSchema = DiscountCouponSchema.extend({
name: z.string().meta({ description: '优惠券名称' }),
description: z.string().meta({ description: '优惠券描述' }),
discount: z.number().min(0).max(1).meta({ description: '折扣率' }),
iconZh: z.string().meta({ description: '中文图标路径' }),
iconEn: z.string().meta({ description: '英文图标路径' }),
status: z.enum(DiscountCouponStatusEnum).meta({ description: '优惠券状态' }),
billId: ObjectIdSchema.optional().meta({
description: '关联的订单 ID, 被使用后该值存在'
})
});
export const DiscountCouponListResponseSchema = z.array(DiscountCouponItemSchema);
export type DiscountCouponListResponseType = z.infer<typeof DiscountCouponListResponseSchema>;

View File

@ -0,0 +1,23 @@
import type { OpenAPIPath } from '../../../type';
import { DiscountCouponListResponseSchema } from './api';
import { TagsMap } from '../../../tag';
export const DiscountCouponPath: OpenAPIPath = {
'/support/wallet/discountCoupon/list': {
get: {
summary: '获取优惠券列表',
description: '获取团队的优惠券列表,包括优惠券状态、使用情况等信息',
tags: [TagsMap.walletDiscountCoupon],
responses: {
200: {
description: '成功获取优惠券列表',
content: {
'application/json': {
schema: DiscountCouponListResponseSchema
}
}
}
}
}
}
};

View File

@ -0,0 +1,8 @@
import type { OpenAPIPath } from '../../type';
import { BillPath } from './bill';
import { DiscountCouponPath } from './discountCoupon';
export const WalletPath: OpenAPIPath = {
...BillPath,
...DiscountCouponPath
};

View File

@ -6,5 +6,7 @@ export const TagsMap = {
pluginAdmin: '管理员插件管理',
pluginToolAdmin: '管理员系统工具管理',
pluginTeam: '团队插件管理',
apiKey: 'APIKey'
apiKey: 'APIKey',
walletBill: '订单',
walletDiscountCoupon: '优惠券'
};

View File

@ -1,21 +1,24 @@
import type { MemberGroupSchemaType } from 'support/permission/memberGroup/type';
import { MemberGroupListItemType } from 'support/permission/memberGroup/type';
import type { MemberGroupSchemaType } from '../permission/memberGroup/type';
import { MemberGroupListItemType } from '../permission/memberGroup/type';
import type { OAuthEnum } from './constant';
import type { TrackRegisterParams } from './login/api';
import { TeamMemberStatusEnum } from './team/constant';
import type { OrgType } from './team/org/type';
import type { TeamMemberItemType } from './team/type';
import type { LangEnum } from '../../common/i18n/type';
import type { TrackRegisterParams } from '../marketing/type';
export type PostLoginProps = {
username: string;
password: string;
code: string;
language?: `${LangEnum}`;
};
export type OauthLoginProps = {
type: `${OAuthEnum}`;
callbackUrl: string;
props: Record<string, string>;
language?: `${LangEnum}`;
} & TrackRegisterParams;
export type WxLoginProps = {

View File

@ -17,13 +17,22 @@ export const InformLevelMap = {
};
export enum SendInformTemplateCodeEnum {
EXPIRE_SOON = 'EXPIRE_SOON',
EXPIRED = 'EXPIRED',
FREE_CLEAN = 'FREE_CLEAN',
REGISTER = 'REGISTER',
RESET_PASSWORD = 'RESET_PASSWORD',
BIND_NOTIFICATION = 'BIND_NOTIFICATION',
LACK_OF_POINTS = 'LACK_OF_POINTS',
CUSTOM = 'CUSTOM',
MANAGE_RENAME = 'MANAGE_RENAME'
REGISTER = 'REGISTER', // 注册
RESET_PASSWORD = 'RESET_PASSWORD', // 重置密码
BIND_NOTIFICATION = 'BIND_NOTIFICATION', // 绑定通知
EXPIRE_SOON = 'EXPIRE_SOON', // 即将过期
EXPIRED = 'EXPIRED', // 已过期
FREE_CLEAN = 'FREE_CLEAN', // 免费版清理
POINTS_THIRTY_PERCENT_REMAIN = 'POINTS_THIRTY_PERCENT_REMAIN', // 积分30%剩余
POINTS_TEN_PERCENT_REMAIN = 'POINTS_TEN_PERCENT_REMAIN', // 积分10%剩余
LACK_OF_POINTS = 'LACK_OF_POINTS', // 积分不足
DATASET_INDEX_NO_REMAIN = 'DATASET_INDEX_NO_REMAIN', // 数据集索引0剩余
DATASET_INDEX_TEN_PERCENT_REMAIN = 'DATASET_INDEX_TEN_PERCENT_REMAIN', // 数据集索引10%剩余
DATASET_INDEX_THIRTY_PERCENT_REMAIN = 'DATASET_INDEX_THIRTY_PERCENT_REMAIN', // 数据集索引30%剩余
MANAGE_RENAME = 'MANAGE_RENAME', // 管理员重命名
CUSTOM = 'CUSTOM' // 自定义
}

View File

@ -1,3 +1,4 @@
import type { LangEnum } from '../../../common/i18n/type';
import type { TrackRegisterParams } from '../../marketing/type';
export type GetWXLoginQRResponse = {
@ -9,4 +10,5 @@ export type AccountRegisterBody = {
username: string;
code: string;
password: string;
language?: `${LangEnum}`;
} & TrackRegisterParams;

View File

@ -1,3 +1,4 @@
import type { LangEnum } from '../common/i18n/type';
import type { TeamPermission } from '../permission/user/controller';
import type { UserStatusEnum } from './constant';
import type { TeamMemberStatusEnum } from './team/constant';
@ -12,6 +13,7 @@ export type UserModelSchema = {
openaiKey: string;
createTime: number;
timezone: string;
language: `${LangEnum}`;
status: `${UserStatusEnum}`;
lastLoginTmbId?: string;
passwordUpdateTime?: Date;
@ -26,9 +28,9 @@ export type UserType = {
username: string;
avatar: string; // it should be team member's avatar after 4.8.18
timezone: string;
language?: `${LangEnum}`;
promotionRate: UserModelSchema['promotionRate'];
team: TeamTmbItemType;
notificationAccount?: string;
permission: TeamPermission;
contact?: string;
};

View File

@ -1,44 +0,0 @@
import type { StandardSubLevelEnum, SubModeEnum } from '../sub/constants';
import type { BillTypeEnum, BillPayWayEnum } from './constants';
import { DrawBillQRItem } from './constants';
export type CreateOrderResponse = {
qrCode?: string;
iframeCode?: string;
markdown?: string;
};
export type CreateStandPlanBill = {
type: BillTypeEnum.standSubPlan;
level: `${StandardSubLevelEnum}`;
subMode: `${SubModeEnum}`;
};
type CreateExtractPointsBill = {
type: BillTypeEnum.extraPoints;
extraPoints: number;
};
type CreateExtractDatasetBill = {
type: BillTypeEnum.extraDatasetSub;
extraDatasetSize: number;
month: number;
};
export type CreateBillProps =
| CreateStandPlanBill
| CreateExtractPointsBill
| CreateExtractDatasetBill;
export type CreateBillResponse = {
billId: string;
readPrice: number;
payment: BillPayWayEnum;
} & CreateOrderResponse;
export type UpdatePaymentProps = {
billId: string;
payWay: BillPayWayEnum;
};
export type CheckPayResultResponse = {
status: BillStatusEnum;
description?: string;
};

View File

@ -1,13 +1,5 @@
import type { PriceOption } from './type';
// 根据积分获取月份
export const getMonthByPoints = (points: number) => {
if (points >= 200) return 12;
if (points >= 100) return 6;
if (points >= 50) return 3;
return 1;
};
// 根据月份获取积分下限
export const getMinPointsByMonth = (month: number): number => {
switch (month) {

View File

@ -1,65 +0,0 @@
import type { StandardSubLevelEnum, SubModeEnum } from '../sub/constants';
import { SubTypeEnum } from '../sub/constants';
import type { BillPayWayEnum, BillStatusEnum, BillTypeEnum } from './constants';
import type { TeamInvoiceHeaderType } from '../../user/team/type';
export type BillSchemaType = {
_id: string;
userId: string;
teamId: string;
tmbId: string;
createTime: Date;
orderId: string;
status: `${BillStatusEnum}`;
type: BillTypeEnum;
price: number;
hasInvoice: boolean;
metadata: {
payWay: `${BillPayWayEnum}`;
subMode?: `${SubModeEnum}`;
standSubLevel?: `${StandardSubLevelEnum}`;
month?: number;
datasetSize?: number;
extraPoints?: number;
};
refundData?: {
amount: number;
refundId: string;
refundTime: Date;
};
};
export type ChatNodeUsageType = {
inputTokens?: number;
outputTokens?: number;
totalPoints: number;
moduleName: string;
model?: string;
};
export type InvoiceType = {
amount: number;
billIdList: string[];
} & TeamInvoiceHeaderType;
export type InvoiceSchemaType = {
_id: string;
teamId: string;
status: 1 | 2;
createTime: Date;
finishTime?: Date;
file?: Buffer;
} & InvoiceType;
export type AIPointsPriceOption = {
type: 'points';
points: number;
};
export type DatasetPriceOption = {
type: 'dataset';
size: number;
month: number;
};
export type PriceOption = AIPointsPriceOption | DatasetPriceOption;

View File

@ -0,0 +1,76 @@
import { StandardSubLevelEnum, SubModeEnum } from '../sub/constants';
import { SubTypeEnum } from '../sub/constants';
import { BillPayWayEnum, BillStatusEnum, BillTypeEnum } from './constants';
import type { TeamInvoiceHeaderType } from '../../user/team/type';
import { z } from 'zod';
import { ObjectIdSchema } from '../../../common/type/mongo';
export const BillSchema = z.object({
_id: ObjectIdSchema.meta({ description: '订单 ID' }),
userId: ObjectIdSchema.meta({ description: '用户 ID' }),
teamId: ObjectIdSchema.meta({ description: '团队 ID' }),
tmbId: ObjectIdSchema.meta({ description: '团队成员 ID' }),
createTime: z.coerce.date().meta({ description: '创建时间' }),
orderId: z.string().meta({ description: '订单 ID' }),
status: z.enum(BillStatusEnum).meta({ description: '订单状态' }),
type: z.enum(BillTypeEnum).meta({ description: '订单类型' }),
price: z.number().meta({ description: '价格' }),
couponId: ObjectIdSchema.optional().meta({
description: '优惠券 ID'
}),
hasInvoice: z.boolean().meta({ description: '是否已开发票' }),
metadata: z
.object({
payWay: z.enum(BillPayWayEnum).meta({ description: '支付方式' }),
subMode: z.enum(SubModeEnum).optional().meta({ description: '订阅周期' }),
standSubLevel: z.enum(StandardSubLevelEnum).optional().meta({ description: '订阅等级' }),
month: z.number().optional().meta({ description: '月数' }),
datasetSize: z.number().optional().meta({ description: '数据集大小' }),
extraPoints: z.number().optional().meta({ description: '额外积分' })
})
.meta({ description: '元数据' }),
refundData: z
.object({
amount: z.number().meta({ description: '退款金额' }),
refundId: z.string().meta({ description: '退款 ID' }),
refundTime: z.date().meta({ description: '退款时间' })
})
.optional()
.meta({ description: '退款数据' })
});
export type BillSchemaType = z.infer<typeof BillSchema>;
export type ChatNodeUsageType = {
inputTokens?: number;
outputTokens?: number;
totalPoints: number;
moduleName: string;
model?: string;
};
export type InvoiceType = {
amount: number;
billIdList: string[];
} & TeamInvoiceHeaderType;
export type InvoiceSchemaType = {
_id: string;
teamId: string;
status: 1 | 2;
createTime: Date;
finishTime?: Date;
file?: Buffer;
} & InvoiceType;
export type AIPointsPriceOption = {
type: 'points';
points: number;
};
export type DatasetPriceOption = {
type: 'dataset';
size: number;
month: number;
};
export type PriceOption = AIPointsPriceOption | DatasetPriceOption;

View File

@ -9,17 +9,17 @@ export enum SubTypeEnum {
export const subTypeMap = {
[SubTypeEnum.standard]: {
label: 'support.wallet.subscription.type.standard',
label: i18nT('common:support.wallet.subscription.type.standard'),
icon: 'support/account/plans',
orderType: BillTypeEnum.standSubPlan
},
[SubTypeEnum.extraDatasetSize]: {
label: 'support.wallet.subscription.type.extraDatasetSize',
label: i18nT('common:support.wallet.subscription.type.extraDatasetSize'),
icon: 'core/dataset/datasetLight',
orderType: BillTypeEnum.extraDatasetSub
},
[SubTypeEnum.extraPoints]: {
label: 'support.wallet.subscription.type.extraPoints',
label: i18nT('common:support.wallet.subscription.type.extraPoints'),
icon: 'core/chat/chatLight',
orderType: BillTypeEnum.extraPoints
}
@ -31,12 +31,12 @@ export enum SubModeEnum {
}
export const subModeMap = {
[SubModeEnum.month]: {
label: 'support.wallet.subscription.mode.Month',
label: i18nT('common:support.wallet.subscription.mode.Month'),
durationMonth: 1,
payMonth: 1
},
[SubModeEnum.year]: {
label: 'support.wallet.subscription.mode.Year',
label: i18nT('common:support.wallet.subscription.mode.Year'),
durationMonth: 12,
payMonth: 10
}
@ -44,17 +44,39 @@ export const subModeMap = {
export enum StandardSubLevelEnum {
free = 'free',
basic = 'basic',
advanced = 'advanced',
custom = 'custom',
// @deprecated
experience = 'experience',
team = 'team',
enterprise = 'enterprise',
custom = 'custom'
enterprise = 'enterprise'
}
export const standardSubLevelMap = {
[StandardSubLevelEnum.free]: {
label: i18nT('common:support.wallet.subscription.standardSubLevel.free'),
desc: i18nT('common:support.wallet.subscription.standardSubLevel.free desc'),
weight: 1
},
[StandardSubLevelEnum.basic]: {
label: i18nT('common:support.wallet.subscription.standardSubLevel.basic'),
desc: i18nT('common:support.wallet.subscription.standardSubLevel.basic_desc'),
weight: 4
},
[StandardSubLevelEnum.advanced]: {
label: i18nT('common:support.wallet.subscription.standardSubLevel.advanced'),
desc: i18nT('common:support.wallet.subscription.standardSubLevel.advanced_desc'),
weight: 5
},
[StandardSubLevelEnum.custom]: {
label: i18nT('common:support.wallet.subscription.standardSubLevel.custom'),
desc: i18nT('common:support.wallet.subscription.standardSubLevel.custom_desc'),
weight: 7
},
// deprecated
[StandardSubLevelEnum.experience]: {
label: i18nT('common:support.wallet.subscription.standardSubLevel.experience'),
desc: i18nT('common:support.wallet.subscription.standardSubLevel.experience_desc'),
@ -68,11 +90,6 @@ export const standardSubLevelMap = {
[StandardSubLevelEnum.enterprise]: {
label: i18nT('common:support.wallet.subscription.standardSubLevel.enterprise'),
desc: i18nT('common:support.wallet.subscription.standardSubLevel.enterprise_desc'),
weight: 4
},
[StandardSubLevelEnum.custom]: {
label: i18nT('common:support.wallet.subscription.standardSubLevel.custom'),
desc: '',
weight: 5
weight: 6
}
};

View File

@ -1,12 +1,26 @@
import type { SubTypeEnum, StandardSubLevelEnum } from '../constants';
import type { CouponTypeEnum } from './constants';
export type CustomSubConfig = {
requestsPerMinute: number;
maxTeamMember: number;
maxAppAmount: number;
maxDatasetAmount: number;
chatHistoryStoreDuration: number;
maxDatasetSize: number;
websiteSyncPerDataset: number;
appRegistrationCount: number;
auditLogStoreDuration: number;
ticketResponseTime: number;
};
export type TeamCouponSub = {
type: `${SubTypeEnum}`; // Sub type
durationDay: number; // Duration day
level?: `${StandardSubLevelEnum}`; // Standard sub level
extraDatasetSize?: number; // Extra dataset size
totalPoints?: number; // Total points(Extrapoints or Standard sub)
customConfig?: CustomSubConfig; // Custom config for custom level (only required when level=custom)
};
export type TeamCouponSchema = {

View File

@ -0,0 +1,33 @@
import { i18nT } from '../../../../../web/i18n/utils';
export enum DiscountCouponTypeEnum {
monthStandardDiscount70 = 'monthStandardDiscount70',
yearStandardDiscount90 = 'yearStandardDiscount90'
}
export enum DiscountCouponStatusEnum {
active = 'active',
used = 'used',
expired = 'expired',
notStart = 'notStart'
}
// Discount coupon type config table, modify to add or update types.
export const discountCouponTypeMap = {
[DiscountCouponTypeEnum.monthStandardDiscount70]: {
type: DiscountCouponTypeEnum.monthStandardDiscount70,
name: i18nT('common:old_user_month_discount_70'),
description: i18nT('common:old_user_month_discount_70_description'),
discount: 0.7,
iconZh: '/imgs/system/discount70CN.svg',
iconEn: '/imgs/system/discount70EN.svg'
},
[DiscountCouponTypeEnum.yearStandardDiscount90]: {
type: DiscountCouponTypeEnum.yearStandardDiscount90,
name: i18nT('common:old_user_year_discount_90'),
description: i18nT('common:old_user_year_discount_90_description'),
discount: 0.9,
iconZh: '/imgs/system/discount90CN.svg',
iconEn: '/imgs/system/discount90EN.svg'
}
};

View File

@ -3,19 +3,28 @@ 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;
chatHistoryStoreDuration: number; // n day
maxDatasetSize: number;
trainingWeight: number; // 1~4
permissionCustomApiKey: boolean;
permissionCustomCopyright: boolean; // feature
permissionWebsiteSync: boolean;
permissionTeamOperationLog: boolean;
requestsPerMinute?: number;
appRegistrationCount?: number;
chatHistoryStoreDuration: number; // n day
websiteSyncPerDataset?: number;
auditLogStoreDuration?: number;
ticketResponseTime?: number;
// Custom plan specific fields
priceDescription?: string;
customFormUrl?: string;
customDescriptions?: string[];
};
export type StandSubPlanLevelMapType = Record<
@ -23,14 +32,21 @@ export type StandSubPlanLevelMapType = Record<
TeamStandardSubPlanItemType
>;
export type PointsPackageItem = {
points: number;
month: number;
price: number;
};
export type SubPlanType = {
[SubTypeEnum.standard]: StandSubPlanLevelMapType;
[SubTypeEnum.standard]?: StandSubPlanLevelMapType;
planDescriptionUrl?: string;
appRegistrationUrl?: string;
[SubTypeEnum.extraDatasetSize]: {
price: number;
};
[SubTypeEnum.extraPoints]: {
price: number;
packages: PointsPackageItem[];
};
};
@ -49,6 +65,15 @@ export type TeamSubSchema = {
maxApp?: number;
maxDataset?: number;
// custom level configurations
requestsPerMinute?: number;
chatHistoryStoreDuration?: number;
maxDatasetSize?: number;
websiteSyncPerDataset?: number;
appRegistrationCount?: number;
auditLogStoreDuration?: number;
ticketResponseTime?: number;
totalPoints: number;
surplusPoints: number;
@ -71,4 +96,5 @@ export type ClientTeamPlanStatusType = TeamPlanStatusType & {
usedAppAmount: number;
usedDatasetSize: number;
usedDatasetIndexSize: number;
usedRegistrationCount: number;
};

View File

@ -1,180 +0,0 @@
import { retryFn } from '@fastgpt/global/common/system/utils';
import { connectionMongo, Types } from '../../mongo';
import { MongoRawTextBufferSchema, bucketName } from './schema';
import { addLog } from '../../system/log';
import { setCron } from '../../system/cron';
import { checkTimerLock } from '../../system/timerLock/utils';
import { TimerIdEnum } from '../../system/timerLock/constants';
import { gridFsStream2Buffer } from '../../file/gridfs/utils';
import { readRawContentFromBuffer } from '../../../worker/function';
const getGridBucket = () => {
return new connectionMongo.mongo.GridFSBucket(connectionMongo.connection.db!, {
bucketName: bucketName
});
};
export const addRawTextBuffer = async ({
sourceId,
sourceName,
text,
expiredTime
}: {
sourceId: string;
sourceName: string;
text: string;
expiredTime: Date;
}) => {
const gridBucket = getGridBucket();
const metadata = {
sourceId,
sourceName,
expiredTime
};
const buffer = Buffer.from(text);
const fileSize = buffer.length;
// 单块大小:尽可能大,但不超过 14MB不小于128KB
const chunkSizeBytes = (() => {
// 计算理想块大小:文件大小 ÷ 目标块数(10)。 并且每个块需要小于 14MB
const idealChunkSize = Math.min(Math.ceil(fileSize / 10), 14 * 1024 * 1024);
// 确保块大小至少为128KB
const minChunkSize = 128 * 1024; // 128KB
// 取理想块大小和最小块大小中的较大值
let chunkSize = Math.max(idealChunkSize, minChunkSize);
// 将块大小向上取整到最接近的64KB的倍数使其更整齐
chunkSize = Math.ceil(chunkSize / (64 * 1024)) * (64 * 1024);
return chunkSize;
})();
const uploadStream = gridBucket.openUploadStream(sourceId, {
metadata,
chunkSizeBytes
});
return retryFn(async () => {
return new Promise((resolve, reject) => {
uploadStream.end(buffer);
uploadStream.on('finish', () => {
resolve(uploadStream.id);
});
uploadStream.on('error', (error) => {
addLog.error('addRawTextBuffer error', error);
resolve('');
});
});
});
};
export const getRawTextBuffer = async (sourceId: string) => {
const gridBucket = getGridBucket();
return retryFn(async () => {
const bufferData = await MongoRawTextBufferSchema.findOne(
{
'metadata.sourceId': sourceId
},
'_id metadata'
).lean();
if (!bufferData) {
return null;
}
// Read file content
const downloadStream = gridBucket.openDownloadStream(new Types.ObjectId(bufferData._id));
const fileBuffers = await gridFsStream2Buffer(downloadStream);
const rawText = await (async () => {
if (fileBuffers.length < 10000000) {
return fileBuffers.toString('utf8');
} else {
return (
await readRawContentFromBuffer({
extension: 'txt',
encoding: 'utf8',
buffer: fileBuffers
})
).rawText;
}
})();
return {
text: rawText,
sourceName: bufferData.metadata?.sourceName || ''
};
});
};
export const deleteRawTextBuffer = async (sourceId: string): Promise<boolean> => {
const gridBucket = getGridBucket();
return retryFn(async () => {
const buffer = await MongoRawTextBufferSchema.findOne({ 'metadata.sourceId': sourceId });
if (!buffer) {
return false;
}
await gridBucket.delete(new Types.ObjectId(buffer._id));
return true;
});
};
export const updateRawTextBufferExpiredTime = async ({
sourceId,
expiredTime
}: {
sourceId: string;
expiredTime: Date;
}) => {
return retryFn(async () => {
return MongoRawTextBufferSchema.updateOne(
{ 'metadata.sourceId': sourceId },
{ $set: { 'metadata.expiredTime': expiredTime } }
);
});
};
export const clearExpiredRawTextBufferCron = async () => {
const gridBucket = getGridBucket();
const clearExpiredRawTextBuffer = async () => {
addLog.debug('Clear expired raw text buffer start');
const data = await MongoRawTextBufferSchema.find(
{
'metadata.expiredTime': { $lt: new Date() }
},
'_id'
).lean();
for (const item of data) {
try {
await gridBucket.delete(new Types.ObjectId(item._id));
} catch (error) {
addLog.error('Delete expired raw text buffer error', error);
}
}
addLog.debug('Clear expired raw text buffer end');
};
setCron('*/10 * * * *', async () => {
if (
await checkTimerLock({
timerId: TimerIdEnum.clearExpiredRawTextBuffer,
lockMinuted: 9
})
) {
try {
await clearExpiredRawTextBuffer();
} catch (error) {
addLog.error('clearExpiredRawTextBufferCron error', error);
}
}
});
};

View File

@ -1,22 +0,0 @@
import { getMongoModel, type Types, Schema } from '../../mongo';
export const bucketName = 'buffer_rawtext';
const RawTextBufferSchema = new Schema({
metadata: {
sourceId: { type: String, required: true },
sourceName: { type: String, required: true },
expiredTime: { type: Date, required: true }
}
});
RawTextBufferSchema.index({ 'metadata.sourceId': 'hashed' });
RawTextBufferSchema.index({ 'metadata.expiredTime': -1 });
export const MongoRawTextBufferSchema = getMongoModel<{
_id: Types.ObjectId;
metadata: {
sourceId: string;
sourceName: string;
expiredTime: Date;
};
}>(`${bucketName}.files`, RawTextBufferSchema);

View File

@ -22,7 +22,10 @@ export enum QueueNames {
datasetSync = 'datasetSync',
evaluation = 'evaluation',
s3FileDelete = 's3FileDelete',
// abondoned
// Delete Queue
datasetDelete = 'datasetDelete',
// @deprecated
websiteSync = 'websiteSync'
}

View File

@ -1,16 +1,6 @@
import { Types, connectionMongo, ReadPreference } from '../../mongo';
import type { BucketNameEnum } from '@fastgpt/global/common/file/constants';
import fsp from 'fs/promises';
import fs from 'fs';
import { type DatasetFileSchema } from '@fastgpt/global/core/dataset/type';
import { MongoChatFileSchema, MongoDatasetFileSchema } from './schema';
import { detectFileEncodingByPath } from '@fastgpt/global/common/file/tools';
import { computeGridFsChunSize, stream2Encoding } from './utils';
import { addLog } from '../../system/log';
import { Readable } from 'stream';
import { retryFn } from '@fastgpt/global/common/system/utils';
import { getS3DatasetSource } from '../../s3/sources/dataset';
import { isS3ObjectKey } from '../../s3/utils';
export function getGFSCollection(bucket: `${BucketNameEnum}`) {
MongoDatasetFileSchema;
@ -18,6 +8,7 @@ export function getGFSCollection(bucket: `${BucketNameEnum}`) {
return connectionMongo.connection.db!.collection(`${bucket}.files`);
}
export function getGridBucket(bucket: `${BucketNameEnum}`) {
return new connectionMongo.mongo.GridFSBucket(connectionMongo.connection.db!, {
bucketName: bucket,
@ -26,106 +17,6 @@ export function getGridBucket(bucket: `${BucketNameEnum}`) {
});
}
/* crud file */
export async function uploadFile({
bucketName,
teamId,
uid,
path,
filename,
contentType,
metadata = {}
}: {
bucketName: `${BucketNameEnum}`;
teamId: string;
uid: string; // tmbId / outLinkUId
path: string;
filename: string;
contentType?: string;
metadata?: Record<string, any>;
}) {
if (!path) return Promise.reject(`filePath is empty`);
if (!filename) return Promise.reject(`filename is empty`);
const stats = await fsp.stat(path);
if (!stats.isFile()) return Promise.reject(`${path} is not a file`);
const readStream = fs.createReadStream(path, {
highWaterMark: 256 * 1024
});
// Add default metadata
metadata.teamId = teamId;
metadata.uid = uid;
metadata.encoding = await detectFileEncodingByPath(path);
// create a gridfs bucket
const bucket = getGridBucket(bucketName);
const chunkSizeBytes = computeGridFsChunSize(stats.size);
const stream = bucket.openUploadStream(filename, {
metadata,
contentType,
chunkSizeBytes
});
// save to gridfs
await new Promise((resolve, reject) => {
readStream
.pipe(stream as any)
.on('finish', resolve)
.on('error', reject);
}).finally(() => {
readStream.destroy();
});
return String(stream.id);
}
export async function getFileById({
bucketName,
fileId
}: {
bucketName: `${BucketNameEnum}`;
fileId: string;
}) {
const db = getGFSCollection(bucketName);
const file = await db.findOne<DatasetFileSchema>({
_id: new Types.ObjectId(fileId)
});
return file || undefined;
}
export async function delFileByFileIdList({
bucketName,
fileIdList
}: {
bucketName: `${BucketNameEnum}`;
fileIdList: string[];
}): Promise<any> {
return retryFn(async () => {
const bucket = getGridBucket(bucketName);
for await (const fileId of fileIdList) {
try {
if (isS3ObjectKey(fileId, 'dataset')) {
await getS3DatasetSource().deleteDatasetFileByKey(fileId);
} else {
await bucket.delete(new Types.ObjectId(String(fileId)));
}
} catch (error: any) {
if (typeof error?.message === 'string' && error.message.includes('File not found')) {
addLog.warn('File not found', { fileId });
return;
}
return Promise.reject(error);
}
}
});
}
export async function getDownloadStream({
bucketName,
fileId

View File

@ -1,78 +1,5 @@
import { detectFileEncoding } from '@fastgpt/global/common/file/tools';
import { PassThrough } from 'stream';
import { getGridBucket } from './controller';
import { type BucketNameEnum } from '@fastgpt/global/common/file/constants';
import { retryFn } from '@fastgpt/global/common/system/utils';
export const createFileFromText = async ({
bucket,
filename,
text,
metadata
}: {
bucket: `${BucketNameEnum}`;
filename: string;
text: string;
metadata: Record<string, any>;
}) => {
const gridBucket = getGridBucket(bucket);
const buffer = Buffer.from(text);
const fileSize = buffer.length;
// 单块大小:尽可能大,但不超过 14MB不小于128KB
const chunkSizeBytes = (() => {
// 计算理想块大小:文件大小 ÷ 目标块数(10)。 并且每个块需要小于 14MB
const idealChunkSize = Math.min(Math.ceil(fileSize / 10), 14 * 1024 * 1024);
// 确保块大小至少为128KB
const minChunkSize = 128 * 1024; // 128KB
// 取理想块大小和最小块大小中的较大值
let chunkSize = Math.max(idealChunkSize, minChunkSize);
// 将块大小向上取整到最接近的64KB的倍数使其更整齐
chunkSize = Math.ceil(chunkSize / (64 * 1024)) * (64 * 1024);
return chunkSize;
})();
const uploadStream = gridBucket.openUploadStream(filename, {
metadata,
chunkSizeBytes
});
return retryFn(async () => {
return new Promise<{ fileId: string }>((resolve, reject) => {
uploadStream.end(buffer);
uploadStream.on('finish', () => {
resolve({ fileId: String(uploadStream.id) });
});
uploadStream.on('error', reject);
});
});
};
export const gridFsStream2Buffer = (stream: NodeJS.ReadableStream) => {
return new Promise<Buffer>((resolve, reject) => {
if (!stream.readable) {
return resolve(Buffer.from([]));
}
const chunks: Uint8Array[] = [];
stream.on('data', (chunk) => {
chunks.push(chunk);
});
stream.on('end', () => {
const resultBuffer = Buffer.concat(chunks); // One-time splicing
resolve(resultBuffer);
});
stream.on('error', (err) => {
reject(err);
});
});
};
export const stream2Encoding = async (stream: NodeJS.ReadableStream) => {
const copyStream = stream.pipe(new PassThrough());
@ -109,20 +36,3 @@ export const stream2Encoding = async (stream: NodeJS.ReadableStream) => {
stream: copyStream
};
};
// 单块大小:尽可能大,但不超过 14MB不小于512KB
export const computeGridFsChunSize = (fileSize: number) => {
// 计算理想块大小:文件大小 ÷ 目标块数(10)。 并且每个块需要小于 14MB
const idealChunkSize = Math.min(Math.ceil(fileSize / 10), 14 * 1024 * 1024);
// 确保块大小至少为512KB
const minChunkSize = 512 * 1024; // 512KB
// 取理想块大小和最小块大小中的较大值
let chunkSize = Math.max(idealChunkSize, minChunkSize);
// 将块大小向上取整到最接近的64KB的倍数使其更整齐
chunkSize = Math.ceil(chunkSize / (64 * 1024)) * (64 * 1024);
return chunkSize;
};

View File

@ -78,9 +78,10 @@ export const copyAvatarImage = async ({
const avatarSource = getS3AvatarSource();
if (isS3ObjectKey(imageUrl?.slice(avatarSource.prefix.length), 'avatar')) {
const filename = (() => {
const last = imageUrl.split('/').pop()?.split('-')[1];
const last = imageUrl.split('/').pop();
if (!last) return getNanoid(6).concat(path.extname(imageUrl));
return `${getNanoid(6)}-${last}`;
const firstDashIndex = last.indexOf('-');
return `${getNanoid(6)}-${firstDashIndex === -1 ? last : last.slice(firstDashIndex + 1)}`;
})();
const key = await getS3AvatarSource().copyAvatar({
key: imageUrl,

View File

@ -1,152 +1,138 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import multer from 'multer';
import path from 'path';
import type { BucketNameEnum } from '@fastgpt/global/common/file/constants';
import { bucketNameMap } from '@fastgpt/global/common/file/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { UserError } from '@fastgpt/global/common/error/utils';
import m from 'multer';
import type { NextApiRequest } from 'next';
import path from 'path';
import fs from 'node:fs';
export type FileType = {
fieldname: string;
originalname: string;
encoding: string;
mimetype: string;
filename: string;
path: string;
size: number;
};
/*
maxSize: File max size (MB)
*/
export const getUploadModel = ({ maxSize = 500 }: { maxSize?: number }) => {
maxSize *= 1024 * 1024;
class UploadModel {
uploaderSingle = multer({
limits: {
fieldSize: maxSize
},
preservePath: true,
storage: multer.diskStorage({
// destination: (_req, _file, cb) => {
// cb(null, tmpFileDirPath);
// },
filename: (req, file, cb) => {
if (!file?.originalname) {
cb(new Error('File not found'), '');
} else {
const { ext } = path.parse(decodeURIComponent(file.originalname));
cb(null, `${getNanoid()}${ext}`);
}
}
})
}).single('file');
async getUploadFile<T = any>(
req: NextApiRequest,
res: NextApiResponse,
originBucketName?: `${BucketNameEnum}`
) {
return new Promise<{
file: FileType;
metadata: Record<string, any>;
data: T;
bucketName?: `${BucketNameEnum}`;
}>((resolve, reject) => {
// @ts-ignore
this.uploaderSingle(req, res, (error) => {
if (error) {
return reject(error);
}
// check bucket name
const bucketName = (req.body?.bucketName || originBucketName) as `${BucketNameEnum}`;
if (bucketName && !bucketNameMap[bucketName]) {
return reject(new UserError('BucketName is invalid'));
}
// @ts-ignore
const file = req.file as FileType;
resolve({
file: {
...file,
originalname: decodeURIComponent(file.originalname)
},
bucketName,
metadata: (() => {
if (!req.body?.metadata) return {};
try {
return JSON.parse(req.body.metadata);
} catch (error) {
return {};
}
})(),
data: (() => {
if (!req.body?.data) return {};
try {
return JSON.parse(req.body.data);
} catch (error) {
return {};
}
})()
});
});
});
export const multer = {
_storage: m.diskStorage({
filename: (_, file, cb) => {
if (!file?.originalname) {
cb(new Error('File not found'), '');
} else {
const ext = path.extname(decodeURIComponent(file.originalname));
cb(null, `${getNanoid()}${ext}`);
}
}
}),
uploaderMultiple = multer({
singleStore(maxFileSize: number = 500) {
const fileSize = maxFileSize * 1024 * 1024;
return m({
limits: {
fieldSize: maxSize
fileSize
},
preservePath: true,
storage: multer.diskStorage({
// destination: (_req, _file, cb) => {
// cb(null, tmpFileDirPath);
// },
filename: (req, file, cb) => {
if (!file?.originalname) {
cb(new Error('File not found'), '');
} else {
const { ext } = path.parse(decodeURIComponent(file.originalname));
cb(null, `${getNanoid()}${ext}`);
}
}
})
storage: this._storage
}).single('file');
},
multipleStore(maxFileSize: number = 500) {
const fileSize = maxFileSize * 1024 * 1024;
return m({
limits: {
fileSize
},
preservePath: true,
storage: this._storage
}).array('file', global.feConfigs?.uploadFileMaxSize);
async getUploadFiles<T = any>(req: NextApiRequest, res: NextApiResponse) {
return new Promise<{
files: FileType[];
data: T;
}>((resolve, reject) => {
// @ts-ignore
this.uploaderMultiple(req, res, (error) => {
if (error) {
console.log(error);
return reject(error);
},
resolveFormData<T extends Record<string, any>>({
request,
maxFileSize
}: {
request: NextApiRequest;
maxFileSize?: number;
}) {
return new Promise<{
data: T;
fileMetadata: Express.Multer.File;
getBuffer: () => Buffer;
getReadStream: () => fs.ReadStream;
}>((resolve, reject) => {
const handler = this.singleStore(maxFileSize);
// @ts-expect-error it can accept a NextApiRequest
handler(request, null, (error) => {
if (error) {
return reject(error);
}
// @ts-expect-error `file` will be injected by multer
const file = request.file as Express.Multer.File;
if (!file) {
return reject(new Error('File not found'));
}
const data = (() => {
if (!request.body?.data) return {};
try {
return JSON.parse(request.body.data);
} catch {
return {};
}
})();
// @ts-ignore
const files = req.files as FileType[];
resolve({
files: files.map((file) => ({
...file,
originalname: decodeURIComponent(file.originalname)
})),
data: (() => {
if (!req.body?.data) return {};
try {
return JSON.parse(req.body.data);
} catch (error) {
return {};
}
})()
});
resolve({
data,
fileMetadata: file,
getBuffer: () => fs.readFileSync(file.path),
getReadStream: () => fs.createReadStream(file.path)
});
});
});
},
resolveMultipleFormData<T extends Record<string, any>>({
request,
maxFileSize
}: {
request: NextApiRequest;
maxFileSize?: number;
}) {
return new Promise<{
data: T;
fileMetadata: Array<Express.Multer.File>;
}>((resolve, reject) => {
const handler = this.multipleStore(maxFileSize);
// @ts-expect-error it can accept a NextApiRequest
handler(request, null, (error) => {
if (error) {
return reject(error);
}
// @ts-expect-error `files` will be injected by multer
const files = request.files as Array<Express.Multer.File>;
if (!files || files.length === 0) {
return reject(new Error('File not found'));
}
const data = (() => {
if (!request.body?.data) return {};
try {
return JSON.parse(request.body.data);
} catch {
return {};
}
})();
resolve({
data,
fileMetadata: files
});
});
});
},
clearDiskTempFiles(filepaths: string[]) {
for (const filepath of filepaths) {
fs.rm(filepath, { force: true }, (_) => {});
}
}
return new UploadModel();
};

View File

@ -0,0 +1,15 @@
import path from 'node:path';
import type { LocationName } from './type';
export const dbPath = path.join(process.cwd(), 'data/GeoLite2-City.mmdb');
export const privateOrOtherLocationName: LocationName = {
city: undefined,
country: {
en: 'Other',
zh: '其他'
},
province: undefined
};
export const cleanupIntervalMs = 6 * 60 * 60 * 1000; // Run cleanup every 6 hours

View File

@ -0,0 +1,107 @@
import fs from 'node:fs';
import type { ReaderModel } from '@maxmind/geoip2-node';
import { Reader } from '@maxmind/geoip2-node';
import { cleanupIntervalMs, dbPath, privateOrOtherLocationName } from './constants';
import type { I18nName, LocationName } from './type';
import { extractLocationData } from './utils';
import type { NextApiRequest } from 'next';
import { getClientIp } from 'request-ip';
import { addLog } from '../system/log';
let reader: ReaderModel | null = null;
const locationIpMap = new Map<string, LocationName>();
function loadGeoDB() {
const dbBuffer = fs.readFileSync(dbPath);
reader = Reader.openBuffer(dbBuffer);
return reader;
}
export function getGeoReader() {
if (!reader) {
return loadGeoDB();
}
return reader;
}
export function getLocationFromIp(ip?: string, locale: keyof I18nName = 'zh') {
if (!ip) {
return privateOrOtherLocationName.country?.[locale];
}
const reader = getGeoReader();
let locationName = locationIpMap.get(ip);
if (locationName) {
return [
locationName.country?.[locale],
locationName.province?.[locale],
locationName.city?.[locale]
]
.filter(Boolean)
.join(locale === 'zh' ? '' : ',');
}
try {
const response = reader.city(ip);
const data = extractLocationData(response);
locationName = {
city: {
en: data.city.en,
zh: data.city.zh
},
country: {
en: data.country.en,
zh: data.country.zh
},
province: {
en: data.province.en,
zh: data.province.zh
}
};
locationIpMap.set(ip, locationName);
return [
locationName.country?.[locale],
locationName.province?.[locale],
locationName.city?.[locale]
]
.filter(Boolean)
.join(locale === 'zh' ? '' : ', ');
} catch (error) {
locationIpMap.set(ip, privateOrOtherLocationName);
return privateOrOtherLocationName.country?.[locale];
}
}
let cleanupInterval: NodeJS.Timeout | null = null;
function cleanupIpMap() {
locationIpMap.clear();
}
export function clearCleanupInterval() {
if (cleanupInterval) {
clearInterval(cleanupInterval);
cleanupInterval = null;
}
}
export function initGeo() {
cleanupInterval = setInterval(cleanupIpMap, cleanupIntervalMs);
try {
loadGeoDB();
} catch (error) {
clearCleanupInterval();
addLog.error(`Failed to load geo db`, error);
throw error;
}
}
export function getIpFromRequest(request: NextApiRequest): string {
const ip = getClientIp(request);
if (!ip || ip === '::1') {
return '127.0.0.1';
}
return ip;
}

View File

@ -0,0 +1,10 @@
export type I18nName = {
zh?: string;
en?: string;
};
export type LocationName = {
country?: I18nName;
province?: I18nName;
city?: I18nName;
};

View File

@ -0,0 +1,21 @@
import type { City } from '@maxmind/geoip2-node';
export function extractLocationData(response: City) {
return {
city: {
id: response.city?.geonameId,
en: response.city?.names.en,
zh: response.city?.names['zh-CN']
},
country: {
id: response.country?.geonameId,
en: response.country?.names.en,
zh: response.country?.names['zh-CN']
},
province: {
id: response.subdivisions?.[0]?.geonameId,
en: response.subdivisions?.[0]?.names.en,
zh: response.subdivisions?.[0]?.names['zh-CN']
}
};
}

View File

@ -15,6 +15,13 @@ const getCurrentTenMinuteBoundary = () => {
return boundary;
};
const getCurrentMinuteBoundary = () => {
const now = new Date();
const boundary = new Date(now);
boundary.setSeconds(0, 0);
return boundary;
};
export const trackTimerProcess = async () => {
while (true) {
await countTrackTimer();
@ -31,7 +38,8 @@ export const countTrackTimer = async () => {
global.countTrackQueue = new Map();
try {
const currentBoundary = getCurrentTenMinuteBoundary();
const currentTenMinuteBoundary = getCurrentTenMinuteBoundary();
const currentMinuteBoundary = getCurrentMinuteBoundary();
const bulkOps = queuedItems
.map(({ event, count, data }) => {
@ -44,7 +52,7 @@ export const countTrackTimer = async () => {
filter: {
event,
teamId,
createTime: currentBoundary,
createTime: currentTenMinuteBoundary,
'data.datasetId': datasetId
},
update: [
@ -52,7 +60,7 @@ export const countTrackTimer = async () => {
$set: {
event,
teamId,
createTime: { $ifNull: ['$createTime', currentBoundary] },
createTime: { $ifNull: ['$createTime', currentTenMinuteBoundary] },
data: {
datasetId,
count: { $add: [{ $ifNull: ['$data.count', 0] }, count] }
@ -65,6 +73,36 @@ export const countTrackTimer = async () => {
}
];
}
if (event === TrackEnum.teamChatQPM) {
const { teamId } = data;
return [
{
updateOne: {
filter: {
event,
teamId,
createTime: currentMinuteBoundary
},
update: [
{
$set: {
event,
teamId,
createTime: { $ifNull: ['$createTime', currentMinuteBoundary] },
data: {
requestCount: { $add: [{ $ifNull: ['$data.requestCount', 0] }, count] }
}
}
}
],
upsert: true
}
}
];
}
return [];
})
.flat();

View File

@ -146,5 +146,15 @@ export const pushTrack = {
}
});
});
},
teamChatQPM: (data: { teamId: string }) => {
if (!data.teamId) return;
pushCountTrack({
event: TrackEnum.teamChatQPM,
key: `${TrackEnum.teamChatQPM}_${data.teamId}`,
data: {
teamId: data.teamId
}
});
}
};

View File

@ -64,8 +64,7 @@ export async function connectMongo(props: {
maxIdleTimeMS: 300000, // 空闲连接超时: 5分钟,防止空闲连接长时间占用资源
retryWrites: true, // 重试写入: 重试写入失败的操作
retryReads: true, // 重试读取: 重试读取失败的操作
serverSelectionTimeoutMS: 10000, // 服务器选择超时: 10秒,防止副本集故障时长时间阻塞
w: 'majority' // 写入确认策略: 多数节点确认后返回,保证数据安全性
serverSelectionTimeoutMS: 10000 // 服务器选择超时: 10秒,防止副本集故障时长时间阻塞
});
console.log('mongo connected');

View File

@ -160,6 +160,18 @@ export class S3BaseBucket {
return this.client.statObject(this.name, ...params);
}
async isObjectExists(key: string): Promise<boolean> {
try {
await this.client.statObject(this.name, key);
return true;
} catch (err) {
if (err instanceof S3Error && err.message === 'Not Found') {
return false;
}
return Promise.reject(err);
}
}
async fileStreamToBuffer(stream: Readable): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
@ -183,10 +195,7 @@ export class S3BaseBucket {
const ext = path.extname(filename).toLowerCase();
const contentType = Mimes[ext as keyof typeof Mimes] ?? 'application/octet-stream';
const key = (() => {
if ('rawKey' in params) return params.rawKey;
return [params.source, params.teamId, `${getNanoid(6)}-${filename}`].join('/');
})();
const key = params.rawKey;
const policy = this.externalClient.newPostPolicy();
policy.setKey(key);
@ -230,9 +239,7 @@ export class S3BaseBucket {
const { key, expiredHours } = parsed;
const expires = expiredHours ? expiredHours * 60 * 60 : 30 * 60; // expires 的单位是秒 默认 30 分钟
return await this.externalClient.presignedGetObject(this.name, key, expires, {
'Content-Disposition': `attachment; filename="${path.basename(key)}"`
});
return await this.externalClient.presignedGetObject(this.name, key, expires);
}
async createPreviewUrl(params: createPreviewUrlParams) {
@ -241,8 +248,6 @@ export class S3BaseBucket {
const { key, expiredHours } = parsed;
const expires = expiredHours ? expiredHours * 60 * 60 : 30 * 60; // expires 的单位是秒 默认 30 分钟
return await this.client.presignedGetObject(this.name, key, expires, {
'Content-Disposition': `attachment; filename="${path.basename(key)}"`
});
return await this.client.presignedGetObject(this.name, key, expires);
}
}

View File

@ -35,6 +35,7 @@ export const defaultS3Options: {
accessKey: process.env.S3_ACCESS_KEY || 'minioadmin',
secretKey: process.env.S3_SECRET_KEY || 'minioadmin',
port: process.env.S3_PORT ? parseInt(process.env.S3_PORT) : 9000,
pathStyle: process.env.S3_PATH_STYLE === 'false' ? false : true,
transportAgent: process.env.HTTP_PROXY
? new HttpProxyAgent(process.env.HTTP_PROXY)
: process.env.HTTPS_PROXY

View File

@ -3,6 +3,7 @@ import { MongoS3TTL } from '../schema';
import { S3PublicBucket } from '../buckets/public';
import { imageBaseUrl } from '@fastgpt/global/common/file/image/constants';
import type { ClientSession } from 'mongoose';
import { getFileS3Key } from '../utils';
class S3AvatarSource {
private bucket: S3PublicBucket;
@ -29,8 +30,10 @@ class S3AvatarSource {
teamId: string;
autoExpired?: boolean;
}) {
const { fileKey } = getFileS3Key.avatar({ teamId, filename });
return this.bucket.createPostPresignedUrl(
{ filename, teamId, source: S3Sources.avatar },
{ filename, rawKey: fileKey },
{
expiredHours: autoExpired ? 1 : undefined, // 1 Hours
maxFileSize: 5 // 5MB

View File

@ -2,6 +2,8 @@ import { S3Sources } from '../../type';
import { S3PrivateBucket } from '../../buckets/private';
import { parseFileExtensionFromUrl } from '@fastgpt/global/common/string/tools';
import {
type AddRawTextBufferParams,
AddRawTextBufferParamsSchema,
type CreateGetDatasetFileURLParams,
CreateGetDatasetFileURLParamsSchema,
type CreateUploadDatasetFileParams,
@ -10,18 +12,20 @@ import {
DeleteDatasetFilesByPrefixParamsSchema,
type GetDatasetFileContentParams,
GetDatasetFileContentParamsSchema,
type UploadDatasetFileByBufferParams,
UploadDatasetFileByBufferParamsSchema
type GetRawTextBufferParams,
type UploadParams,
UploadParamsSchema
} from './type';
import { MongoS3TTL } from '../../schema';
import { addHours, addMinutes } from 'date-fns';
import { addLog } from '../../../system/log';
import { detectFileEncoding } from '@fastgpt/global/common/file/tools';
import { readS3FileContentByBuffer } from '../../../file/read/utils';
import { addRawTextBuffer, getRawTextBuffer } from '../../../buffer/rawText/controller';
import path from 'node:path';
import { Mimes } from '../../constants';
import { getFileS3Key, truncateFilename } from '../../utils';
import { createHash } from 'node:crypto';
import { S3Error } from 'minio';
export class S3DatasetSource {
public bucket: S3PrivateBucket;
@ -61,8 +65,8 @@ export class S3DatasetSource {
*
**/
deleteDatasetFilesByPrefix(params: DeleteDatasetFilesByPrefixParams) {
const { datasetId, rawPrefix } = DeleteDatasetFilesByPrefixParamsSchema.parse(params);
const prefix = rawPrefix || [S3Sources.dataset, datasetId].filter(Boolean).join('/');
const { datasetId } = DeleteDatasetFilesByPrefixParamsSchema.parse(params);
const prefix = [S3Sources.dataset, datasetId].filter(Boolean).join('/');
return this.bucket.addDeleteJob({ prefix });
}
@ -83,7 +87,14 @@ export class S3DatasetSource {
// 获取文件状态
getDatasetFileStat(key: string) {
return this.bucket.statObject(key);
try {
return this.bucket.statObject(key);
} catch (error) {
if (error instanceof S3Error && error.message === 'Not Found') {
return null;
}
return Promise.reject(error);
}
}
// 获取文件元数据
@ -117,12 +128,11 @@ export class S3DatasetSource {
const { fileId, teamId, tmbId, customPdfParse, getFormatText, usageId } =
GetDatasetFileContentParamsSchema.parse(params);
const bufferId = `${fileId}-${customPdfParse}`;
const fileBuffer = await getRawTextBuffer(bufferId);
if (fileBuffer) {
const rawTextBuffer = await this.getRawTextBuffer({ customPdfParse, sourceId: fileId });
if (rawTextBuffer) {
return {
rawText: fileBuffer.text,
filename: fileBuffer.sourceName
rawText: rawTextBuffer.text,
filename: rawTextBuffer.filename
};
}
@ -154,11 +164,11 @@ export class S3DatasetSource {
}
});
addRawTextBuffer({
sourceId: bufferId,
this.addRawTextBuffer({
sourceId: fileId,
sourceName: filename,
text: rawText,
expiredTime: addMinutes(new Date(), 20)
customPdfParse
});
return {
@ -168,25 +178,85 @@ export class S3DatasetSource {
}
// 根据文件 Buffer 上传文件
async uploadDatasetFileByBuffer(params: UploadDatasetFileByBufferParams): Promise<string> {
const { datasetId, buffer, filename } = UploadDatasetFileByBufferParamsSchema.parse(params);
async upload(params: UploadParams): Promise<string> {
const { datasetId, filename, ...file } = UploadParamsSchema.parse(params);
// 截断文件名以避免S3 key过长的问题
// 截断文件名以避免 S3 key 过长的问题
const truncatedFilename = truncateFilename(filename);
const { fileKey: key } = getFileS3Key.dataset({ datasetId, filename: truncatedFilename });
await this.bucket.putObject(key, buffer, buffer.length, {
'content-type': Mimes[path.extname(truncatedFilename) as keyof typeof Mimes],
'upload-time': new Date().toISOString(),
'origin-filename': encodeURIComponent(truncatedFilename)
});
const { stream, size } = (() => {
if ('buffer' in file) {
return {
stream: file.buffer,
size: file.buffer.length
};
}
return {
stream: file.stream,
size: file.size
};
})();
await MongoS3TTL.create({
minioKey: key,
bucketName: this.bucket.name,
expiredTime: addHours(new Date(), 3)
});
await this.bucket.putObject(key, stream, size, {
'content-type': Mimes[path.extname(truncatedFilename) as keyof typeof Mimes],
'upload-time': new Date().toISOString(),
'origin-filename': encodeURIComponent(truncatedFilename)
});
return key;
}
async addRawTextBuffer(params: AddRawTextBufferParams) {
const { sourceId, sourceName, text, customPdfParse } =
AddRawTextBufferParamsSchema.parse(params);
// 因为 Key 唯一对应一个 Object 所以不需要根据文件内容计算 Hash 直接用 Key 计算 Hash 就行了
const hash = createHash('md5').update(sourceId).digest('hex');
const key = getFileS3Key.rawText({ hash, customPdfParse });
await MongoS3TTL.create({
minioKey: key,
bucketName: this.bucket.name,
expiredTime: addMinutes(new Date(), 20)
});
const buffer = Buffer.from(text);
await this.bucket.putObject(key, buffer, buffer.length, {
'content-type': 'text/plain',
'origin-filename': encodeURIComponent(sourceName),
'upload-time': new Date().toISOString()
});
return key;
}
async getRawTextBuffer(params: GetRawTextBufferParams) {
const { customPdfParse, sourceId } = params;
const hash = createHash('md5').update(sourceId).digest('hex');
const key = getFileS3Key.rawText({ hash, customPdfParse });
if (!(await this.bucket.isObjectExists(key))) return null;
const [stream, metadata] = await Promise.all([
this.bucket.getObject(key),
this.getFileMetadata(key)
]);
const buffer = await this.bucket.fileStreamToBuffer(stream);
return {
text: buffer.toString('utf-8'),
filename: metadata.filename
};
}
}
export function getS3DatasetSource() {

View File

@ -1,4 +1,5 @@
import { ObjectIdSchema } from '@fastgpt/global/common/type/mongo';
import { ReadStream } from 'fs';
import { z } from 'zod';
export const CreateUploadDatasetFileParamsSchema = z.object({
@ -15,8 +16,7 @@ export const CreateGetDatasetFileURLParamsSchema = z.object({
export type CreateGetDatasetFileURLParams = z.infer<typeof CreateGetDatasetFileURLParamsSchema>;
export const DeleteDatasetFilesByPrefixParamsSchema = z.object({
datasetId: ObjectIdSchema.optional(),
rawPrefix: z.string().nonempty().optional()
datasetId: ObjectIdSchema.optional()
});
export type DeleteDatasetFilesByPrefixParams = z.infer<
typeof DeleteDatasetFilesByPrefixParamsSchema
@ -44,9 +44,27 @@ export const ParsedFileContentS3KeyParamsSchema = z.object({
});
export type ParsedFileContentS3KeyParams = z.infer<typeof ParsedFileContentS3KeyParamsSchema>;
export const UploadDatasetFileByBufferParamsSchema = z.object({
datasetId: ObjectIdSchema,
buffer: z.instanceof(Buffer),
filename: z.string().nonempty()
export const UploadParamsSchema = z.union([
z.object({
datasetId: ObjectIdSchema,
filename: z.string().nonempty(),
buffer: z.instanceof(Buffer)
}),
z.object({
datasetId: ObjectIdSchema,
filename: z.string().nonempty(),
stream: z.instanceof(ReadStream),
size: z.int().positive().optional()
})
]);
export type UploadParams = z.input<typeof UploadParamsSchema>;
export const AddRawTextBufferParamsSchema = z.object({
customPdfParse: z.boolean().optional(),
sourceId: z.string().nonempty(),
sourceName: z.string().nonempty(),
text: z.string()
});
export type UploadDatasetFileByBufferParams = z.infer<typeof UploadDatasetFileByBufferParamsSchema>;
export type AddRawTextBufferParams = z.input<typeof AddRawTextBufferParamsSchema>;
export type GetRawTextBufferParams = Pick<AddRawTextBufferParams, 'customPdfParse' | 'sourceId'>;

View File

@ -17,25 +17,15 @@ export type ExtensionType = keyof typeof Mimes;
export type S3OptionsType = typeof defaultS3Options;
export const S3SourcesSchema = z.enum(['avatar', 'chat', 'dataset', 'temp']);
export const S3SourcesSchema = z.enum(['avatar', 'chat', 'dataset', 'temp', 'rawText']);
export const S3Sources = S3SourcesSchema.enum;
export type S3SourceType = z.infer<typeof S3SourcesSchema>;
export const CreatePostPresignedUrlParamsSchema = z.union([
// Option 1: Only rawKey
z.object({
filename: z.string().min(1),
rawKey: z.string().min(1),
metadata: z.record(z.string(), z.string()).optional()
}),
// Option 2: filename with optional source and teamId
z.object({
filename: z.string().min(1),
source: S3SourcesSchema.optional(),
teamId: z.string().length(16).optional(),
metadata: z.record(z.string(), z.string()).optional()
})
]);
export const CreatePostPresignedUrlParamsSchema = z.object({
filename: z.string().min(1),
rawKey: z.string().min(1),
metadata: z.record(z.string(), z.string()).optional()
});
export type CreatePostPresignedUrlParams = z.infer<typeof CreatePostPresignedUrlParamsSchema>;
export const CreatePostPresignedUrlOptionsSchema = z.object({

View File

@ -11,6 +11,7 @@ import { getNanoid } from '@fastgpt/global/common/string/tools';
import path from 'node:path';
import type { ParsedFileContentS3KeyParams } from './sources/dataset/type';
import { EndpointUrl } from '@fastgpt/global/common/file/constants';
import type { NextApiRequest } from 'next';
// S3文件名最大长度配置
export const S3_FILENAME_MAX_LENGTH = 50;
@ -173,6 +174,17 @@ export const getFileS3Key = {
};
},
avatar: ({ teamId, filename }: { teamId: string; filename?: string }) => {
const { formatedFilename, extension } = getFormatedFilename(filename);
return {
fileKey: [
S3Sources.avatar,
teamId,
`${formatedFilename}${extension ? `.${extension}` : ''}`
].join('/')
};
},
// 对话中上传的文件的解析结果的图片的 Key
chat: ({
appId,
@ -215,6 +227,10 @@ export const getFileS3Key = {
fileKey: key,
fileParsedPrefix: prefix
};
},
rawText: ({ hash, customPdfParse }: { hash: string; customPdfParse?: boolean }) => {
return [S3Sources.rawText, `${hash}${customPdfParse ? '-true' : ''}`].join('/');
}
};
@ -230,3 +246,130 @@ export function isS3ObjectKey<T extends keyof typeof S3Sources>(
): key is `${T}/${string}` {
return typeof key === 'string' && key.startsWith(`${S3Sources[source]}/`);
}
// export const multer = {
// _storage: multer.diskStorage({
// filename: (_, file, cb) => {
// if (!file?.originalname) {
// cb(new Error('File not found'), '');
// } else {
// const ext = path.extname(decodeURIComponent(file.originalname));
// cb(null, `${getNanoid()}${ext}`);
// }
// }
// }),
// singleStore(maxFileSize: number = 500) {
// const fileSize = maxFileSize * 1024 * 1024;
// return multer({
// limits: {
// fileSize
// },
// preservePath: true,
// storage: this._storage
// }).single('file');
// },
// multipleStore(maxFileSize: number = 500) {
// const fileSize = maxFileSize * 1024 * 1024;
// return multer({
// limits: {
// fileSize
// },
// preservePath: true,
// storage: this._storage
// }).array('file', global.feConfigs?.uploadFileMaxSize);
// },
// resolveFormData({ request, maxFileSize }: { request: NextApiRequest; maxFileSize?: number }) {
// return new Promise<{
// data: Record<string, any>;
// fileMetadata: Express.Multer.File;
// getBuffer: () => Buffer;
// getReadStream: () => fs.ReadStream;
// }>((resolve, reject) => {
// const handler = this.singleStore(maxFileSize);
// // @ts-expect-error it can accept a NextApiRequest
// handler(request, null, (error) => {
// if (error) {
// return reject(error);
// }
// // @ts-expect-error `file` will be injected by multer
// const file = request.file as Express.Multer.File;
// if (!file) {
// return reject(new Error('File not found'));
// }
// const data = (() => {
// if (!request.body?.data) return {};
// try {
// return JSON.parse(request.body.data);
// } catch {
// return {};
// }
// })();
// resolve({
// data,
// fileMetadata: file,
// getBuffer: () => fs.readFileSync(file.path),
// getReadStream: () => fs.createReadStream(file.path)
// });
// });
// });
// },
// resolveMultipleFormData({
// request,
// maxFileSize
// }: {
// request: NextApiRequest;
// maxFileSize?: number;
// }) {
// return new Promise<{
// data: Record<string, any>;
// fileMetadata: Array<Express.Multer.File>;
// }>((resolve, reject) => {
// const handler = this.multipleStore(maxFileSize);
// // @ts-expect-error it can accept a NextApiRequest
// handler(request, null, (error) => {
// if (error) {
// return reject(error);
// }
// // @ts-expect-error `files` will be injected by multer
// const files = request.files as Array<Express.Multer.File>;
// if (!files || files.length === 0) {
// return reject(new Error('File not found'));
// }
// const data = (() => {
// if (!request.body?.data) return {};
// try {
// return JSON.parse(request.body.data);
// } catch {
// return {};
// }
// })();
// resolve({
// data,
// fileMetadata: files
// });
// });
// });
// },
// clearDiskTempFiles(filepaths: string[]) {
// for (const filepath of filepaths) {
// fs.unlink(filepath, (_) => {});
// }
// }
// };

View File

@ -0,0 +1,29 @@
import z from 'zod';
import { CountLimitTypeEnum } from './type';
export const CountLimitConfigType = z.record(
CountLimitTypeEnum,
z.object({ maxCount: z.number() })
);
// 只会发送 n 次通知,如需自动发送,需要主动清除记录
export const CountLimitConfig = {
[CountLimitTypeEnum.enum['notice:30PercentPoints']]: {
maxCount: 3
},
[CountLimitTypeEnum.enum['notice:10PercentPoints']]: {
maxCount: 5
},
[CountLimitTypeEnum.enum['notice:LackOfPoints']]: {
maxCount: 5
},
[CountLimitTypeEnum.enum['notice:30PercentDatasetIndexes']]: {
maxCount: 3
},
[CountLimitTypeEnum.enum['notice:10PercentDatasetIndexes']]: {
maxCount: 5
},
[CountLimitTypeEnum.enum['notice:NoDatasetIndexes']]: {
maxCount: 5
}
} satisfies z.infer<typeof CountLimitConfigType>;

View File

@ -0,0 +1,81 @@
import type z from 'zod';
import type { CountLimitTypeEnum } from './type';
import { CountLimitConfig } from './const';
import { MongoCountLimit } from './schema';
import { mongoSessionRun } from '../../mongo/sessionRun';
/**
* Update the count limit for a specific type and key.
* @param param0 - The type, key, and update value.
* @returns The updated count limit information.
*/
export const updateCountLimit = async ({
type,
key,
update
}: {
type: z.infer<typeof CountLimitTypeEnum>;
key: string;
update: number;
}) =>
mongoSessionRun(async (session) => {
const maxCount = CountLimitConfig[type].maxCount;
const countLimit = await MongoCountLimit.findOne(
{
type,
key
},
undefined,
{
session
}
).lean();
if (!countLimit) {
// do not exist, create a new one
await MongoCountLimit.create(
[
{
type,
key,
count: update // 0 + update
}
],
{
session
}
);
return {
maxCount,
nowCount: update,
remain: maxCount - update
};
}
if (countLimit.count >= maxCount) {
return Promise.reject(`Max Count Reached, type: ${type}, key: ${key}`);
}
await MongoCountLimit.updateOne(
{
type,
key
},
{
$inc: { count: update }
},
{ session }
);
return {
maxCount,
nowCount: countLimit.count + update,
remain: maxCount - (countLimit.count + update)
};
});
/** Clean the Count limit, if no key provided, clean all the type */
export const cleanCountLimit = async ({ teamId }: { teamId: string }) =>
MongoCountLimit.deleteMany({
key: teamId
});

View File

@ -0,0 +1,34 @@
import { connectionMongo, getMongoModel } from '../../../common/mongo';
import type { CountLimitType } from './type';
const { Schema } = connectionMongo;
const collectionName = 'system_count_limits';
const CountLimitSchema = new Schema({
key: {
type: String,
required: true
},
type: {
type: String,
required: true
},
count: {
type: Number,
required: true,
default: 0
},
createTime: {
type: Date,
default: () => new Date()
}
});
try {
CountLimitSchema.index({ type: 1, key: 1 }, { unique: true });
CountLimitSchema.index({ createTime: 1 }, { expireAfterSeconds: 60 * 60 * 24 * 30 }); // ttl 30天
} catch (error) {
console.log(error);
}
export const MongoCountLimit = getMongoModel<CountLimitType>(collectionName, CountLimitSchema);

View File

@ -0,0 +1,19 @@
import z from 'zod';
export const CountLimitTypeEnum = z.enum([
'notice:30PercentPoints',
'notice:10PercentPoints',
'notice:LackOfPoints',
'notice:30PercentDatasetIndexes',
'notice:10PercentDatasetIndexes',
'notice:NoDatasetIndexes'
]);
export const CountLimitType = z.object({
type: CountLimitTypeEnum,
key: z.string(),
count: z.number()
});
export type CountLimitType = z.infer<typeof CountLimitType>;
export type CountLimitTypeEnum = z.infer<typeof CountLimitTypeEnum>;

View File

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

View File

@ -2,7 +2,7 @@ import { connectionMongo, getMongoModel } from '../../mongo';
const { Schema } = connectionMongo;
import { type TimerLockSchemaType } from './type.d';
export const collectionName = 'systemtimerlocks';
export const collectionName = 'system_timer_locks';
const TimerLockSchema = new Schema({
timerId: {

View File

@ -2,7 +2,7 @@ import { type ClientSession } from '../../mongo';
import { MongoTimerLock } from './schema';
import { addMinutes } from 'date-fns';
/*
/*
使
*/
export const checkTimerLock = async ({
@ -30,3 +30,19 @@ export const checkTimerLock = async ({
return false;
}
};
export const cleanTimerLock = async ({
teamId,
session
}: {
teamId: string;
session?: ClientSession;
}) => {
// Match timerId pattern where lockId (last segment) equals teamId
await MongoTimerLock.deleteMany(
{
timerId: new RegExp(`--${teamId}$`)
},
{ session }
);
};

View File

@ -6,16 +6,23 @@ import { addLog } from '../../../common/system/log';
import { filterGPTMessageByMaxContext } from '../llm/utils';
import json5 from 'json5';
import { createLLMResponse } from '../llm/request';
import { useTextCosine } from '../hooks/useTextCosine';
/*
query extension -
/*
Query Extension - Semantic Search Enhancement
This module can eliminate referential ambiguity and expand queries based on context to improve retrieval.
Submodular Optimization Mode: Generate multiple candidate queries, then use submodular algorithm to select the optimal query combination
*/
const title = global.feConfigs?.systemTitle || 'FastAI';
const defaultPrompt = `## 你的任务
"原问题"{{count}}"检索词"
##
1.
2.
3.
4.
5. "原问题语言相同"
##
@ -24,7 +31,7 @@ const defaultPrompt = `## 你的任务
null
"""
原问题: 介绍下剧情
: ["介绍下故事的背景。","故事的主题是什么?","介绍下故事的主要人物。"]
: ["介绍下故事的背景。","故事的主题是什么?","介绍下故事的主要人物。","故事的转折点在哪里?","故事的结局如何?"]
----------------
:
"""
@ -32,7 +39,7 @@ user: 对话背景。
assistant: 当前对话是关于 Nginx 使
"""
原问题: 怎么下载
: ["Nginx 如何下载?","下载 Nginx 需要什么条件?","有哪些渠道可以下载 Nginx"]
: ["Nginx 如何下载?","下载 Nginx 需要什么条件?","有哪些渠道可以下载 Nginx","Nginx 各版本的下载方式有什么区别?","如何选择合适的 Nginx 版本下载?"]
----------------
:
"""
@ -42,7 +49,7 @@ user: 报错 "no connection"
assistant: 报错"no connection"
"""
原问题: 怎么解决
: ["Nginx报错"no connection"如何解决?","造成'no connection'报错的原因。","Nginx提示'no connection',要怎么办"]
: ["Nginx报错'no connection'如何解决?","造成'no connection'报错的原因。","Nginx提示'no connection',要怎么办","'no connection'错误的常见解决步骤。","如何预防 Nginx 'no connection' 错误"]
----------------
:
"""
@ -50,7 +57,7 @@ user: How long is the maternity leave?
assistant: The number of days of maternity leave depends on the city in which the employee is located. Please provide your city so that I can answer your questions.
"""
原问题: ShenYang
: ["How many days is maternity leave in Shenyang?","Shenyang's maternity leave policy.","The standard of maternity leave in Shenyang."]
: ["How many days is maternity leave in Shenyang?","Shenyang's maternity leave policy.","The standard of maternity leave in Shenyang.","What benefits are included in Shenyang's maternity leave?","How to apply for maternity leave in Shenyang?"]
----------------
:
"""
@ -58,7 +65,7 @@ user: 作者是谁?
assistant: ${title} labring
"""
原问题: Tell me about him
: ["Introduce labring, the author of ${title}." ," Background information on author labring." "," Why does labring do ${title}?"]
: ["Introduce labring, the author of ${title}." ,"Background information on author labring.","Why does labring do ${title}?","What other projects has labring worked on?","How did labring start ${title}?"]
----------------
:
"""
@ -74,7 +81,7 @@ user: ${title} 如何收费?
assistant: ${title}
"""
原问题: 你知道 laf
: ["laf 的官网地址是多少?","laf 的使用教程。","laf 有什么特点和优势。"]
: ["laf 的官网地址是多少?","laf 的使用教程。","laf 有什么特点和优势。","laf 的主要功能是什么?","laf 与其他类似产品的对比。"]
----------------
:
"""
@ -100,6 +107,7 @@ assistant: Laf 是一个云函数开发平台。
1. JSON
2.
3. {{count}}
##
@ -114,18 +122,24 @@ export const queryExtension = async ({
chatBg,
query,
histories = [],
model
llmModel,
embeddingModel,
generateCount = 10 // 生成优化问题集的数量默认为10个
}: {
chatBg?: string;
query: string;
histories: ChatItemType[];
model: string;
llmModel: string;
embeddingModel: string;
generateCount?: number;
}): Promise<{
rawQuery: string;
extensionQueries: string[];
model: string;
llmModel: string;
embeddingModel: string;
inputTokens: number;
outputTokens: number;
embeddingTokens: number;
}> => {
const systemFewShot = chatBg
? `user: 对话背景。
@ -133,7 +147,8 @@ assistant: ${chatBg}
`
: '';
const modelData = getLLMModel(model);
// 1. Request model
const modelData = getLLMModel(llmModel);
const filterHistories = await filterGPTMessageByMaxContext({
messages: chats2GPTMessages({ messages: histories, reserveId: false }),
maxContext: modelData.maxContext - 1000
@ -160,7 +175,8 @@ assistant: ${chatBg}
role: 'user',
content: replaceVariable(defaultPrompt, {
query: `${query}`,
histories: concatFewShot || 'null'
histories: concatFewShot || 'null',
count: generateCount.toString()
})
}
] as any;
@ -181,12 +197,15 @@ assistant: ${chatBg}
return {
rawQuery: query,
extensionQueries: [],
model,
llmModel: modelData.model,
embeddingModel,
inputTokens: inputTokens,
outputTokens: outputTokens
outputTokens: outputTokens,
embeddingTokens: 0
};
}
// 2. Parse answer
const start = answer.indexOf('[');
const end = answer.lastIndexOf(']');
if (start === -1 || end === -1) {
@ -196,9 +215,11 @@ assistant: ${chatBg}
return {
rawQuery: query,
extensionQueries: [],
model,
llmModel: modelData.model,
embeddingModel,
inputTokens: inputTokens,
outputTokens: outputTokens
outputTokens: outputTokens,
embeddingTokens: 0
};
}
@ -211,23 +232,51 @@ assistant: ${chatBg}
try {
const queries = json5.parse(jsonStr) as string[];
if (!Array.isArray(queries) || queries.length === 0) {
return {
rawQuery: query,
extensionQueries: [],
llmModel: modelData.model,
embeddingModel,
inputTokens,
outputTokens,
embeddingTokens: 0
};
}
// 3. 通过计算获取到最优的检索词
const { lazyGreedyQuerySelection, embeddingModel: useEmbeddingModel } = useTextCosine({
embeddingModel
});
const { selectedData: selectedQueries, embeddingTokens } = await lazyGreedyQuerySelection({
originalText: query,
candidates: queries,
k: Math.min(3, queries.length), // 至多 3 个
alpha: 0.3
});
return {
rawQuery: query,
extensionQueries: (Array.isArray(queries) ? queries : []).slice(0, 5),
model,
extensionQueries: selectedQueries,
llmModel: modelData.model,
embeddingModel: useEmbeddingModel,
inputTokens,
outputTokens
outputTokens,
embeddingTokens
};
} catch (error) {
addLog.warn('Query extension failed, not a valid JSON', {
addLog.warn('Query extension failed', {
error,
answer
});
return {
rawQuery: query,
extensionQueries: [],
model,
llmModel: modelData.model,
embeddingModel,
inputTokens,
outputTokens
outputTokens,
embeddingTokens: 0
};
}
};

View File

@ -0,0 +1,154 @@
/*
Reference: https://github.com/jina-ai/submodular-optimization
*/
import { getVectorsByText } from '../embedding';
import { getEmbeddingModel } from '../model';
class PriorityQueue<T> {
private heap: Array<{ item: T; priority: number }> = [];
enqueue(item: T, priority: number): void {
this.heap.push({ item, priority });
this.heap.sort((a, b) => b.priority - a.priority);
}
dequeue(): T | undefined {
return this.heap.shift()?.item;
}
isEmpty(): boolean {
return this.heap.length === 0;
}
size(): number {
return this.heap.length;
}
}
export const useTextCosine = ({ embeddingModel }: { embeddingModel: string }) => {
const vectorModel = getEmbeddingModel(embeddingModel);
// Calculate marginal gain
const computeMarginalGain = (
candidateEmbedding: number[],
selectedEmbeddings: number[][],
originalEmbedding: number[],
alpha: number = 0.3
): number => {
// Calculate cosine similarity
const cosineSimilarity = (a: number[], b: number[]): number => {
if (a.length !== b.length) {
throw new Error('Vectors must have the same length');
}
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA === 0 || normB === 0) return 0;
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
};
if (selectedEmbeddings.length === 0) {
return alpha * cosineSimilarity(originalEmbedding, candidateEmbedding);
}
let maxSimilarity = 0;
for (const selectedEmbedding of selectedEmbeddings) {
const similarity = cosineSimilarity(candidateEmbedding, selectedEmbedding);
maxSimilarity = Math.max(maxSimilarity, similarity);
}
const relevance = alpha * cosineSimilarity(originalEmbedding, candidateEmbedding);
const diversity = 1 - maxSimilarity;
return relevance + diversity;
};
// Lazy greedy query selection algorithm
const lazyGreedyQuerySelection = async ({
originalText,
candidates,
k,
alpha = 0.3
}: {
originalText: string;
candidates: string[]; // 候选文本
k: number;
alpha?: number;
}) => {
const { tokens: embeddingTokens, vectors: embeddingVectors } = await getVectorsByText({
model: vectorModel,
input: [originalText, ...candidates],
type: 'query'
});
const originalEmbedding = embeddingVectors[0];
const candidateEmbeddings = embeddingVectors.slice(1);
const n = candidates.length;
const selected: string[] = [];
const selectedEmbeddings: number[][] = [];
// Initialize priority queue
const pq = new PriorityQueue<{ index: number; gain: number }>();
// Calculate initial marginal gain for all candidates
for (let i = 0; i < n; i++) {
const gain = computeMarginalGain(
candidateEmbeddings[i],
selectedEmbeddings,
originalEmbedding,
alpha
);
pq.enqueue({ index: i, gain }, gain);
}
// Greedy selection
for (let iteration = 0; iteration < k; iteration++) {
if (pq.isEmpty()) break;
let bestCandidate: { index: number; gain: number } | undefined;
// Find candidate with maximum marginal gain
while (!pq.isEmpty()) {
const candidate = pq.dequeue()!;
const currentGain = computeMarginalGain(
candidateEmbeddings[candidate.index],
selectedEmbeddings,
originalEmbedding,
alpha
);
if (currentGain >= candidate.gain) {
bestCandidate = { index: candidate.index, gain: currentGain };
break;
} else {
// Create new object with updated gain to avoid infinite loop
pq.enqueue({ index: candidate.index, gain: currentGain }, currentGain);
}
}
if (bestCandidate) {
selected.push(candidates[bestCandidate.index]);
selectedEmbeddings.push(candidateEmbeddings[bestCandidate.index]);
}
}
return {
selectedData: selected,
embeddingTokens
};
};
return {
lazyGreedyQuerySelection,
embeddingModel: vectorModel.model
};
};

View File

@ -12,7 +12,6 @@ import { SystemToolSecretInputTypeEnum } from '@fastgpt/global/core/app/tool/sys
import { type ClientSession } from '../../common/mongo';
import { MongoEvaluation } from './evaluation/evalSchema';
import { removeEvaluationJob } from './evaluation/mq';
import { deleteChatFiles } from '../chat/controller';
import { MongoChatItem } from '../chat/chatItemSchema';
import { MongoChat } from '../chat/chatSchema';
import { MongoOutLink } from '../../support/outLink/schema';
@ -224,7 +223,7 @@ export const onDelOneApp = async ({
// Delete chats
for await (const app of apps) {
const appId = String(app._id);
await deleteChatFiles({ appId });
await getS3ChatSource().deleteChatFilesByPrefix({ appId });
await MongoChatItemResponse.deleteMany({
appId
});

View File

@ -1,10 +1,6 @@
import type { ChatHistoryItemResType, ChatItemType } from '@fastgpt/global/core/chat/type';
import { MongoChatItem } from './chatItemSchema';
import { addLog } from '../../common/system/log';
import { delFileByFileIdList, getGFSCollection } from '../../common/file/gridfs/controller';
import { BucketNameEnum } from '@fastgpt/global/common/file/constants';
import { MongoChat } from './chatSchema';
import { UserError } from '@fastgpt/global/common/error/utils';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { MongoChatItemResponse } from './chatItemResponseSchema';
@ -95,41 +91,3 @@ export const addCustomFeedbacks = async ({
addLog.error('addCustomFeedbacks error', error);
}
};
/*
Delete chat files
1. ChatId: Delete one chat files
2. AppId: Delete all the app's chat files
*/
export const deleteChatFiles = async ({
chatIdList,
appId
}: {
chatIdList?: string[];
appId?: string;
}) => {
if (!appId && !chatIdList)
return Promise.reject(new UserError('appId or chatIdList is required'));
const appChatIdList = await (async () => {
if (appId) {
const appChatIdList = await MongoChat.find({ appId }, { chatId: 1 });
return appChatIdList.map((item) => String(item.chatId));
} else if (chatIdList) {
return chatIdList;
}
return [];
})();
const collection = getGFSCollection(BucketNameEnum.chat);
const where = {
'metadata.chatId': { $in: appChatIdList }
};
const files = await collection.find(where, { projection: { _id: 1 } }).toArray();
await delFileByFileIdList({
bucketName: BucketNameEnum.chat,
fileIdList: files.map((item) => String(item._id))
});
};

View File

@ -245,6 +245,7 @@ export async function saveChat(props: Props) {
...chat?.metadata,
...metadata
};
const { welcomeText, variables: variableList } = getAppChatConfig({
chatConfig: appChatConfig,
systemConfigNode: getGuideModule(nodes),

View File

@ -9,8 +9,7 @@ import { addLog } from '../../../../common/system/log';
import { readFileRawTextByUrl } from '../../read';
import { type ParentIdType } from '@fastgpt/global/common/parentFolder/type';
import { type RequireOnlyOne } from '@fastgpt/global/common/type/utils';
import { addRawTextBuffer, getRawTextBuffer } from '../../../../common/buffer/rawText/controller';
import { addMinutes } from 'date-fns';
import { getS3DatasetSource } from '../../../../common/s3/sources/dataset';
type ResponseDataType = {
success: boolean;
@ -155,11 +154,14 @@ export const useApiDatasetRequest = ({ apiServer }: { apiServer: APIFileServer }
}
if (previewUrl) {
// Get from buffer
const buffer = await getRawTextBuffer(previewUrl);
if (buffer) {
const rawTextBuffer = await getS3DatasetSource().getRawTextBuffer({
sourceId: previewUrl,
customPdfParse
});
if (rawTextBuffer) {
return {
title,
rawText: buffer.text
rawText: rawTextBuffer.text
};
}
@ -173,11 +175,11 @@ export const useApiDatasetRequest = ({ apiServer }: { apiServer: APIFileServer }
getFormatText: true
});
await addRawTextBuffer({
getS3DatasetSource().addRawTextBuffer({
sourceId: previewUrl,
sourceName: title || '',
text: rawText,
expiredTime: addMinutes(new Date(), 30)
customPdfParse
});
return {

View File

@ -12,8 +12,6 @@ import { MongoDatasetTraining } from '../training/schema';
import { MongoDatasetData } from '../data/schema';
import { delImgByRelatedId } from '../../../common/file/image/controller';
import { deleteDatasetDataVector } from '../../../common/vectorDB/controller';
import { delFileByFileIdList } from '../../../common/file/gridfs/controller';
import { BucketNameEnum } from '@fastgpt/global/common/file/constants';
import type { ClientSession } from '../../../common/mongo';
import { createOrGetCollectionTags } from './utils';
import { rawText2Chunks } from '../read';
@ -33,9 +31,7 @@ import {
getLLMMaxChunkSize
} from '@fastgpt/global/core/dataset/training/utils';
import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants';
import { clearCollectionImages } from '../image/utils';
import { getS3DatasetSource, S3DatasetSource } from '../../../common/s3/sources/dataset';
import path from 'node:path';
import { getS3DatasetSource } from '../../../common/s3/sources/dataset';
import { removeS3TTL, isS3ObjectKey } from '../../../common/s3/utils';
export const createCollectionAndInsertData = async ({
@ -326,18 +322,13 @@ export const delCollectionRelatedSource = async ({
if (!teamId) return Promise.reject('teamId is not exist');
const fileIdList = collections.map((item) => item?.fileId || '').filter(Boolean);
// FIXME: 兼容旧解析图像删除
const relatedImageIds = collections
.map((item) => item?.metadata?.relatedImgId || '')
.filter(Boolean);
// Delete files and images in parallel
await Promise.all([
// Delete files
delFileByFileIdList({
bucketName: BucketNameEnum.dataset,
fileIdList
}),
// Delete images
delImgByRelatedId({
teamId,
@ -405,10 +396,8 @@ export async function delCollection({
datasetId: { $in: datasetIds },
collectionId: { $in: collectionIds }
}),
// Delete dataset_images
clearCollectionImages(collectionIds),
// Delete images if needed
...(delImg
...(delImg // 兼容旧图像删除
? [
delImgByRelatedId({
teamId,
@ -421,10 +410,9 @@ export async function delCollection({
// Delete files if needed
...(delFile
? [
delFileByFileIdList({
bucketName: BucketNameEnum.dataset,
fileIdList: collections.map((item) => item?.fileId || '').filter(Boolean)
})
getS3DatasetSource().deleteDatasetFilesByKeys(
collections.map((item) => item?.fileId || '').filter(Boolean)
)
]
: []),
// Delete vector data

View File

@ -9,11 +9,6 @@ import { deleteDatasetDataVector } from '../../common/vectorDB/controller';
import { MongoDatasetDataText } from './data/dataTextSchema';
import { DatasetErrEnum } from '@fastgpt/global/common/error/code/dataset';
import { retryFn } from '@fastgpt/global/common/system/utils';
import { clearDatasetImages } from './image/utils';
import { MongoDatasetCollectionTags } from './tag/schema';
import { removeDatasetSyncJobScheduler } from './datasetSync';
import { mongoSessionRun } from '../../common/mongo/sessionRun';
import { removeImageByPath } from '../../common/file/image/controller';
import { UserError } from '@fastgpt/global/common/error/utils';
import { getS3DatasetSource } from '../../common/s3/sources/dataset';
@ -95,77 +90,39 @@ export async function delDatasetRelevantData({
'_id teamId datasetId fileId metadata'
).lean();
await retryFn(async () => {
await Promise.all([
// delete training data
MongoDatasetTraining.deleteMany({
teamId,
datasetId: { $in: datasetIds }
}),
//Delete dataset_data_texts
MongoDatasetDataText.deleteMany({
teamId,
datasetId: { $in: datasetIds }
}),
//delete dataset_datas
MongoDatasetData.deleteMany({ teamId, datasetId: { $in: datasetIds } }),
// Delete collection image and file
delCollectionRelatedSource({ collections }),
// Delete dataset Image
clearDatasetImages(datasetIds),
// Delete vector data
deleteDatasetDataVector({ teamId, datasetIds })
]);
// delete training data
await MongoDatasetTraining.deleteMany({
teamId,
datasetId: { $in: datasetIds }
});
// Delete dataset_data_texts in batches by datasetId
for (const datasetId of datasetIds) {
await MongoDatasetDataText.deleteMany({
teamId,
datasetId
}).maxTimeMS(300000); // Reduce timeout for single batch
}
// Delete dataset_datas in batches by datasetId
for (const datasetId of datasetIds) {
await MongoDatasetData.deleteMany({
teamId,
datasetId
}).maxTimeMS(300000);
}
await delCollectionRelatedSource({ collections });
// Delete vector data
await deleteDatasetDataVector({ teamId, datasetIds });
// delete collections
await MongoDatasetCollection.deleteMany({
teamId,
datasetId: { $in: datasetIds }
}).session(session);
// Delete all dataset files
for (const datasetId of datasetIds) {
await getS3DatasetSource().deleteDatasetFilesByPrefix({ datasetId });
}
}
export const deleteDatasets = async ({
teamId,
datasets
}: {
teamId: string;
datasets: DatasetSchemaType[];
}) => {
const datasetIds = datasets.map((d) => d._id);
// delete collection.tags
await MongoDatasetCollectionTags.deleteMany({
teamId,
datasetId: { $in: datasetIds }
});
// Remove cron job
await Promise.all(
datasets.map((dataset) => {
return removeDatasetSyncJobScheduler(dataset._id);
})
);
// delete all dataset.data and pg data
await mongoSessionRun(async (session) => {
// delete dataset data
await delDatasetRelevantData({ datasets, session });
// delete dataset
await MongoDataset.deleteMany(
{
_id: { $in: datasetIds }
},
{ session }
);
for await (const dataset of datasets) {
await removeImageByPath(dataset.avatar, session);
}
});
};

View File

@ -1,4 +1,4 @@
import { replaceDatasetQuoteTextWithJWT } from '../../../core/dataset/utils';
import { replaceS3KeyToPreviewUrl } from '../../../core/dataset/utils';
import { addEndpointToImageUrl } from '../../../common/file/image/utils';
import type { DatasetDataSchemaType } from '@fastgpt/global/core/dataset/type';
import { addDays } from 'date-fns';
@ -53,8 +53,8 @@ export const formatDatasetDataValue = ({
if (!imageId) {
return {
q: replaceDatasetQuoteTextWithJWT(q, addDays(new Date(), 90)),
a: a ? replaceDatasetQuoteTextWithJWT(a, addDays(new Date(), 90)) : undefined
q: replaceS3KeyToPreviewUrl(q, addDays(new Date(), 90)),
a: a ? replaceS3KeyToPreviewUrl(a, addDays(new Date(), 90)) : undefined
};
}

View File

@ -105,9 +105,6 @@ try {
// rebuild data
DatasetDataSchema.index({ rebuilding: 1, teamId: 1, datasetId: 1 });
// 为查询 initJieba 字段不存在的数据添加索引
DatasetDataSchema.index({ initJieba: 1, updateTime: 1 });
// Cron clear invalid data
DatasetDataSchema.index({ updateTime: 1 });
} catch (error) {

View File

@ -0,0 +1,41 @@
import { getQueue, getWorker, QueueNames } from '../../../common/bullmq';
import { datasetDeleteProcessor } from './processor';
export type DatasetDeleteJobData = {
teamId: string;
datasetId: string;
};
// 创建工作进程
export const initDatasetDeleteWorker = () => {
return getWorker<DatasetDeleteJobData>(QueueNames.datasetDelete, datasetDeleteProcessor, {
concurrency: 1, // 确保同时只有1个删除任务
removeOnFail: {
age: 30 * 24 * 60 * 60 // 保留30天失败记录
}
});
};
// 添加删除任务
export const addDatasetDeleteJob = (data: DatasetDeleteJobData) => {
// 创建删除队列
const datasetDeleteQueue = getQueue<DatasetDeleteJobData>(QueueNames.datasetDelete, {
defaultJobOptions: {
attempts: 10,
backoff: {
type: 'exponential',
delay: 5000
},
removeOnComplete: true,
removeOnFail: { age: 30 * 24 * 60 * 60 } // 保留30天失败记录
}
});
const jobId = `${data.teamId}:${data.datasetId}`;
// 使用去重机制,避免重复删除
return datasetDeleteQueue.add(jobId, data, {
deduplication: { id: jobId },
delay: 1000 // 延迟1秒执行确保API响应完成
});
};

View File

@ -0,0 +1,130 @@
import type { Processor } from 'bullmq';
import 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';
import { MongoDataset } from '../schema';
import { removeImageByPath } from '../../../common/file/image/controller';
import { MongoDatasetTraining } from '../training/schema';
export const deleteDatasetsImmediate = async ({
teamId,
datasets
}: {
teamId: string;
datasets: DatasetSchemaType[];
}) => {
const datasetIds = datasets.map((d) => d._id);
// delete training data
MongoDatasetTraining.deleteMany({
teamId,
datasetId: { $in: datasetIds }
});
// Remove cron job
await Promise.all(
datasets.map((dataset) => {
// 只处理已标记删除的数据集
if (datasetIds.includes(dataset._id)) {
return removeDatasetSyncJobScheduler(dataset._id);
}
})
);
};
export const deleteDatasets = async ({
teamId,
datasets
}: {
teamId: string;
datasets: DatasetSchemaType[];
}) => {
const datasetIds = datasets.map((d) => d._id);
// delete collection.tags
await MongoDatasetCollectionTags.deleteMany({
teamId,
datasetId: { $in: datasetIds }
});
// Delete dataset avatar
for await (const dataset of datasets) {
await removeImageByPath(dataset.avatar);
}
// delete all dataset.data and pg data
await mongoSessionRun(async (session) => {
// delete dataset data
await delDatasetRelevantData({
datasets,
session
});
});
// delete dataset
await MongoDataset.deleteMany({
_id: { $in: datasetIds }
});
};
export const datasetDeleteProcessor: Processor<DatasetDeleteJobData> = async (job) => {
const { teamId, datasetId } = job.data;
const startTime = Date.now();
addLog.info(`[Dataset Delete] Start deleting dataset: ${datasetId} for team: ${teamId}`);
try {
// 1. 查找知识库及其所有子知识库
const datasets = await findDatasetAndAllChildren({
teamId,
datasetId
});
if (!datasets || datasets.length === 0) {
addLog.warn(`[Dataset Delete] Dataset not found: ${datasetId}`);
return;
}
// 2. 安全检查:确保所有要删除的数据集都已标记为 deleteTime
const markedForDelete = await MongoDataset.find(
{
_id: { $in: datasets.map((d) => d._id) },
teamId,
deleteTime: { $ne: null }
},
{ _id: 1 }
).lean();
if (markedForDelete.length !== datasets.length) {
addLog.warn(
`[Dataset Delete] Safety check: ${markedForDelete.length}/${datasets.length} datasets marked for deletion`,
{
markedDatasetIds: markedForDelete.map((d) => d._id),
totalDatasetIds: datasets.map((d) => d._id)
}
);
}
// 3. 执行真正的删除操作(只删除已经标记为 deleteTime 的数据)
await deleteDatasets({
teamId,
datasets
});
addLog.info(
`[Dataset Delete] Successfully deleted dataset: ${datasetId} and ${datasets.length - 1} children`,
{
duration: Date.now() - startTime,
totalDatasets: datasets.length,
datasetIds: datasets.map((d) => d._id)
}
);
} catch (error: any) {
addLog.error(`[Dataset Delete] Failed to delete dataset: ${datasetId}`, error);
throw error;
}
};

View File

@ -1,17 +1,6 @@
import { addMinutes } from 'date-fns';
import { bucketName, MongoDatasetImageSchema } from './schema';
import { connectionMongo, Types } from '../../../common/mongo';
import fs from 'fs';
import type { FileType } from '../../../common/file/multer';
import fsp from 'fs/promises';
import { computeGridFsChunSize } from '../../../common/file/gridfs/utils';
import { setCron } from '../../../common/system/cron';
import { checkTimerLock } from '../../../common/system/timerLock/utils';
import { TimerIdEnum } from '../../../common/system/timerLock/constants';
import { addLog } from '../../../common/system/log';
import { UserError } from '@fastgpt/global/common/error/utils';
import { getS3DatasetSource, S3DatasetSource } from '../../../common/s3/sources/dataset';
import { isS3ObjectKey } from '../../../common/s3/utils';
const getGridBucket = () => {
return new connectionMongo.mongo.GridFSBucket(connectionMongo.connection.db!, {
@ -19,53 +8,6 @@ const getGridBucket = () => {
});
};
export const createDatasetImage = async ({
teamId,
datasetId,
file,
expiredTime = addMinutes(new Date(), 30)
}: {
teamId: string;
datasetId: string;
file: FileType;
expiredTime?: Date;
}): Promise<{ imageId: string; previewUrl: string }> => {
const path = file.path;
const gridBucket = getGridBucket();
const metadata = {
teamId: String(teamId),
datasetId: String(datasetId),
expiredTime
};
const stats = await fsp.stat(path);
if (!stats.isFile()) return Promise.reject(`${path} is not a file`);
const readStream = fs.createReadStream(path, {
highWaterMark: 256 * 1024
});
const chunkSizeBytes = computeGridFsChunSize(stats.size);
const stream = gridBucket.openUploadStream(file.originalname, {
metadata,
contentType: file.mimetype,
chunkSizeBytes
});
// save to gridfs
await new Promise((resolve, reject) => {
readStream
.pipe(stream as any)
.on('finish', resolve)
.on('error', reject);
});
return {
imageId: String(stream.id),
previewUrl: ''
};
};
export const getDatasetImageReadData = async (imageId: string) => {
// Get file metadata to get contentType
const fileInfo = await MongoDatasetImageSchema.findOne({
@ -81,93 +23,3 @@ export const getDatasetImageReadData = async (imageId: string) => {
fileInfo
};
};
export const getDatasetImageBase64 = async (imageId: string) => {
// Get file metadata to get contentType
const fileInfo = await MongoDatasetImageSchema.findOne({
_id: new Types.ObjectId(imageId)
}).lean();
if (!fileInfo) {
return Promise.reject(new UserError('Image not found'));
}
// Get image stream from GridFS
const { stream } = await getDatasetImageReadData(imageId);
// Convert stream to buffer
const chunks: Buffer[] = [];
return new Promise<string>((resolve, reject) => {
stream.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
stream.on('end', () => {
// Combine all chunks into a single buffer
const buffer = Buffer.concat(chunks);
// Convert buffer to base64 string
const base64 = buffer.toString('base64');
const dataUrl = `data:${fileInfo.contentType || 'image/jpeg'};base64,${base64}`;
resolve(dataUrl);
});
stream.on('error', reject);
});
};
export const deleteDatasetImage = async (imageId: string) => {
const gridBucket = getGridBucket();
try {
if (isS3ObjectKey(imageId, 'dataset')) {
await getS3DatasetSource().deleteDatasetFileByKey(imageId);
} else {
await gridBucket.delete(new Types.ObjectId(imageId));
}
} catch (error: any) {
const msg = error?.message;
if (msg.includes('File not found')) {
addLog.warn('Delete dataset image error', error);
return;
} else {
return Promise.reject(error);
}
}
};
export const clearExpiredDatasetImageCron = async () => {
const gridBucket = getGridBucket();
const clearExpiredDatasetImages = async () => {
addLog.debug('Clear expired dataset image start');
const data = await MongoDatasetImageSchema.find(
{
'metadata.expiredTime': { $lt: new Date() }
},
'_id'
).lean();
for (const item of data) {
try {
await gridBucket.delete(new Types.ObjectId(item._id));
} catch (error) {
addLog.error('Delete expired dataset image error', error);
}
}
addLog.debug('Clear expired dataset image end');
};
setCron('*/10 * * * *', async () => {
if (
await checkTimerLock({
timerId: TimerIdEnum.clearExpiredDatasetImage,
lockMinuted: 9
})
) {
try {
await clearExpiredDatasetImages();
} catch (error) {
addLog.error('clearExpiredDatasetImageCron error', error);
}
}
});
};

View File

@ -1,107 +0,0 @@
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { Types, type ClientSession } from '../../../common/mongo';
import { deleteDatasetImage } from './controller';
import { MongoDatasetImageSchema } from './schema';
import { addMinutes } from 'date-fns';
import jwt from 'jsonwebtoken';
import { EndpointUrl } from '@fastgpt/global/common/file/constants';
export const removeDatasetImageExpiredTime = async ({
ids = [],
collectionId,
session
}: {
ids?: string[];
collectionId: string;
session?: ClientSession;
}) => {
if (ids.length === 0) return;
return MongoDatasetImageSchema.updateMany(
{
_id: {
$in: ids
.filter((id) => Types.ObjectId.isValid(id))
.map((id) => (typeof id === 'string' ? new Types.ObjectId(id) : id))
}
},
{
$unset: { 'metadata.expiredTime': '' },
$set: {
'metadata.collectionId': String(collectionId)
}
},
{ session }
);
};
export const getDatasetImagePreviewUrl = ({
imageId,
teamId,
datasetId,
expiredMinutes
}: {
imageId: string;
teamId: string;
datasetId: string;
expiredMinutes: number;
}) => {
const expiredTime = Math.floor(addMinutes(new Date(), expiredMinutes).getTime() / 1000);
const key = (process.env.FILE_TOKEN_KEY as string) ?? 'filetoken';
const token = jwt.sign(
{
teamId: String(teamId),
datasetId: String(datasetId),
imageId: String(imageId),
exp: expiredTime
},
key
);
return `${EndpointUrl}/api/file/datasetImg/${token}.jpeg`;
};
export const authDatasetImagePreviewUrl = (token?: string) =>
new Promise<{
teamId: string;
datasetId: string;
imageId: string;
}>((resolve, reject) => {
if (!token) {
return reject(ERROR_ENUM.unAuthFile);
}
const key = (process.env.FILE_TOKEN_KEY as string) ?? 'filetoken';
jwt.verify(token, key, (err, decoded: any) => {
if (err || !decoded?.teamId || !decoded?.datasetId) {
reject(ERROR_ENUM.unAuthFile);
return;
}
resolve({
teamId: decoded.teamId,
datasetId: decoded.datasetId,
imageId: decoded.imageId
});
});
});
export const clearDatasetImages = async (datasetIds: string[]) => {
if (datasetIds.length === 0) return;
const images = await MongoDatasetImageSchema.find(
{
'metadata.datasetId': { $in: datasetIds.map((item) => String(item)) }
},
'_id'
).lean();
await Promise.all(images.map((image) => deleteDatasetImage(String(image._id))));
};
export const clearCollectionImages = async (collectionIds: string[]) => {
if (collectionIds.length === 0) return;
const images = await MongoDatasetImageSchema.find(
{
'metadata.collectionId': { $in: collectionIds.map((item) => String(item)) }
},
'_id'
).lean();
await Promise.all(images.map((image) => deleteDatasetImage(String(image._id))));
};

View File

@ -11,7 +11,7 @@ export type DatasetMigrationLogSchemaType = {
migrationVersion: string; // 如 'v4.14.3'
// 资源类型和标识
resourceType: 'collection' | 'image' | 'chat_file'; // 支持不同类型的文件迁移
resourceType: 'collection' | 'dataset_image'; // 支持不同类型的文件迁移
resourceId: string; // collection._id 或 image._id
teamId: string;
datasetId?: string; // collection 有image 可能没有
@ -101,7 +101,7 @@ const DatasetMigrationLogSchema = new Schema({
// 资源类型和标识
resourceType: {
type: String,
enum: ['collection', 'image', 'chat_file'],
enum: ['collection', 'dataset_image'],
required: true
},
resourceId: {

View File

@ -131,6 +131,12 @@ const DatasetSchema = new Schema({
apiDatasetServer: Object,
// 软删除标记字段
deleteTime: {
type: Date,
default: null // null表示未删除有值表示删除时间
},
// abandoned
autoSync: Boolean,
externalReadUrl: String,
@ -143,6 +149,7 @@ const DatasetSchema = new Schema({
try {
DatasetSchema.index({ teamId: 1 });
DatasetSchema.index({ type: 1 });
DatasetSchema.index({ deleteTime: 1 }); // 添加软删除字段索引
} catch (error) {
console.log(error);
}

View File

@ -33,7 +33,7 @@ import { datasetSearchQueryExtension } from './utils';
import type { RerankModelItemType } from '@fastgpt/global/core/ai/model.d';
import { formatDatasetDataValue } from '../data/controller';
import { pushTrack } from '../../../common/middle/tracks/utils';
import { replaceDatasetQuoteTextWithJWT } from '../../../core/dataset/utils';
import { replaceS3KeyToPreviewUrl } from '../../../core/dataset/utils';
import { addDays, addHours } from 'date-fns';
export type SearchDatasetDataProps = {
@ -81,9 +81,11 @@ export type SearchDatasetDataResponse = {
usingSimilarityFilter: boolean;
queryExtensionResult?: {
model: string;
llmModel: string;
embeddingModel: string;
inputTokens: number;
outputTokens: number;
embeddingTokens: number;
query: string;
};
deepSearchResult?: { model: string; inputTokens: number; outputTokens: number };
@ -906,7 +908,7 @@ export async function searchDatasetData(
const filterMaxTokensResult = await filterDatasetDataByMaxTokens(scoreFilter, maxTokens);
const finalResult = filterMaxTokensResult.map((item) => {
item.q = replaceDatasetQuoteTextWithJWT(item.q, addDays(new Date(), 90));
item.q = replaceS3KeyToPreviewUrl(item.q, addDays(new Date(), 90));
return item;
});
@ -938,32 +940,32 @@ export const defaultSearchDatasetData = async ({
const query = props.queries[0];
const histories = props.histories;
const extensionModel = datasetSearchUsingExtensionQuery
? getLLMModel(datasetSearchExtensionModel)
: undefined;
const { concatQueries, extensionQueries, rewriteQuery, aiExtensionResult } =
await datasetSearchQueryExtension({
query,
extensionModel,
extensionBg: datasetSearchExtensionBg,
histories
});
const { searchQueries, reRankQuery, aiExtensionResult } = await datasetSearchQueryExtension({
query,
llmModel: datasetSearchUsingExtensionQuery
? getLLMModel(datasetSearchExtensionModel).model
: undefined,
embeddingModel: props.model,
extensionBg: datasetSearchExtensionBg,
histories
});
const result = await searchDatasetData({
...props,
reRankQuery: rewriteQuery,
queries: concatQueries
reRankQuery: reRankQuery,
queries: searchQueries
});
return {
...result,
queryExtensionResult: aiExtensionResult
? {
model: aiExtensionResult.model,
llmModel: aiExtensionResult.llmModel,
inputTokens: aiExtensionResult.inputTokens,
outputTokens: aiExtensionResult.outputTokens,
query: extensionQueries.join('\n')
embeddingModel: aiExtensionResult.embeddingModel,
embeddingTokens: aiExtensionResult.embeddingTokens,
query: searchQueries.join('\n')
}
: undefined
};

View File

@ -1,17 +1,18 @@
import { type LLMModelItemType } from '@fastgpt/global/core/ai/model.d';
import { queryExtension } from '../../ai/functions/queryExtension';
import { type ChatItemType } from '@fastgpt/global/core/chat/type';
import { hashStr } from '@fastgpt/global/common/string/tools';
import { chatValue2RuntimePrompt } from '@fastgpt/global/core/chat/adapt';
import { addLog } from '../../../common/system/log';
export const datasetSearchQueryExtension = async ({
query,
extensionModel,
llmModel,
embeddingModel,
extensionBg = '',
histories = []
}: {
query: string;
extensionModel?: LLMModelItemType;
llmModel?: string;
embeddingModel?: string;
extensionBg?: string;
histories?: ChatItemType[];
}) => {
@ -28,19 +29,8 @@ export const datasetSearchQueryExtension = async ({
return filterSameQueries;
};
let { queries, rewriteQuery, alreadyExtension } = (() => {
// concat query
let rewriteQuery =
histories.length > 0
? `${histories
.map((item) => {
return `${item.obj}: ${chatValue2RuntimePrompt(item.value).text}`;
})
.join('\n')}
Human: ${query}
`
: query;
// 检查传入的 query 是否已经进行过扩展
let { queries, reRankQuery, alreadyExtension } = (() => {
/* if query already extension, direct parse */
try {
const jsonParse = JSON.parse(query);
@ -48,41 +38,45 @@ Human: ${query}
const alreadyExtension = Array.isArray(jsonParse);
return {
queries,
rewriteQuery: alreadyExtension ? queries.join('\n') : rewriteQuery,
alreadyExtension: alreadyExtension
reRankQuery: alreadyExtension ? queries.join('\n') : query,
alreadyExtension
};
} catch (error) {
return {
queries: [query],
rewriteQuery,
reRankQuery: query,
alreadyExtension: false
};
}
})();
// ai extension
// Use LLM to generate extension queries
const aiExtensionResult = await (async () => {
if (!extensionModel || alreadyExtension) return;
const result = await queryExtension({
chatBg: extensionBg,
query,
histories,
model: extensionModel.model
});
if (result.extensionQueries?.length === 0) return;
return result;
if (!llmModel || !embeddingModel || alreadyExtension) return;
try {
const result = await queryExtension({
chatBg: extensionBg,
query,
histories,
llmModel,
embeddingModel
});
if (result.extensionQueries?.length === 0) return;
return result;
} catch (error) {
addLog.error('Failed to generate extension queries', error);
}
})();
const extensionQueries = filterSamQuery(aiExtensionResult?.extensionQueries || []);
if (aiExtensionResult) {
queries = filterSamQuery(queries.concat(extensionQueries));
rewriteQuery = queries.join('\n');
queries = queries.concat(aiExtensionResult.extensionQueries);
reRankQuery = queries.join('\n');
}
return {
extensionQueries,
concatQueries: queries,
rewriteQuery,
searchQueries: queries,
reRankQuery,
aiExtensionResult
};
};

View File

@ -44,12 +44,12 @@ export const filterDatasetsByTmbId = async ({
*
* ```typescript
* const datasetQuoteText = '![image.png](dataset/68fee42e1d416bb5ddc85b19/6901c3071ba2bea567e8d8db/aZos7D-214afce5-4d42-4356-9e05-8164d51c59ae.png)';
* const replacedText = await replaceDatasetQuoteTextWithJWT(datasetQuoteText, addDays(new Date(), 90))
* const replacedText = await replaceS3KeyToPreviewUrl(datasetQuoteText, addDays(new Date(), 90))
* console.log(replacedText)
* // '![image.png](http://localhost:3000/api/system/file/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvYmplY3RLZXkiOiJjaGF0LzY5MWFlMjlkNDA0ZDA0Njg3MTdkZDc0Ny82OGFkODVhNzQ2MzAwNmM5NjM3OTlhMDcvalhmWHk4eWZHQUZzOVdKcGNXUmJBaFYyL3BhcnNlZC85YTBmNGZlZC00ZWRmLTQ2MTMtYThkNi01MzNhZjVhZTUxZGMucG5nIiwiaWF0IjoxNzYzMzcwOTYwLCJleHAiOjk1MzkzNzA5NjB9.tMDWg0-ZWRnWPNp9Hakd0w1hhaO8jj2oD98SU0wAQYQ)'
* ```
*/
export function replaceDatasetQuoteTextWithJWT(documentQuoteText: string, expiredTime: Date) {
export function replaceS3KeyToPreviewUrl(documentQuoteText: string, expiredTime: Date) {
if (!documentQuoteText || typeof documentQuoteText !== 'string')
return documentQuoteText as string;

View File

@ -41,9 +41,6 @@ import { i18nT } from '../../../../../web/i18n/utils';
import { postTextCensor } from '../../../chat/postTextCensor';
import { createLLMResponse } from '../../../ai/llm/request';
import { formatModelChars2Points } from '../../../../support/wallet/usage/utils';
import { replaceDatasetQuoteTextWithJWT } from '../../../dataset/utils';
import { getFileS3Key } from '../../../../common/s3/utils';
import { addDays } from 'date-fns';
export type ChatProps = ModuleDispatchProps<
AIChatNodeProps & {
@ -307,7 +304,6 @@ async function filterDatasetQuote({
: '';
return {
// datasetQuoteText: replaceDatasetQuoteTextWithJWT(datasetQuoteText, addDays(new Date(), 90))
datasetQuoteText
};
}

View File

@ -1,7 +1,4 @@
import {
type DispatchNodeResponseType,
type DispatchNodeResultType
} from '@fastgpt/global/core/workflow/runtime/type.d';
import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type.d';
import { formatModelChars2Points } from '../../../../support/wallet/usage/utils';
import type { SelectedDatasetType } from '@fastgpt/global/core/workflow/type/io';
import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
@ -117,7 +114,7 @@ export async function dispatchDatasetSearch(
return emptyResult;
}
// get vector
// Get vector model
const vectorModel = getEmbeddingModel(
(await MongoDataset.findById(datasets[0].datasetId, 'vectorModel').lean())?.vectorModel
);
@ -165,7 +162,7 @@ export async function dispatchDatasetSearch(
// count bill results
const nodeDispatchUsages: ChatNodeUsageType[] = [];
// vector
// 1. Search vector
const { totalPoints: embeddingTotalPoints, modelName: embeddingModelName } =
formatModelChars2Points({
model: vectorModel.model,
@ -177,96 +174,98 @@ export async function dispatchDatasetSearch(
model: embeddingModelName,
inputTokens: embeddingTokens
});
// Rerank
const { totalPoints: reRankTotalPoints, modelName: reRankModelName } = formatModelChars2Points({
model: rerankModelData?.model,
inputTokens: reRankInputTokens
});
// 2. Rerank
if (usingReRank) {
const { totalPoints: reRankTotalPoints, modelName: reRankModelName } =
formatModelChars2Points({
model: rerankModelData?.model,
inputTokens: reRankInputTokens
});
nodeDispatchUsages.push({
totalPoints: reRankTotalPoints,
moduleName: node.name,
moduleName: i18nT('account_usage:rerank'),
model: reRankModelName,
inputTokens: reRankInputTokens
});
}
// Query extension
(() => {
if (queryExtensionResult) {
const { totalPoints, modelName } = formatModelChars2Points({
model: queryExtensionResult.model,
inputTokens: queryExtensionResult.inputTokens,
outputTokens: queryExtensionResult.outputTokens
});
nodeDispatchUsages.push({
totalPoints,
moduleName: i18nT('common:core.module.template.Query extension'),
model: modelName,
inputTokens: queryExtensionResult.inputTokens,
outputTokens: queryExtensionResult.outputTokens
});
return {
totalPoints
};
}
return {
totalPoints: 0
};
})();
// Deep search
(() => {
if (deepSearchResult) {
const { totalPoints, modelName } = formatModelChars2Points({
model: deepSearchResult.model,
inputTokens: deepSearchResult.inputTokens,
outputTokens: deepSearchResult.outputTokens
});
nodeDispatchUsages.push({
totalPoints,
moduleName: i18nT('common:deep_rag_search'),
model: modelName,
inputTokens: deepSearchResult.inputTokens,
outputTokens: deepSearchResult.outputTokens
});
return {
totalPoints
};
}
return {
totalPoints: 0
};
})();
// 3. Query extension
if (queryExtensionResult) {
const { totalPoints: llmPoints, modelName: llmModelName } = formatModelChars2Points({
model: queryExtensionResult.llmModel,
inputTokens: queryExtensionResult.inputTokens,
outputTokens: queryExtensionResult.outputTokens
});
nodeDispatchUsages.push({
totalPoints: llmPoints,
moduleName: i18nT('common:core.module.template.Query extension'),
model: llmModelName,
inputTokens: queryExtensionResult.inputTokens,
outputTokens: queryExtensionResult.outputTokens
});
const { totalPoints: embeddingPoints, modelName: embeddingModelName } =
formatModelChars2Points({
model: queryExtensionResult.embeddingModel,
inputTokens: queryExtensionResult.embeddingTokens
});
nodeDispatchUsages.push({
totalPoints: embeddingPoints,
moduleName: `${i18nT('account_usage:ai.query_extension_embedding')}`,
model: embeddingModelName,
inputTokens: queryExtensionResult.embeddingTokens,
outputTokens: 0
});
}
// 4. Deep search
if (deepSearchResult) {
const { totalPoints, modelName } = formatModelChars2Points({
model: deepSearchResult.model,
inputTokens: deepSearchResult.inputTokens,
outputTokens: deepSearchResult.outputTokens
});
nodeDispatchUsages.push({
totalPoints,
moduleName: i18nT('common:deep_rag_search'),
model: modelName,
inputTokens: deepSearchResult.inputTokens,
outputTokens: deepSearchResult.outputTokens
});
}
const totalPoints = nodeDispatchUsages.reduce((acc, item) => acc + item.totalPoints, 0);
const responseData: DispatchNodeResponseType & { totalPoints: number } = {
totalPoints,
query: userChatInput,
embeddingModel: vectorModel.name,
embeddingTokens,
similarity: usingSimilarityFilter ? similarity : undefined,
limit,
searchMode,
embeddingWeight:
searchMode === DatasetSearchModeEnum.mixedRecall ? embeddingWeight : undefined,
// Rerank
...(searchUsingReRank && {
rerankModel: rerankModelData?.name,
rerankWeight: rerankWeight,
reRankInputTokens
}),
searchUsingReRank,
// Results
quoteList: searchRes,
queryExtensionResult,
deepSearchResult
};
return {
data: {
quoteQA: searchRes
},
[DispatchNodeResponseKeyEnum.nodeResponse]: responseData,
[DispatchNodeResponseKeyEnum.nodeResponse]: {
totalPoints,
query: userChatInput,
embeddingModel: vectorModel.name,
embeddingTokens,
similarity: usingSimilarityFilter ? similarity : undefined,
limit,
searchMode,
embeddingWeight:
searchMode === DatasetSearchModeEnum.mixedRecall ? embeddingWeight : undefined,
// Rerank
...(searchUsingReRank && {
rerankModel: rerankModelData?.name,
rerankWeight: rerankWeight,
reRankInputTokens
}),
searchUsingReRank,
queryExtensionResult: queryExtensionResult
? {
model: queryExtensionResult.llmModel,
inputTokens: queryExtensionResult.inputTokens,
outputTokens: queryExtensionResult.outputTokens,
query: queryExtensionResult.query
}
: undefined,
deepSearchResult,
// Results
quoteList: searchRes
},
nodeDispatchUsages,
[DispatchNodeResponseKeyEnum.toolResponses]:
searchRes.length > 0

View File

@ -3,13 +3,12 @@ import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/
import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { getLLMModel } from '../../../../core/ai/model';
import { getLLMModel, getEmbeddingModel } from '../../../../core/ai/model';
import { formatModelChars2Points } from '../../../../support/wallet/usage/utils';
import { queryExtension } from '../../../../core/ai/functions/queryExtension';
import { getHistories } from '../utils';
import { hashStr } from '@fastgpt/global/common/string/tools';
import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type';
import { ModelTypeEnum } from '@fastgpt/global/core/ai/model';
type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.aiModel]: string;
@ -31,23 +30,39 @@ export const dispatchQueryExtension = async ({
}
const queryExtensionModel = getLLMModel(model);
const embeddingModel = getEmbeddingModel();
const chatHistories = getHistories(history, histories);
const { extensionQueries, inputTokens, outputTokens } = await queryExtension({
const {
extensionQueries,
inputTokens,
outputTokens,
embeddingTokens,
llmModel,
embeddingModel: useEmbeddingModel
} = await queryExtension({
chatBg: systemPrompt,
query: userChatInput,
histories: chatHistories,
model: queryExtensionModel.model
llmModel: queryExtensionModel.model,
embeddingModel: embeddingModel.model
});
extensionQueries.unshift(userChatInput);
const { totalPoints, modelName } = formatModelChars2Points({
model: queryExtensionModel.model,
const { totalPoints: llmPoints, modelName: llmModelName } = formatModelChars2Points({
model: llmModel,
inputTokens,
outputTokens
});
const { totalPoints: embeddingPoints, modelName: embeddingModelName } = formatModelChars2Points({
model: useEmbeddingModel,
inputTokens: embeddingTokens
});
const totalPoints = llmPoints + embeddingPoints;
const set = new Set<string>();
const filterSameQueries = extensionQueries.filter((item) => {
// 删除所有的标点符号与空格等,只对文本进行比较
@ -63,19 +78,27 @@ export const dispatchQueryExtension = async ({
},
[DispatchNodeResponseKeyEnum.nodeResponse]: {
totalPoints,
model: modelName,
model: llmModelName,
inputTokens,
outputTokens,
embeddingTokens,
query: userChatInput,
textOutput: JSON.stringify(filterSameQueries)
},
[DispatchNodeResponseKeyEnum.nodeDispatchUsages]: [
{
moduleName: node.name,
totalPoints,
model: modelName,
totalPoints: llmPoints,
model: llmModelName,
inputTokens,
outputTokens
},
{
moduleName: `${node.name} - Embedding`,
totalPoints: embeddingPoints,
model: embeddingModelName,
inputTokens: embeddingTokens,
outputTokens: 0
}
]
};

View File

@ -10,18 +10,17 @@ import { detectFileEncoding, parseUrlToFileType } from '@fastgpt/global/common/f
import { readS3FileContentByBuffer } from '../../../../common/file/read/utils';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { type ChatItemType, type UserChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import { parseFileExtensionFromUrl } from '@fastgpt/global/common/string/tools';
import { addLog } from '../../../../common/system/log';
import { addRawTextBuffer, getRawTextBuffer } from '../../../../common/buffer/rawText/controller';
import { addDays, addMinutes } from 'date-fns';
import { addDays } from 'date-fns';
import { getNodeErrResponse } from '../utils';
import { isInternalAddress } from '../../../../common/system/utils';
import { replaceDatasetQuoteTextWithJWT } from '../../../dataset/utils';
import { replaceS3KeyToPreviewUrl } from '../../../dataset/utils';
import { getFileS3Key } from '../../../../common/s3/utils';
import { S3ChatSource } from '../../../../common/s3/sources/chat';
import path from 'path';
import path from 'node:path';
import { S3Buckets } from '../../../../common/s3/constants';
import { S3Sources } from '../../../../common/s3/type';
import { getS3DatasetSource } from '../../../../common/s3/sources/dataset';
type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.fileUrlList]: string[];
@ -176,12 +175,15 @@ export const getFileContentFromLinks = async ({
parseUrlList
.map(async (url) => {
// Get from buffer
const fileBuffer = await getRawTextBuffer(url);
if (fileBuffer) {
const rawTextBuffer = await getS3DatasetSource().getRawTextBuffer({
sourceId: url,
customPdfParse
});
if (rawTextBuffer) {
return formatResponseObject({
filename: fileBuffer.sourceName || url,
filename: rawTextBuffer.filename || url,
url,
content: fileBuffer.text
content: rawTextBuffer.text
});
}
@ -264,14 +266,14 @@ export const getFileContentFromLinks = async ({
usageId
});
const replacedText = replaceDatasetQuoteTextWithJWT(rawText, addDays(new Date(), 90));
const replacedText = replaceS3KeyToPreviewUrl(rawText, addDays(new Date(), 90));
// Add to buffer
addRawTextBuffer({
getS3DatasetSource().addRawTextBuffer({
sourceId: url,
sourceName: filename,
text: replacedText,
expiredTime: addMinutes(new Date(), 20)
customPdfParse
});
return formatResponseObject({ filename, url, content: replacedText });

View File

@ -4,6 +4,7 @@
"type": "module",
"dependencies": {
"@fastgpt/global": "workspace:*",
"@maxmind/geoip2-node": "^6.3.4",
"@modelcontextprotocol/sdk": "^1.24.0",
"@node-rs/jieba": "2.0.1",
"@opentelemetry/api": "^1.9.0",

View File

@ -0,0 +1,33 @@
import { Schema, getMongoModel } from '../../common/mongo/index';
import { AppCollectionName } from '../../core/app/schema';
import { TeamMemberCollectionName } from '@fastgpt/global/support/user/team/constant';
import { TeamCollectionName } from '@fastgpt/global/support/user/team/constant';
export const AppRegistrationCollectionName = 'app_registrations';
const AppRegistrationSchema = new Schema({
teamId: {
type: Schema.Types.ObjectId,
ref: TeamCollectionName,
required: true
},
tmbId: {
type: Schema.Types.ObjectId,
ref: TeamMemberCollectionName,
required: true
},
appId: {
type: Schema.Types.ObjectId,
ref: AppCollectionName
},
createdAt: {
type: Date
}
});
AppRegistrationSchema.index({ teamId: 1 });
export const MongoAppRegistration = getMongoModel(
AppRegistrationCollectionName,
AppRegistrationSchema
);

View File

@ -1,7 +1,4 @@
import { type AuthModeType, type AuthResponseType } from '../type';
import { type DatasetFileSchema } from '@fastgpt/global/core/dataset/type';
import { getFileById } from '../../../common/file/gridfs/controller';
import { BucketNameEnum, bucketNameMap } from '@fastgpt/global/common/file/constants';
import { CommonErrEnum } from '@fastgpt/global/common/error/code/common';
import { OwnerPermissionVal, ReadRoleVal } from '@fastgpt/global/support/permission/constant';
import { Permission } from '@fastgpt/global/support/permission/controller';
@ -10,8 +7,7 @@ import { addMinutes } from 'date-fns';
import { parseHeaderCert } from './common';
import jwt from 'jsonwebtoken';
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { S3Sources } from '../../../common/s3/type';
import { getS3DatasetSource, S3DatasetSource } from '../../../common/s3/sources/dataset';
import { getS3DatasetSource } from '../../../common/s3/sources/dataset';
import { isS3ObjectKey } from '../../../common/s3/utils';
export const authCollectionFile = async ({
@ -22,19 +18,12 @@ export const authCollectionFile = async ({
fileId: string;
}): Promise<AuthResponseType> => {
const authRes = await parseHeaderCert(props);
const { teamId, tmbId } = authRes;
if (isS3ObjectKey(fileId, 'dataset')) {
const stat = await getS3DatasetSource().getDatasetFileStat(fileId);
if (!stat) return Promise.reject(CommonErrEnum.fileNotFound);
} else {
const file = await getFileById({ bucketName: BucketNameEnum.dataset, fileId });
if (!file) {
return Promise.reject(CommonErrEnum.fileNotFound);
}
if (file.metadata?.teamId !== teamId) {
return Promise.reject(CommonErrEnum.unAuthFile);
}
return Promise.reject('Invalid dataset file key');
}
const permission = new Permission({ role: ReadRoleVal, isOwner: true });
@ -49,27 +38,6 @@ export const authCollectionFile = async ({
};
};
/* file permission */
export const createFileToken = (data: FileTokenQuery) => {
if (!process.env.FILE_TOKEN_KEY) {
return Promise.reject('System unset FILE_TOKEN_KEY');
}
const expireMinutes =
data.customExpireMinutes ?? bucketNameMap[data.bucketName].previewExpireMinutes;
const expiredTime = Math.floor(addMinutes(new Date(), expireMinutes).getTime() / 1000);
const key = (process.env.FILE_TOKEN_KEY as string) ?? 'filetoken';
const token = jwt.sign(
{
...data,
exp: expiredTime
},
key
);
return Promise.resolve(token);
};
export const authFileToken = (token?: string) =>
new Promise<FileTokenQuery>((resolve, reject) => {
if (!token) {

View File

@ -19,13 +19,11 @@ import { MongoDatasetData } from '../../../core/dataset/data/schema';
import { type AuthModeType, type AuthResponseType } from '../type';
import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants';
import { type ParentIdType } from '@fastgpt/global/common/parentFolder/type';
import { getDatasetImagePreviewUrl } from '../../../core/dataset/image/utils';
import { i18nT } from '../../../../web/i18n/utils';
import { parseHeaderCert } from '../auth/common';
import { sumPer } from '@fastgpt/global/support/permission/utils';
import { getS3DatasetSource, S3DatasetSource } from '../../../common/s3/sources/dataset';
import { isS3ObjectKey, jwtSignS3ObjectKey } from '../../../common/s3/utils';
import { addHours } from 'date-fns';
import { getS3DatasetSource } from '../../../common/s3/sources/dataset';
import { isS3ObjectKey } from '../../../common/s3/utils';
export const authDatasetByTmbId = async ({
tmbId,
@ -174,55 +172,6 @@ export async function authDatasetCollection({
};
}
// export async function authDatasetFile({
// fileId,
// per,
// ...props
// }: AuthModeType & {
// fileId: string;
// }): Promise<
// AuthResponseType<DatasetPermission> & {
// file: DatasetFileSchema;
// }
// > {
// const { teamId, tmbId, isRoot } = await parseHeaderCert(props);
// const [file, collection] = await Promise.all([
// getFileById({ bucketName: BucketNameEnum.dataset, fileId }),
// MongoDatasetCollection.findOne({
// teamId,
// fileId
// })
// ]);
// if (!file) {
// return Promise.reject(CommonErrEnum.fileNotFound);
// }
// if (!collection) {
// return Promise.reject(DatasetErrEnum.unAuthDatasetFile);
// }
// try {
// const { permission } = await authDatasetCollection({
// ...props,
// collectionId: collection._id,
// per,
// isRoot
// });
// return {
// teamId,
// tmbId,
// file,
// permission,
// isRoot
// };
// } catch (error) {
// return Promise.reject(DatasetErrEnum.unAuthDatasetFile);
// }
// }
/*
DatasetData permission is inherited from collection.
*/
@ -251,21 +200,14 @@ export async function authDatasetData({
q: datasetData.q,
a: datasetData.a,
imageId: datasetData.imageId,
imagePreivewUrl: datasetData.imageId
? isS3ObjectKey(datasetData.imageId, 'dataset')
? // jwtSignS3ObjectKey(datasetData.imageId, addHours(new Date(), 1))
await getS3DatasetSource().createGetDatasetFileURL({
imagePreivewUrl:
datasetData.imageId && isS3ObjectKey(datasetData.imageId, 'dataset')
? await getS3DatasetSource().createGetDatasetFileURL({
key: datasetData.imageId,
expiredHours: 1,
external: true
})
: getDatasetImagePreviewUrl({
imageId: datasetData.imageId,
teamId: datasetData.teamId,
datasetId: datasetData.datasetId,
expiredMinutes: 30
})
: undefined,
: undefined,
chunkIndex: datasetData.chunkIndex,
indexes: datasetData.indexes,
datasetId: String(datasetData.datasetId),

View File

@ -4,7 +4,7 @@ import { MongoDataset } from '../../core/dataset/schema';
import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants';
import { TeamErrEnum } from '@fastgpt/global/common/error/code/team';
import { SystemErrEnum } from '@fastgpt/global/common/error/code/system';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { AppTypeEnum, ToolTypeList, AppFolderTypeList } from '@fastgpt/global/core/app/constants';
import { MongoTeamMember } from '../user/team/teamMemberSchema';
import { TeamMemberStatusEnum } from '@fastgpt/global/support/user/team/constant';
import { getVectorCountByTeamId } from '../../common/vectorDB/controller';
@ -40,41 +40,62 @@ export const checkTeamMemberLimit = async (teamId: string, newCount: number) =>
}
};
export const checkTeamAppLimit = async (teamId: string, amount = 1) => {
const [{ standardConstants }, appCount] = await Promise.all([
getTeamStandPlan({ teamId }),
MongoApp.countDocuments({
export const checkTeamAppTypeLimit = async ({
teamId,
appCheckType,
amount = 1
}: {
teamId: string;
appCheckType: 'app' | 'tool' | 'folder';
amount?: number;
}) => {
if (appCheckType === 'app') {
const [{ standardConstants }, appCount] = await Promise.all([
getTeamStandPlan({ teamId }),
MongoApp.countDocuments({
teamId,
type: {
$in: [AppTypeEnum.simple, AppTypeEnum.workflow]
}
})
]);
if (standardConstants && appCount + amount > standardConstants.maxAppAmount) {
return Promise.reject(TeamErrEnum.appAmountNotEnough);
}
// System check
if (global?.licenseData?.maxApps && typeof global?.licenseData?.maxApps === 'number') {
const totalApps = await MongoApp.countDocuments({
type: {
$in: [AppTypeEnum.simple, AppTypeEnum.workflow]
}
});
if (totalApps > global.licenseData.maxApps) {
return Promise.reject(SystemErrEnum.licenseAppAmountLimit);
}
}
} else if (appCheckType === 'tool') {
const toolCount = await MongoApp.countDocuments({
teamId,
type: {
$in: [
AppTypeEnum.simple,
AppTypeEnum.workflow,
AppTypeEnum.workflowTool,
AppTypeEnum.mcpToolSet,
AppTypeEnum.httpToolSet
]
}
})
]);
if (standardConstants && appCount + amount >= standardConstants.maxAppAmount) {
return Promise.reject(TeamErrEnum.appAmountNotEnough);
}
// System check
if (global?.licenseData?.maxApps && typeof global?.licenseData?.maxApps === 'number') {
const totalApps = await MongoApp.countDocuments({
type: {
$in: [
AppTypeEnum.simple,
AppTypeEnum.workflow,
AppTypeEnum.workflowTool,
AppTypeEnum.mcpToolSet
]
$in: ToolTypeList
}
});
if (totalApps >= global.licenseData.maxApps) {
return Promise.reject(SystemErrEnum.licenseAppAmountLimit);
const maxToolAmount = 1000;
if (toolCount + amount > maxToolAmount) {
return Promise.reject(TeamErrEnum.pluginAmountNotEnough);
}
} else if (appCheckType === 'folder') {
const folderCount = await MongoApp.countDocuments({
teamId,
type: {
$in: AppFolderTypeList
}
});
const maxAppFolderAmount = 1000;
if (folderCount + amount > maxAppFolderAmount) {
return Promise.reject(TeamErrEnum.appFolderAmountNotEnough);
}
}
};
@ -131,7 +152,7 @@ export const checkTeamDatasetSyncPermission = async (teamId: string) => {
teamId
});
if (standardConstants && !standardConstants?.permissionWebsiteSync) {
if (standardConstants && !standardConstants?.websiteSyncPerDataset) {
return Promise.reject(TeamErrEnum.websiteSyncNotEnough);
}
};

View File

@ -35,7 +35,6 @@ const OperationLogSchema = new Schema({
});
OperationLogSchema.index({ teamId: 1, tmbId: 1, event: 1 });
OperationLogSchema.index({ timestamp: 1 }, { expireAfterSeconds: 14 * 24 * 60 * 60 }); // Auto delete after 14 days
export const MongoOperationLog = getMongoLogModel<OperationLogSchema>(
OperationLogCollectionName,

View File

@ -45,8 +45,8 @@ export async function getUserDetail({
timezone: user.timezone,
promotionRate: user.promotionRate,
team: tmb,
notificationAccount: tmb.notificationAccount,
permission: tmb.permission,
contact: user.contact
contact: user.contact,
language: user.language
};
}

View File

@ -4,6 +4,7 @@ import { hashStr } from '@fastgpt/global/common/string/tools';
import type { UserModelSchema } from '@fastgpt/global/support/user/type';
import { UserStatusEnum, userStatusMap } from '@fastgpt/global/support/user/constant';
import { TeamMemberCollectionName } from '@fastgpt/global/support/user/team/constant';
import { LangEnum } from '@fastgpt/global/common/i18n/type';
export const userCollectionName = 'users';
@ -19,7 +20,6 @@ const UserSchema = new Schema({
required: true,
unique: true // 唯一
},
phonePrefix: Number,
password: {
type: String,
required: true,
@ -46,6 +46,10 @@ const UserSchema = new Schema({
type: String,
default: 'Asia/Shanghai'
},
language: {
type: String,
default: LangEnum.zh_CN
},
lastLoginTmbId: {
type: Schema.Types.ObjectId,
ref: TeamMemberCollectionName
@ -58,6 +62,8 @@ const UserSchema = new Schema({
},
fastgpt_sem: Object,
sourceDomain: String,
phonePrefix: Number,
contact: String,
/** @deprecated */

View File

@ -0,0 +1,42 @@
import { connectionMongo, getMongoModel } from '../../../common/mongo';
const { Schema } = connectionMongo;
import { TeamCollectionName } from '@fastgpt/global/support/user/team/constant';
import { DiscountCouponTypeEnum } from '@fastgpt/global/support/wallet/sub/discountCoupon/constants';
import type { DiscountCouponSchemaType } from '@fastgpt/global/openapi/support/wallet/discountCoupon/api';
export const discountCouponCollectionName = 'team_discount_coupons';
const DiscountCouponSchema = new Schema({
teamId: {
type: Schema.Types.ObjectId,
ref: TeamCollectionName,
required: true
},
type: {
type: String,
required: true,
enum: Object.values(DiscountCouponTypeEnum)
},
startTime: Date,
expiredTime: {
type: Date,
required: true
},
usedAt: Date,
createTime: {
type: Date,
default: () => new Date()
}
});
try {
DiscountCouponSchema.index({ status: 1, type: 1 });
DiscountCouponSchema.index({ teamId: 1, status: 1 });
} catch (error) {
console.log(error);
}
export const MongoDiscountCoupon = getMongoModel<DiscountCouponSchemaType>(
discountCouponCollectionName,
DiscountCouponSchema
);

View File

@ -56,6 +56,15 @@ const SubSchema = new Schema({
maxApp: Number,
maxDataset: Number,
// custom level configurations
requestsPerMinute: Number,
chatHistoryStoreDuration: Number,
maxDatasetSize: Number,
websiteSyncPerDataset: Number,
appRegistrationCount: Number,
auditLogStoreDuration: Number,
ticketResponseTime: Number,
// stand sub and extra points sub. Plan total points
totalPoints: {
type: Number

View File

@ -63,7 +63,18 @@ export const getTeamStandPlan = async ({ teamId }: { teamId: string }) => {
...standardConstants,
maxTeamMember: standard?.maxTeamMember || standardConstants.maxTeamMember,
maxAppAmount: standard?.maxApp || standardConstants.maxAppAmount,
maxDatasetAmount: standard?.maxDataset || standardConstants.maxDatasetAmount
maxDatasetAmount: standard?.maxDataset || standardConstants.maxDatasetAmount,
requestsPerMinute: standard?.requestsPerMinute || standardConstants.requestsPerMinute,
chatHistoryStoreDuration:
standard?.chatHistoryStoreDuration || standardConstants.chatHistoryStoreDuration,
maxDatasetSize: standard?.maxDatasetSize || standardConstants.maxDatasetSize,
websiteSyncPerDataset:
standard?.websiteSyncPerDataset || standardConstants.websiteSyncPerDataset,
appRegistrationCount:
standard?.appRegistrationCount || standardConstants.appRegistrationCount,
auditLogStoreDuration:
standard?.auditLogStoreDuration || standardConstants.auditLogStoreDuration,
ticketResponseTime: standard?.ticketResponseTime || standardConstants.ticketResponseTime
}
: undefined
};
@ -165,7 +176,9 @@ export const getTeamPlanStatus = async ({
const standardMaxDatasetSize =
standardPlan?.currentSubLevel && standardPlans
? standardPlans[standardPlan.currentSubLevel]?.maxDatasetSize || Infinity
? standardPlans[standardPlan.currentSubLevel]?.maxDatasetSize ||
standardPlan?.maxDatasetSize ||
Infinity
: Infinity;
const totalDatasetSize =
standardMaxDatasetSize +
@ -185,7 +198,19 @@ export const getTeamPlanStatus = async ({
...standardConstants,
maxTeamMember: standardPlan?.maxTeamMember || standardConstants.maxTeamMember,
maxAppAmount: standardPlan?.maxApp || standardConstants.maxAppAmount,
maxDatasetAmount: standardPlan?.maxDataset || standardConstants.maxDatasetAmount
maxDatasetAmount: standardPlan?.maxDataset || standardConstants.maxDatasetAmount,
requestsPerMinute: standardPlan?.requestsPerMinute || standardConstants.requestsPerMinute,
chatHistoryStoreDuration:
standardPlan?.chatHistoryStoreDuration || standardConstants.chatHistoryStoreDuration,
maxDatasetSize: standardPlan?.maxDatasetSize || standardConstants.maxDatasetSize,
websiteSyncPerDataset:
standardPlan?.websiteSyncPerDataset || standardConstants.websiteSyncPerDataset,
appRegistrationCount:
standardPlan?.appRegistrationCount || standardConstants.appRegistrationCount,
auditLogStoreDuration:
standardPlan?.auditLogStoreDuration || standardConstants.auditLogStoreDuration,
ticketResponseTime:
standardPlan?.ticketResponseTime || standardConstants.ticketResponseTime
}
: undefined,

View File

@ -28,6 +28,7 @@ declare global {
CHECK_INTERNAL_IP?: string;
ALLOWED_ORIGINS?: string;
SHOW_COUPON?: string;
SHOW_DISCOUNT_COUPON?: string;
CONFIG_JSON_PATH?: string;
PASSWORD_LOGIN_LOCK_SECONDS?: string;
PASSWORD_EXPIRED_MONTH?: string;

View File

@ -83,5 +83,22 @@
"reset_default": "Restore the default configuration",
"team": "Team",
"third_party": "Third Party",
"usage_records": "Usage"
"usage_records": "Usage",
"bill_detail": "Bill details",
"order_number": "Order number",
"generation_time": "Generation time",
"order_type": "Order type",
"status": "state",
"payment_method": "Payment method",
"support_wallet_amount": "Amount",
"yuan": "¥{{amount}}",
"has_invoice": "Whether the invoice has been issued",
"yes": "yes",
"no": "no",
"subscription_period": "Subscription cycle",
"subscription_package": "Subscription package",
"subscription_mode_month": "Duration",
"month": "moon",
"extra_dataset_size": "Additional knowledge base capacity",
"extra_ai_points": "AI points calculation standard"
}

View File

@ -4,7 +4,6 @@
"back": "return",
"bank_account": "Account opening account",
"bank_name": "Bank of deposit",
"bill_detail": "Bill details",
"bill_record": "billing records",
"click_to_download": "Click to download",
"company_address": "Company address",
@ -17,37 +16,23 @@
"default_header": "Default header",
"detail": "Details",
"email_address": "Email address",
"extra_ai_points": "AI points calculation standard",
"extra_dataset_size": "Additional knowledge base capacity",
"generation_time": "Generation time",
"has_invoice": "Whether the invoice has been issued",
"invoice_amount": "Invoice amount",
"invoice_detail": "Invoice details",
"invoice_sending_info": "The invoice will be sent to your mailbox within 3-7 working days, please be patient.",
"mm": "mm",
"month": "moon",
"need_special_invoice": "Do you need a special ticket?",
"no": "no",
"no_invoice_record": "No bill record~",
"no_invoice_record_tip": "No invoicing record yet",
"order_number": "Order number",
"order_type": "Order type",
"organization_name": "Organization name",
"payment_method": "Payment method",
"payway_coupon": "Redeem code",
"rerank": "Rerank",
"save": "save",
"save_failed": "Save exception",
"save_success": "Saved successfully",
"status": "state",
"sub_mode_custom": "Customize",
"submit_failed": "Submission failed",
"submit_success": "Submission successful",
"submitted": "Submitted",
"subscription_mode_month": "Duration",
"subscription_package": "Subscription package",
"subscription_period": "Subscription cycle",
"support_wallet_amount": "Amount",
"support_wallet_apply_invoice": "Billable bills",
"support_wallet_bill_tag_invoice": "bill invoice",
"support_wallet_invoicing": "Invoicing",
@ -56,7 +41,5 @@
"type": "type",
"unit_code": "unified credit code",
"unit_code_void": "Unified credit code format error",
"update": "renew",
"yes": "yes",
"yuan": "¥{{amount}}"
"update": "renew"
}

View File

@ -6,15 +6,18 @@
"ai_points_calculation_standard": "AI points",
"ai_points_usage": "AI points",
"ai_points_usage_tip": "Each time the AI model is called, a certain amount of AI points will be consumed. \nFor specific calculation standards, please refer to the \"Billing Standards\" above.",
"app_amount": "App amount",
"app_amount": "Agent amount",
"app_registration_count": "Registered app count",
"apply_app_registration": "Apply for app registration",
"avatar": "Avatar",
"avatar_selection_exception": "Abnormal avatar selection",
"avatar_can_only_select_one": "Avatar can only select one picture",
"avatar_can_only_select_jpg_png": "Avatar can only select jpg or png format",
"avatar_can_only_select_one": "Avatar can only select one picture",
"avatar_selection_exception": "Abnormal avatar selection",
"balance": "balance",
"billing_standard": "Standards",
"cancel": "Cancel",
"change": "change",
"check_purchase_history": "View order",
"choose_avatar": "Click to select avatar",
"click_modify_nickname": "Click to modify nickname",
"code_required": "Verification code cannot be empty",
@ -25,6 +28,7 @@
"current_package": "Current plan",
"current_token_price": "Current points price",
"dataset_amount": "Dataset amount",
"discount_coupon": "Coupon",
"effective_time": "Effective time",
"email_label": "Mail",
"exchange": "Exchange",
@ -32,6 +36,7 @@
"exchange_success": "Redemption successful",
"expiration_time": "Expiration time",
"expired": "Expired",
"expired_tips": "It has expired~",
"general_info": "General information",
"group": "Group",
"help_chatbot": "robot assistant",
@ -42,6 +47,7 @@
"member_name": "Name",
"month": "moon",
"new_password": "New Password",
"not_started_tips": "Not started",
"notification_receiving": "Notify",
"old_password": "Old Password",
"package_and_usage": "Plans",
@ -76,6 +82,8 @@
"upgrade_package": "Upgrade",
"usage_balance": "Use balance: Use balance",
"usage_balance_notice": "Due to the system upgrade, the original \"automatic renewal and deduction from balance\" mode has been cancelled, and the balance recharge entrance has been closed. \nYour balance can be used to purchase points",
"used_time": "Used at",
"used_tips": "Already used",
"user_account": "Username",
"user_team_team_name": "Team",
"verification_code": "Verification code",

View File

@ -1,4 +1,5 @@
{
"ai.query_extension_embedding": "Problem optimization-embedding",
"ai_model": "AI model",
"all": "all",
"app_name": "Application name",

View File

@ -209,6 +209,7 @@
"logs_keys_lastConversationTime": "last conversation time",
"logs_keys_messageCount": "Message Count",
"logs_keys_points": "Points Consumed",
"logs_keys_region": "User IP",
"logs_keys_responseTime": "Average Response Time",
"logs_keys_sessionId": "Session ID",
"logs_keys_source": "Source",

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