Commit 4d1372a5 by Xianquan Committed by GitHub

fix: optimize chat ui behavior (#7086)

* fix: bind s3 object keys to authorized resources

* fix: optimize chat ui behavior

* fix: align chat sandbox header actions

* fix: adjust quick apps spacing and auth test

* fix: polish chat ui details

* fix: stabilize chat app switching

* fix: bind chat updates to active history

* fix: refine chat ui interactions
parent 3a908e79
# S3 Object Key 授权绑定设计
## 背景
FastGPT 的私有对象存储 key 是 bucket 内的全局路径字符串,例如:
- `chat/<appId>/<uid>/<chatId>/<filename>`
- `dataset/<datasetId>/<filename>`
- `temp/<teamId>/<filename>`
- `helperBot/<type>/<userId>/<chatId>/<filename>`
这些路径段携带资源归属信息,但 S3 和下载代理本身只验证签名,不理解业务权限。因此任何来自请求体、query、工具参数或外部输入的 object key,在签发 URL、读取、预览、解析前,都必须先绑定到当前已鉴权的业务资源。
## 漏洞模式
危险模式是“校验 A 资源,使用 B key”:
1. API 先校验调用者可访问某个 app、dataset 或 team。
2. API 直接把请求里的 `key`、`fileId`、`sourceId` 传给 S3 签名或读取函数。
3. 如果 key 实际属于其他团队或资源,S3 签名仍然会成功,造成跨团队文件读取。
不能把以下条件当作权限:
- S3 对象存在。
- key 形如某个合法 source 前缀。
- 下载 JWT 签名有效。
- 请求里同时带了一个调用者有权限的 appId/datasetId。
## 统一授权 helper
新增入口必须优先复用 `packages/service/common/s3/utils.ts` 中的 helper:
- `parseChatFileS3Key` / `isAuthorizedChatFileS3Key`
- `parseDatasetFileS3Key` / `isAuthorizedDatasetFileS3Key`
- `parseHelperBotFileS3Key` / `isAuthorizedHelperBotFileS3Key`
- `isAuthorizedTempFileS3Key`
这些 helper 只负责 key 结构解析和 key 与已鉴权上下文的绑定判断。业务权限仍由对应 auth 函数负责:
- chat 文件:先 `authChatCrud`,再校验 `chat` key 的 `appId + uid`。
- dataset 文件:用 `authDatasetFileKey` 从 `dataset/<datasetId>/...` 解析 datasetId,并复用 dataset 权限体系。
- temp 文件:先得到当前 `teamId`,再校验 `temp/<teamId>/...`。
- helperBot 文件:先 `authCert` 得到 `userId`,再校验 `helperBot` key 的 `userId`。
## 新增入口规范
当 API 或工具入口接收外部传入的 S3 key 时,必须满足以下规则:
1. 使用 `parseApiInput` 校验请求入参。
2. 先完成业务资源鉴权,拿到可信的 `teamId`、`appId`、`datasetId`、`uid` 或 `userId`。
3. 使用对应 `isAuthorized*FileS3Key` helper 绑定 key 与可信上下文。
4. 绑定失败时返回通用未授权错误,不暴露 key 是否存在。
5. 只有通过绑定后,才允许调用 `createExternalUrl`、`createGet*URL`、`jwtSignS3DownloadToken`、`downloadObject`、`getDatasetFileRawText`、`isObjectExists` 等存储层能力。
## 底层防线
`S3BaseBucket.createExternalUrl` 是裸存储签名方法,只保证 token 有效,不做业务权限判断。调用方不能把它当成鉴权接口。
`readDatasetSourceRawText` 在 `fileLocal` 分支额外校验 `sourceId` 必须属于传入的 `datasetId`,用于防止未来新增入口绕过 API 层鉴权。
`authDatasetFileKey` 会先按 key 内的 datasetId 复用 dataset 权限体系,再检查对象是否存在。对象存在性不能出现在权限校验之前。
## 排查结论
已排查当前主要 S3 签名/读取点:
- 请求体直接传 key 的聊天文件下载、helperBot 文件预览、数据集预览、搜索测试临时图片已接入统一授权 helper。
- 其他 dataset data、training detail、collection read 等签名点使用的是数据库记录中的 key,并且前置查询已经绑定 `teamId/datasetId/collectionId` 权限边界。
- 通用 `/api/system/file/*` 代理只校验 token,它不是业务鉴权入口;安全性依赖 token 签发前的业务授权绑定。
## 测试要求
新增类似入口时至少补充以下测试:
- 合法 key 可以签名或读取。
- 同 source 但不同 app/dataset/user/team 的 key 被拒绝。
- 错误 source 或畸形 key 被拒绝。
- 被拒绝场景不得调用底层 S3 签名或读取 mock。
......@@ -64,14 +64,14 @@ export type UpdateHistoryBodyType = z.infer<typeof UpdateHistoryBodySchema>;
// Delete single chat history schema
export const DelChatHistorySchema = OutLinkChatAuthSchema.extend({
appId: ObjectIdSchema.describe('应用ID'),
appId: ObjectIdSchema.optional().describe('应用ID'),
chatId: z.string().min(1).describe('对话ID')
});
export type DelChatHistoryType = z.infer<typeof DelChatHistorySchema>;
// Clear all chat histories schema
export const ClearChatHistoriesSchema = OutLinkChatAuthSchema.extend({
appId: ObjectIdSchema.describe('应用ID')
appId: ObjectIdSchema.optional().describe('应用ID')
});
export type ClearChatHistoriesType = z.infer<typeof ClearChatHistoriesSchema>;
......
......@@ -233,6 +233,13 @@ export class S3BaseBucket {
}
}
/**
* 为对象 key 生成外部可访问 URL。
*
* 该方法只负责存储层签名,不做 team/app/dataset/user 的业务归属校验。任何 API 边界或
* 用户可控 key 调用到这里前,必须先使用 common/s3/utils 中的授权绑定 helper 校验 key
* 属于当前已鉴权资源。
*/
async createExternalUrl(params: createPreviewUrlParams) {
const parsed = CreateGetPresignedUrlParamsSchema.parse(params);
......
......@@ -18,10 +18,10 @@ import { addHours } from 'date-fns';
import { getLogger, LogCategories } from '../../../logger';
import { detectFileEncoding } from '@fastgpt/global/common/file/tools';
import { readFileContentByBuffer } from '../../../file/read/utils';
import path from 'node:path';
import { ensureTextContentTypeCharset, isTextLikeFile, resolveMimeType } from '../../utils/mime';
import { datasetAllowedExtensions } from '../../utils/uploadConstraints';
import { getFileS3Key, truncateFilename } from '../../utils';
import { isAuthorizedDatasetFileS3Key } from '../../utils';
import type { S3RawTextSource } from '../rawText';
import { getS3RawTextSource } from '../rawText';
......@@ -102,9 +102,13 @@ export class S3DatasetSource extends S3PrivateBucket {
}
async getDatasetFileRawText(params: GetDatasetFileContentParams) {
const { fileId, teamId, tmbId, customPdfParse, getFormatText, usageId } =
const { fileId, teamId, tmbId, customPdfParse, getFormatText, usageId, datasetId } =
GetDatasetFileContentParamsSchema.parse(params);
if (!isAuthorizedDatasetFileS3Key({ key: fileId, datasetId })) {
return Promise.reject('Invalid dataset file key');
}
const rawTextBuffer = await this.rawTextSource.getRawTextBuffer({
customPdfParse,
sourceId: fileId
......
......@@ -10,7 +10,7 @@ import {
import { differenceInHours } from 'date-fns';
import { S3Buckets } from '../../config/constants';
import path from 'path';
import { getFileS3Key } from '../../utils';
import { getFileS3Key, parseHelperBotFileS3Key } from '../../utils';
export class S3HelperBotSource extends S3PrivateBucket {
private static instance: S3HelperBotSource;
......@@ -54,8 +54,7 @@ export class S3HelperBotSource extends S3PrivateBucket {
}
parseKey(key: string) {
const [type, chatId, userId, filename] = key.split('/');
return { type, chatId, userId, filename };
return parseHelperBotFileS3Key(key);
}
async createGetFileURL(params: {
......
......@@ -9,6 +9,7 @@ import { getNanoid } from '@fastgpt/global/common/string/tools';
import path from 'node:path';
import type { ParsedFileContentS3KeyParams } from './sources/dataset/type';
import type { HelperBotTypeEnumType } from '@fastgpt/global/core/chat/helperBot/type';
import { HelperBotTypeEnumSchema } from '@fastgpt/global/core/chat/helperBot/type';
export { jwtSignS3ObjectKey, jwtVerifyS3ObjectKey, jwtSignS3DownloadToken } from './security/token';
......@@ -265,6 +266,135 @@ export function isS3ObjectKey<T extends keyof typeof S3Sources>(
return typeof key === 'string' && key.startsWith(`${S3Sources[source]}/`);
}
/**
* 解析聊天文件的 S3 key。
*
* 聊天文件 key 的授权边界由 appId、uid、chatId 共同决定。任何签名或读取前都应先解析
* 这些路径段并与已鉴权上下文绑定,避免只校验一个无关 app 后签发任意 object key。
*/
export function parseChatFileS3Key(key: string): {
appId: string;
uid: string;
chatId: string;
filename: string;
} | null {
if (!isS3ObjectKey(key, 'chat')) return null;
const [, appId, uid, chatId, ...filenameParts] = key.split('/');
const filename = filenameParts.join('/');
if (!appId || !uid || !chatId || !filename) return null;
return {
appId,
uid,
chatId,
filename
};
}
/**
* 判断聊天文件 key 是否属于已鉴权的 app 与聊天用户。
*/
export function isAuthorizedChatFileS3Key({
key,
appId,
uid
}: {
key: string;
appId: string;
uid: string;
}) {
const parsedKey = parseChatFileS3Key(key);
return (
!!parsedKey &&
String(parsedKey.appId) === String(appId) &&
String(parsedKey.uid) === String(uid)
);
}
/**
* 解析数据集文件的 S3 key。
*
* 数据集文件的第二段是 datasetId。调用方应基于解析出的 datasetId 做权限校验,而不是把
* S3 对象存在性当作访问权限。
*/
export function parseDatasetFileS3Key(key: string): {
datasetId: string;
filename: string;
} | null {
if (!isS3ObjectKey(key, 'dataset')) return null;
const [, datasetId, ...filenameParts] = key.split('/');
const filename = filenameParts.join('/');
if (!datasetId || !filename) return null;
return {
datasetId,
filename
};
}
/**
* 判断数据集文件 key 是否属于指定 dataset。
*/
export function isAuthorizedDatasetFileS3Key({
key,
datasetId
}: {
key: string;
datasetId: string;
}) {
const parsedKey = parseDatasetFileS3Key(key);
return !!parsedKey && String(parsedKey.datasetId) === String(datasetId);
}
/**
* 解析 HelperBot 文件 key。
*
* HelperBot 文件 key 的第一段是固定 source,后续才是 type/user/chat 维度。
*/
export function parseHelperBotFileS3Key(key: string): {
type: HelperBotTypeEnumType;
userId: string;
chatId: string;
filename: string;
} | null {
if (!isS3ObjectKey(key, 'helperBot')) return null;
const [, type, userId, chatId, ...filenameParts] = key.split('/');
const filename = filenameParts.join('/');
const parsedType = HelperBotTypeEnumSchema.safeParse(type);
if (!parsedType.success || !userId || !chatId || !filename) return null;
return {
type: parsedType.data,
userId,
chatId,
filename
};
}
/**
* 判断 HelperBot 文件 key 是否属于当前用户。
*/
export function isAuthorizedHelperBotFileS3Key({ key, userId }: { key: string; userId: string }) {
const parsedKey = parseHelperBotFileS3Key(key);
return !!parsedKey && String(parsedKey.userId) === String(userId);
}
/**
* 判断临时文件 key 是否属于指定团队。
*/
export function isAuthorizedTempFileS3Key({ key, teamId }: { key: string; teamId: string }) {
return isS3ObjectKey(key, 'temp') && key.startsWith(`temp/${teamId}/`);
}
export function sanitizeS3ObjectKey(key: string) {
// 替换掉圆括号
const replaceParentheses = (key: string) => {
......
......@@ -16,8 +16,9 @@ import { getFileMaxSize } from '../../common/file/utils';
import { UserError } from '@fastgpt/global/common/error/utils';
import { getAxiosHeaderValue } from '@fastgpt/global/common/axios/utils';
import { getS3DatasetSource } from '../../common/s3/sources/dataset';
import { getFileS3Key, isS3ObjectKey } from '../../common/s3/utils';
import { getFileS3Key, isS3ObjectKey, isAuthorizedDatasetFileS3Key } from '../../common/s3/utils';
import { getLogger, LogCategories } from '../../common/logger';
import { DatasetErrEnum } from '@fastgpt/global/common/error/code/dataset';
const logger = getLogger(LogCategories.MODULE.DATASET.FILE);
......@@ -193,6 +194,10 @@ export const readDatasetSourceRawText = async ({
return Promise.reject('datasetId is required for S3 files');
}
if (!isAuthorizedDatasetFileS3Key({ key: sourceId, datasetId })) {
return Promise.reject(DatasetErrEnum.unAuthDatasetFile);
}
const { filename, rawText } = await getS3DatasetSource().getDatasetFileRawText({
teamId,
tmbId,
......
import { type AuthModeType, type AuthResponseType } from '../type';
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';
import { OwnerPermissionVal } from '@fastgpt/global/support/permission/constant';
import type { FileTokenQuery } from '@fastgpt/global/common/file/type';
import { addMinutes } from 'date-fns';
import { parseHeaderCert } from './common';
import jwt from 'jsonwebtoken';
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { getS3DatasetSource } from '../../../common/s3/sources/dataset';
import { isS3ObjectKey } from '../../../common/s3/utils';
import { parseDatasetFileS3Key } from '../../../common/s3/utils';
import { serviceEnv } from '../../../env';
import { authDataset } from '../dataset/auth';
export const authCollectionFile = async ({
/**
* 校验来自请求的 dataset S3 object key 是否属于调用者有权限访问的数据集。
*
* 该函数会从 `dataset/<datasetId>/...` 中解析 datasetId,并复用数据集权限体系完成
* team/成员/协作者校验。S3 对象存在性只能作为最后的文件存在检查,不能作为权限依据。
*/
export const authDatasetFileKey = async ({
fileId,
per = OwnerPermissionVal,
...props
}: AuthModeType & {
fileId: string;
}): Promise<AuthResponseType> => {
const authRes = await parseHeaderCert(props);
if (isS3ObjectKey(fileId, 'dataset')) {
const exists = await getS3DatasetSource().isObjectExists(fileId);
if (!exists) return Promise.reject(CommonErrEnum.fileNotFound);
} else {
const parsedKey = parseDatasetFileS3Key(fileId);
if (!parsedKey) {
return Promise.reject('Invalid dataset file key');
}
const permission = new Permission({ role: ReadRoleVal, isOwner: true });
// 先按 key 内的 datasetId 做权限校验,再检查对象是否存在,避免用存在性绕过团队边界。
const authRes = await authDataset({
...props,
datasetId: parsedKey.datasetId,
per
});
if (!permission.checkPer(per)) {
const exists = await getS3DatasetSource().isObjectExists(fileId);
if (!exists) {
return Promise.reject(CommonErrEnum.fileNotFound);
}
if (!authRes.permission.checkPer(per)) {
return Promise.reject(CommonErrEnum.unAuthFile);
}
return {
...authRes,
permission
permission: authRes.permission
};
};
export const authCollectionFile = authDatasetFileKey;
export const authFileToken = (token?: string) =>
new Promise<FileTokenQuery>((resolve, reject) => {
if (!token) {
......
......@@ -5,7 +5,14 @@ import {
truncateFilename,
S3_FILENAME_MAX_LENGTH,
isS3ObjectKey,
getFileS3Key
getFileS3Key,
isAuthorizedChatFileS3Key,
isAuthorizedDatasetFileS3Key,
isAuthorizedHelperBotFileS3Key,
isAuthorizedTempFileS3Key,
parseChatFileS3Key,
parseDatasetFileS3Key,
parseHelperBotFileS3Key
} from '@fastgpt/service/common/s3/utils';
import * as stringTools from '@fastgpt/global/common/string/tools';
......@@ -505,6 +512,59 @@ describe('isS3ObjectKey', () => {
});
});
describe('authorized S3 object key helpers', () => {
it('parses and authorizes chat file keys by appId and uid', () => {
const key = 'chat/app-1/user-1/chat-1/folder/demo.pdf';
expect(parseChatFileS3Key(key)).toEqual({
appId: 'app-1',
uid: 'user-1',
chatId: 'chat-1',
filename: 'folder/demo.pdf'
});
expect(isAuthorizedChatFileS3Key({ key, appId: 'app-1', uid: 'user-1' })).toBe(true);
expect(isAuthorizedChatFileS3Key({ key, appId: 'app-2', uid: 'user-1' })).toBe(false);
expect(isAuthorizedChatFileS3Key({ key, appId: 'app-1', uid: 'user-2' })).toBe(false);
expect(parseChatFileS3Key('temp/app-1/user-1/chat-1/demo.pdf')).toBeNull();
});
it('parses and authorizes dataset file keys by datasetId', () => {
const key = 'dataset/dataset-1/folder/demo.pdf';
expect(parseDatasetFileS3Key(key)).toEqual({
datasetId: 'dataset-1',
filename: 'folder/demo.pdf'
});
expect(isAuthorizedDatasetFileS3Key({ key, datasetId: 'dataset-1' })).toBe(true);
expect(isAuthorizedDatasetFileS3Key({ key, datasetId: 'dataset-2' })).toBe(false);
expect(parseDatasetFileS3Key('dataset/dataset-1')).toBeNull();
});
it('parses and authorizes helper bot file keys by userId', () => {
const key = 'helperBot/topAgent/user-1/chat-1/demo.pdf';
expect(parseHelperBotFileS3Key(key)).toEqual({
type: 'topAgent',
userId: 'user-1',
chatId: 'chat-1',
filename: 'demo.pdf'
});
expect(isAuthorizedHelperBotFileS3Key({ key, userId: 'user-1' })).toBe(true);
expect(isAuthorizedHelperBotFileS3Key({ key, userId: 'user-2' })).toBe(false);
expect(parseHelperBotFileS3Key('helperBot/unknown/user-1/chat-1/demo.pdf')).toBeNull();
});
it('authorizes temp file keys by exact team path segment', () => {
expect(isAuthorizedTempFileS3Key({ key: 'temp/team-1/demo.pdf', teamId: 'team-1' })).toBe(true);
expect(isAuthorizedTempFileS3Key({ key: 'temp/team-11/demo.pdf', teamId: 'team-1' })).toBe(
false
);
expect(isAuthorizedTempFileS3Key({ key: 'dataset/team-1/demo.pdf', teamId: 'team-1' })).toBe(
false
);
});
});
describe('getFileS3Key', () => {
let mockNanoid: ReturnType<typeof vi.fn>;
......
import { DatasetErrEnum } from '@fastgpt/global/common/error/code/dataset';
import { DatasetSourceReadTypeEnum } from '@fastgpt/global/core/dataset/constants';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
getDatasetFileRawText: vi.fn()
}));
vi.mock('@fastgpt/service/common/s3/sources/dataset', () => ({
getS3DatasetSource: () => ({
getDatasetFileRawText: mocks.getDatasetFileRawText
})
}));
import { readDatasetSourceRawText } from '@fastgpt/service/core/dataset/read';
describe('readDatasetSourceRawText', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getDatasetFileRawText.mockResolvedValue({
filename: 'demo.pdf',
rawText: 'demo content'
});
});
it('rejects a local dataset file key that is not under the authorized dataset id', async () => {
await expect(
readDatasetSourceRawText({
teamId: 'team-a',
tmbId: 'tmb-a',
type: DatasetSourceReadTypeEnum.fileLocal,
sourceId: 'dataset/victim-dataset/secret.pdf',
datasetId: 'attacker-dataset'
})
).rejects.toBe(DatasetErrEnum.unAuthDatasetFile);
expect(mocks.getDatasetFileRawText).not.toHaveBeenCalled();
});
it('reads a local dataset file key under the authorized dataset id', async () => {
await expect(
readDatasetSourceRawText({
teamId: 'team-a',
tmbId: 'tmb-a',
type: DatasetSourceReadTypeEnum.fileLocal,
sourceId: 'dataset/dataset-a/demo.pdf',
datasetId: 'dataset-a'
})
).resolves.toEqual({
title: 'demo.pdf',
rawText: 'demo content'
});
expect(mocks.getDatasetFileRawText).toHaveBeenCalledWith(
expect.objectContaining({
fileId: 'dataset/dataset-a/demo.pdf',
datasetId: 'dataset-a'
})
);
});
});
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DatasetErrEnum } from '@fastgpt/global/common/error/code/dataset';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { OwnerPermissionVal, ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
const {
mockParseHeaderCert,
mockGetCollectionWithDataset,
mockFindDataset,
mockGetTmbInfoByTmbId,
mockGetTmbPermission
mockGetTmbPermission,
mockIsObjectExists
} = vi.hoisted(() => ({
mockParseHeaderCert: vi.fn(),
mockGetCollectionWithDataset: vi.fn(),
mockFindDataset: vi.fn(),
mockGetTmbInfoByTmbId: vi.fn(),
mockGetTmbPermission: vi.fn()
mockGetTmbPermission: vi.fn(),
mockIsObjectExists: vi.fn()
}));
vi.mock('@fastgpt/service/support/permission/auth/common', () => ({
......@@ -44,7 +46,14 @@ vi.mock('@fastgpt/service/core/dataset/data/schema', () => ({
}
}));
vi.mock('@fastgpt/service/common/s3/sources/dataset', () => ({
getS3DatasetSource: () => ({
isObjectExists: mockIsObjectExists
})
}));
import { authDatasetCollection } from '@fastgpt/service/support/permission/dataset/auth';
import { authCollectionFile } from '@fastgpt/service/support/permission/auth/file';
const datasetId = '507f1f77bcf86cd799439011';
const collectionId = '507f1f77bcf86cd799439012';
......@@ -69,6 +78,7 @@ describe('authDatasetCollection', () => {
permission: { isOwner: true }
});
mockGetTmbPermission.mockResolvedValue(0);
mockIsObjectExists.mockResolvedValue(true);
mockDatasetQuery({
_id: datasetId,
teamId: 'team-a',
......@@ -111,3 +121,60 @@ describe('authDatasetCollection', () => {
expect(result.collection._id).toBe(collectionId);
});
});
describe('authCollectionFile', () => {
beforeEach(() => {
vi.clearAllMocks();
mockParseHeaderCert.mockResolvedValue({
teamId: 'team-a',
tmbId: 'tmb-a',
userId: 'user-a',
isRoot: false
});
mockGetTmbInfoByTmbId.mockResolvedValue({
teamId: 'team-a',
permission: { isOwner: true }
});
mockGetTmbPermission.mockResolvedValue(0);
mockIsObjectExists.mockResolvedValue(true);
});
it('authorizes a dataset file through the dataset id embedded in the key', async () => {
mockDatasetQuery({
_id: datasetId,
teamId: 'team-a',
tmbId: 'tmb-a',
inheritPermission: false
});
const result = await authCollectionFile({
req: {} as any,
authToken: true,
fileId: `dataset/${datasetId}/demo.pdf`,
per: OwnerPermissionVal
});
expect(result.teamId).toBe('team-a');
expect(mockIsObjectExists).toHaveBeenCalledWith(`dataset/${datasetId}/demo.pdf`);
});
it('rejects a dataset file key that belongs to another team', async () => {
mockDatasetQuery({
_id: datasetId,
teamId: 'team-b',
tmbId: 'tmb-b',
inheritPermission: false
});
await expect(
authCollectionFile({
req: {} as any,
authToken: true,
fileId: `dataset/${datasetId}/secret.pdf`,
per: OwnerPermissionVal
})
).rejects.toBe(DatasetErrEnum.unAuthDataset);
expect(mockIsObjectExists).not.toHaveBeenCalled();
});
});
......@@ -251,6 +251,7 @@ export const iconPaths = {
'core/chat/fileSelect': () => import('./icons/core/chat/fileSelect.svg'),
'core/chat/finishSpeak': () => import('./icons/core/chat/finishSpeak.svg'),
'core/chat/imgSelect': () => import('./icons/core/chat/imgSelect.svg'),
'core/chat/monitor': () => import('./icons/core/chat/monitor.svg'),
'core/chat/markdown': () => import('./icons/core/chat/markdown.svg'),
'core/chat/quoteFill': () => import('./icons/core/chat/quoteFill.svg'),
'core/chat/quoteSign': () => import('./icons/core/chat/quoteSign.svg'),
......
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="20" height="20" viewBox="-2 -2 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0751 5.5509C13.5454 4.09178 13.2912 3.31806 12.989 3.01581C12.6867 2.71355 11.913 2.45941 10.4539 2.92966C10.0732 3.05236 9.67248 3.21789 9.25998 3.42506C9.86524 3.87324 10.4643 4.38811 11.0405 4.96428C11.6167 5.54045 12.1315 6.13955 12.5797 6.74481C12.7869 6.33231 12.9524 5.93162 13.0751 5.5509ZM2.06909 2.073C3.24845 0.893639 5.58121 1.18139 8.00044 2.5924C10.4197 1.18139 12.7524 0.893638 13.9318 2.073C15.1112 3.25236 14.8234 5.58512 13.4124 8.00435C14.8234 10.4236 15.1111 12.7563 13.9318 13.9357C12.7524 15.115 10.4197 14.8273 8.00044 13.4163C5.58122 14.8273 3.24846 15.115 2.06911 13.9357C0.889755 12.7563 1.1775 10.4236 2.5885 8.00435C1.17748 5.58512 0.88973 3.25236 2.06909 2.073ZM5.547 2.92966C5.92772 3.05236 6.3284 3.21789 6.7409 3.42506C6.13564 3.87324 5.53656 4.38811 4.96039 4.96428C4.38422 5.54045 3.86934 6.13955 3.42116 6.74481C3.21398 6.33231 3.04845 5.93163 2.92575 5.5509C2.45551 4.09178 2.70965 3.31806 3.0119 3.01581C3.31415 2.71355 4.08787 2.45942 5.547 2.92966ZM2.92577 10.4578C3.04847 10.0771 3.21399 9.67638 3.42116 9.2639C3.86934 9.86915 4.38421 10.4682 4.96037 11.0444C5.53654 11.6206 6.13564 12.1354 6.74089 12.5836C6.3284 12.7908 5.92773 12.9563 5.54701 13.079C4.08789 13.5493 3.31417 13.2951 3.01192 12.9929C2.70967 12.6906 2.45553 11.9169 2.92577 10.4578ZM5.90318 10.1016C6.58836 10.7868 7.29938 11.3686 8.00044 11.8419C8.7015 11.3686 9.41252 10.7868 10.0977 10.1016C10.7829 9.41642 11.3647 8.70541 11.838 8.00435C11.3647 7.30328 10.7829 6.59227 10.0977 5.90709C9.41251 5.22192 8.7015 4.64006 8.00044 4.16675C7.29938 4.64006 6.58837 5.22192 5.9032 5.90709C5.21803 6.59227 4.63616 7.30328 4.16285 8.00435C4.63616 8.70541 5.21802 9.41642 5.90318 10.1016ZM10.4539 13.079C10.0732 12.9563 9.67248 12.7908 9.25999 12.5836C9.86524 12.1354 10.4643 11.6206 11.0405 11.0444C11.6167 10.4682 12.1315 9.86914 12.5797 9.26389C12.7869 9.67638 12.9524 10.0771 13.0751 10.4578C13.5454 11.9169 13.2912 12.6906 12.989 12.9929C12.6867 13.2951 11.913 13.5493 10.4539 13.079ZM8.00049 9.33326C8.73853 9.33326 9.33683 8.73631 9.33683 7.99993C9.33683 7.26355 8.73853 6.6666 8.00049 6.6666C7.26245 6.6666 6.66414 7.26355 6.66414 7.99993C6.66414 8.73631 7.26245 9.33326 8.00049 9.33326Z" fill="#667085"/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 3.62521C13.5 3.34907 13.2759 3.12497 12.9998 3.12497H3.00021C2.72407 3.12497 2.49997 3.34907 2.49997 3.62521V9.87486C2.49997 10.151 2.72407 10.3751 3.00021 10.3751H12.9998C13.2759 10.3751 13.5 10.151 13.5 9.87486V3.62521ZM15 9.87486C15 10.9794 14.1043 11.8751 12.9998 11.8751H8.74962V12.8748H10.5001C10.9142 12.8749 11.2501 13.2107 11.2501 13.6248C11.2501 14.0389 10.9142 14.3746 10.5001 14.3748H5.49992C5.08572 14.3748 4.74993 14.039 4.74993 13.6248C4.74993 13.2106 5.08572 12.8748 5.49992 12.8748H7.24965V11.8751H3.00021C1.89566 11.8751 1 10.9794 1 9.87486V3.62521C1 2.52066 1.89566 1.625 3.00021 1.625H12.9998C14.1043 1.625 15 2.52066 15 3.62521V9.87486Z" fill="currentColor"/>
<path d="M6.38024 4.5657C6.54531 4.51219 6.70195 4.53297 6.82469 4.58115L6.82504 4.58048L9.79405 5.73886L9.79319 5.73971C9.91875 5.78805 10.027 5.87264 10.1026 5.984C10.1804 6.09863 10.2204 6.235 10.2166 6.37349C10.2127 6.51202 10.1655 6.6462 10.0813 6.7564C9.99937 6.86358 9.88592 6.94156 9.75758 6.98271L9.75807 6.98424L8.60255 7.35912L8.26003 8.5742C8.25733 8.5838 8.25454 8.59354 8.25132 8.60297C8.21782 8.70096 8.16185 8.79028 8.08802 8.86298C8.01426 8.93559 7.92443 8.98992 7.82594 9.0219L7.79685 9.03134C7.74116 9.04939 7.68294 9.05828 7.6244 9.05795C7.48801 9.05718 7.35498 9.01463 7.24359 8.93611C7.13228 8.8576 7.04783 8.74665 7.00128 8.61872C6.9991 8.61272 6.99678 8.60658 6.99481 8.60051L5.95678 5.40088C5.90358 5.23688 5.91633 5.05856 5.99234 4.90365L6.02367 4.84723C6.10245 4.71972 6.2225 4.62156 6.36493 4.57067L6.38024 4.5657Z" fill="currentColor"/>
</svg>
......@@ -40,6 +40,7 @@ export function useLinkedScroll<
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<string, HTMLElement | null>>(new Map());
const isInit = useRef(false);
const paramsVersionRef = useRef(0);
const scrollToItem = useCallback(
(id?: string) => {
......@@ -82,8 +83,6 @@ export function useLinkedScroll<
const scrollSign = useRef(false);
const { runAsync: loadInitData } = useRequest(
async ({ scrollWhenFinish, refresh } = { scrollWhenFinish: true, refresh: false }) => {
if (isLoading) return;
// 已经被加载的数据,直接滚动到该位置
const item = dataList.find((item) => item.id === currentData?.id);
if (item && !refresh) {
......@@ -91,12 +90,14 @@ export function useLinkedScroll<
return;
}
const paramsVersion = paramsVersionRef.current;
const response = await callApi({
initialId: currentData?.id,
anchor: currentData?.anchor,
pageSize,
...params
} as TParams);
if (paramsVersion !== paramsVersionRef.current) return;
setHasMorePrev(response.hasMorePrev);
setHasMoreNext(response.hasMoreNext);
......@@ -120,6 +121,15 @@ export function useLinkedScroll<
);
useEffect(() => {
if (!isInit.current) return;
paramsVersionRef.current += 1;
anchorRef.current = {
top: null,
bottom: null
};
itemRefs.current.clear();
setHasMorePrev(true);
setHasMoreNext(true);
setDataList([]);
loadInitData({ refresh: true, scrollWhenFinish: true });
}, [params]);
useEffect(() => {
......@@ -133,6 +143,7 @@ export function useLinkedScroll<
async (scrollRef = containerRef) => {
if (!anchorRef.current.top || !hasMorePrev || isLoading) return;
const paramsVersion = paramsVersionRef.current;
const prevScrollTop = scrollRef?.current?.scrollTop || 0;
const prevScrollHeight = scrollRef?.current?.scrollHeight || 0;
......@@ -143,6 +154,7 @@ export function useLinkedScroll<
...params
} as TParams);
if (paramsVersion !== paramsVersionRef.current) return;
if (!response) return;
setHasMorePrev(response.hasMorePrev);
......@@ -172,6 +184,7 @@ export function useLinkedScroll<
async (scrollRef = containerRef) => {
if (!anchorRef.current.bottom || !hasMoreNext || isLoading) return;
const paramsVersion = paramsVersionRef.current;
const prevScrollTop = scrollRef?.current?.scrollTop || 0;
const response = await callApi({
......@@ -181,6 +194,7 @@ export function useLinkedScroll<
...params
} as TParams);
if (paramsVersion !== paramsVersionRef.current) return;
if (!response) return;
setHasMoreNext(response.hasMoreNext);
......
......@@ -2,6 +2,7 @@
"AI_input_is_empty": "The content passed to the AI ​​node is empty",
"Delete_all": "Clear All Lexicon",
"LLM_model_response_empty": "The model response is empty, please check the model details request",
"no_output_content": "The app returned no output",
"Next": "Next",
"Previous": "Previous",
"agent_plan_generating": "Creating plan",
......
......@@ -2,6 +2,7 @@
"AI_input_is_empty": "传入 AI 节点的内容为空",
"Delete_all": "清空词库",
"LLM_model_response_empty": "模型响应为空,请查看模型详情请求",
"no_output_content": "应用无输出内容",
"Next": "下一个",
"Previous": "上一个",
"agent_plan_generating": "正在生成计划",
......
......@@ -2,6 +2,7 @@
"AI_input_is_empty": "傳送至 AI 節點的內容為空",
"Delete_all": "清除所有詞彙",
"LLM_model_response_empty": "模型響應為空,請查看模型詳情請求",
"no_output_content": "應用無輸出內容",
"Next": "下一個",
"Previous": "上一個",
"agent_plan_generating": "正在生成計畫",
......
.waitingAnimation > :last-child::after {
.waitingAnimation> :last-child::after {
display: inline-block;
content: '';
width: 3px;
......@@ -23,25 +23,31 @@
animation: blink 0.6s infinite;
}
}
@keyframes blink {
from,
to {
opacity: 0;
}
50% {
opacity: 1;
}
}
.markdown > *:first-child {
.markdown>*:first-child {
margin-top: 0 !important;
}
.markdown > *:last-child {
.markdown>*:last-child {
margin-bottom: 0 !important;
}
.markdown a.absent {
color: #cc0000;
}
.markdown a.anchor {
bottom: 0;
cursor: pointer;
......@@ -52,6 +58,7 @@
position: absolute;
top: 0;
}
.markdown h1,
.markdown h2,
.markdown h3,
......@@ -67,6 +74,7 @@
overflow-wrap: anywhere;
word-break: break-word;
}
.markdown h1 .mini-icon-link,
.markdown h2 .mini-icon-link,
.markdown h3 .mini-icon-link,
......@@ -75,6 +83,7 @@
.markdown h6 .mini-icon-link {
display: none;
}
.markdown h1:hover a.anchor,
.markdown h2:hover a.anchor,
.markdown h3:hover a.anchor,
......@@ -87,6 +96,7 @@
text-decoration: none;
top: 15%;
}
.markdown h1:hover a.anchor .mini-icon-link,
.markdown h2:hover a.anchor .mini-icon-link,
.markdown h3:hover a.anchor .mini-icon-link,
......@@ -95,6 +105,7 @@
.markdown h6:hover a.anchor .mini-icon-link {
display: inline-block;
}
.markdown h1 tt,
.markdown h1 code,
.markdown h2 tt,
......@@ -109,24 +120,31 @@
.markdown h6 code {
font-size: inherit;
}
.markdown h1 {
font-size: var(--chakra-fontSizes-2xl);
}
.markdown h2 {
font-size: var(--chakra-fontSizes-xl);
}
.markdown h3 {
font-size: var(--chakra-fontSizes-lg);
}
.markdown h4 {
font-size: var(--chakra-fontSizes-md);
}
.markdown h5 {
font-size: 14px;
}
.markdown h6 {
font-size: 12px;
}
.markdown p,
.markdown blockquote,
.markdown ul,
......@@ -136,16 +154,18 @@
.markdown pre {
margin: 14px 0;
}
.markdown > h2:first-child,
.markdown > h1:first-child,
.markdown > h1:first-child + h2,
.markdown > h3:first-child,
.markdown > h4:first-child,
.markdown > h5:first-child,
.markdown > h6:first-child {
.markdown>h2:first-child,
.markdown>h1:first-child,
.markdown>h1:first-child+h2,
.markdown>h3:first-child,
.markdown>h4:first-child,
.markdown>h5:first-child,
.markdown>h6:first-child {
margin-top: 0;
padding-top: 0;
}
.markdown a:first-child h1,
.markdown a:first-child h2,
.markdown a:first-child h3,
......@@ -155,39 +175,45 @@
margin-top: 0;
padding-top: 0;
}
.markdown h1 + p,
.markdown h2 + p,
.markdown h3 + p,
.markdown h4 + p,
.markdown h5 + p,
.markdown h6 + p {
.markdown h1+p,
.markdown h2+p,
.markdown h3+p,
.markdown h4+p,
.markdown h5+p,
.markdown h6+p {
margin-top: 0;
}
.markdown li p.first {
display: inline-block;
}
.markdown ul,
.markdown ol {
padding-left: 0;
list-style-position: inside;
padding-left: 2em;
list-style-position: outside;
}
.markdown ul.no-list,
.markdown ol.no-list {
list-style-type: none;
padding: 0;
}
.markdown ul li > *:first-child,
.markdown ol li > *:first-child {
margin-top: 0;
.markdown li {
padding-left: 0.25em;
}
.markdown ul,
.markdown ol {
padding-left: 0;
list-style-position: inside;
.markdown ul li>*:first-child,
.markdown ol li>*:first-child {
margin-top: 0;
}
.markdown dl {
padding: 0;
}
.markdown dl dt {
font-size: 14px;
font-style: italic;
......@@ -195,61 +221,78 @@
margin: 15px 0 5px;
padding: 0;
}
.markdown dl dt:first-child {
padding: 0;
}
.markdown dl dt > *:first-child {
.markdown dl dt>*:first-child {
margin-top: 0;
}
.markdown dl dt > *:last-child {
.markdown dl dt>*:last-child {
margin-bottom: 0;
}
.markdown dl dd {
margin: 0 0 15px;
padding: 0 15px;
}
.markdown dl dd > *:first-child {
.markdown dl dd>*:first-child {
margin-top: 0;
}
.markdown dl dd > *:last-child {
.markdown dl dd>*:last-child {
margin-bottom: 0;
}
.markdown blockquote {
border-left: 4px solid #dddddd;
color: #777777;
padding: 0 15px;
}
.markdown blockquote > *:first-child {
.markdown blockquote>*:first-child {
margin-top: 0;
}
.markdown blockquote > *:last-child {
.markdown blockquote>*:last-child {
margin-bottom: 0;
}
.markdown table {
position: relative;
width: 100%;
}
.markdown table th {
font-weight: bold;
}
.markdown table th,
.markdown table td {
padding: 6px 13px;
}
.markdown table tr {
background-color: #ffffff;
}
.markdown table tr:nth-child(2n) {
background-color: #f0f0f0;
}
.markdown img {
max-width: 100%;
}
.markdown span.frame {
display: block;
overflow: hidden;
}
.markdown span.frame > span {
.markdown span.frame>span {
border: 1px solid #dddddd;
display: block;
float: left;
......@@ -258,67 +301,80 @@
padding: 7px;
width: auto;
}
.markdown span.frame span img {
display: block;
float: left;
}
.markdown span.frame span span {
clear: both;
color: #333333;
display: block;
padding: 5px 0 0;
}
.markdown span.align-center {
clear: both;
display: block;
overflow: hidden;
}
.markdown span.align-center > span {
.markdown span.align-center>span {
display: block;
margin: 13px auto 0;
overflow: hidden;
text-align: center;
}
.markdown span.align-center span img {
margin: 0 auto;
text-align: center;
}
.markdown span.align-right {
clear: both;
display: block;
overflow: hidden;
}
.markdown span.align-right > span {
.markdown span.align-right>span {
display: block;
margin: 13px 0 0;
overflow: hidden;
text-align: right;
}
.markdown span.align-right span img {
margin: 0;
text-align: right;
}
.markdown span.float-left {
display: block;
float: left;
margin-right: 13px;
overflow: hidden;
}
.markdown span.float-left span {
margin: 13px 0 0;
}
.markdown span.float-right {
display: block;
float: right;
margin-left: 13px;
overflow: hidden;
}
.markdown span.float-right > span {
.markdown span.float-right>span {
display: block;
margin: 13px auto 0;
overflow: hidden;
text-align: right;
}
.markdown code,
.markdown tt {
border: 1px solid #dee0e2;
......@@ -327,12 +383,14 @@
margin: 0 2px;
padding: 0 5px;
}
.markdown pre > code {
.markdown pre>code {
background: none repeat scroll 0 0 transparent;
border: medium none;
margin: 0;
padding: 0;
}
.markdown .highlight pre,
.markdown pre {
border: 1px solid #cccccc;
......@@ -342,15 +400,18 @@
overflow: auto;
padding: 6px 10px;
}
.markdown pre code,
.markdown pre tt {
background-color: transparent !important;
border: medium none;
}
.markdown hr {
margin: 10px 0;
border-color: var(--chakra-colors-myGray-200);
}
.markdown {
tab-size: 4;
word-spacing: normal;
......@@ -398,6 +459,7 @@
&:first-child {
border-top-left-radius: 0.375rem;
}
&:last-child {
border-right-width: 1px;
border-top-right-radius: 0.375rem;
......@@ -416,10 +478,12 @@
tbody tr:last-child {
overflow: hidden;
td {
&:first-child {
border-bottom-left-radius: 0.375rem;
}
&:last-child {
border-bottom-right-radius: 0.375rem;
}
......
......@@ -178,10 +178,10 @@ const ChatInput = ({
}
resize={'none'}
rows={1}
height={[5, 6]}
lineHeight={[5, 6]}
height={textareaMinH}
lineHeight={textareaMinH}
maxHeight={[24, 32]}
minH={'50px'}
minH={textareaMinH}
mb={0}
maxLength={-1}
overflowY={'hidden'}
......@@ -191,7 +191,7 @@ const ChatInput = ({
boxShadow={'none !important'}
color={'myGray.900'}
fontWeight={400}
fontSize={'1rem'}
fontSize={'16px'}
letterSpacing={'0.5px'}
w={'100%'}
_placeholder={{
......@@ -426,7 +426,7 @@ const ChatInput = ({
w={'100%'}
maxW={['100%', '780px']}
mx={'auto'}
pb={0}
pb={['calc(16px + env(safe-area-inset-bottom))', 4]}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
......@@ -523,7 +523,7 @@ const ChatInput = ({
{!mobilePreSpeak && <Box>{RenderButtonGroup}</Box>}
</Flex>
<ComplianceTip type={'chat'} pt={4} pb={['calc(12px + env(safe-area-inset-bottom))', 3]} />
<ComplianceTip type={'chat'} pt={4} pb={0} />
</Box>
);
};
......
import { Box, Flex } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import React from 'react';
import React, { useMemo } from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus';
......@@ -8,9 +8,15 @@ import ChatController, { type ChatControllerProps } from '../ChatController';
import { ChatBoxContext } from '../../Provider';
import { useContextSelector } from 'use-context-selector';
import { ChatTypeEnum } from '../../constants';
import type { ChatSiteItemType } from '../../type';
import { addStatisticalDataToHistoryItem } from '@/global/core/chat/utils';
import { useSandboxEditor } from '@/pageComponents/chat/SandboxEditor/hook';
import { WorkflowRuntimeContext } from '../../../context/workflowRuntimeContext';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
type AIChatBubbleActionsProps = {
chatControllerProps: ChatControllerProps;
historyItem: ChatSiteItemType;
questionGuides: string[];
showWholeResponse: boolean;
onOpenWholeModal: () => void;
......@@ -19,6 +25,7 @@ type AIChatBubbleActionsProps = {
const AIChatBubbleActions = ({
chatControllerProps,
historyItem,
questionGuides,
showWholeResponse,
onOpenWholeModal,
......@@ -26,8 +33,21 @@ const AIChatBubbleActions = ({
}: AIChatBubbleActionsProps) => {
const { t } = useTranslation();
const { onRetry } = chatControllerProps;
const { isPc } = useSystem();
const chatType = useContextSelector(ChatBoxContext, (v) => v.chatType);
const showRetry = chatType !== ChatTypeEnum.log && !!onRetry;
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData);
const { useAgentSandbox } = useMemo(
() => addStatisticalDataToHistoryItem(historyItem),
[historyItem]
);
const { onOpenSandboxModal, SandboxEditorModal } = useSandboxEditor({
appId,
chatId,
outLinkAuthData
});
return (
<Box mt={4} maxW={'100%'}>
......@@ -72,6 +92,29 @@ const AIChatBubbleActions = ({
<Box>{t('chat:run_detail')}</Box>
</Flex>
)}
{isPc && useAgentSandbox && (
<Flex
alignItems={'center'}
gap={'4px'}
p={'4px'}
cursor={'pointer'}
color={'myGray.400'}
_hover={{ color: 'primary.600' }}
onClick={onOpenSandboxModal}
>
<MyIcon
name={'core/chat/monitor'}
w={'16px'}
sx={{
'& path': {
fill: 'currentColor'
}
}}
/>
<Box>{t('app:use_agent_sandbox')}</Box>
</Flex>
)}
</Flex>
{durationSeconds > 0 && (
......@@ -112,6 +155,8 @@ const AIChatBubbleActions = ({
))}
</Flex>
)}
<SandboxEditorModal />
</Box>
);
};
......
......@@ -87,23 +87,24 @@ const AIChatBubbleContent = ({
const group = processingGroup;
processingGroup = [];
const previewItem = group[group.length - 1];
const hasFinishedContent = group.some(
({ value }) => hasAiAnswerContent(value) || hasAiInteractiveContent(value)
);
const isProcessing =
isChatting && isLastChild && group.some(({ index }) => index === chatValue.length - 1);
isChatting &&
isLastChild &&
!hasFinishedContent &&
group.some(({ index }) => index === chatValue.length - 1);
contentBlocks.push(
<Box key={`${dataId}-processing-${group[0].index}`} _notFirst={{ mt: 2 }}>
<Box key={`${dataId}-processing-${group[0].index}`}>
<RenderProcessingCollapse
isProcessing={isProcessing}
label={previewItem ? getProcessingPreviewLabelKey(previewItem.value) : undefined}
preview={
previewItem
? (
<RenderProcessingPreview
value={previewItem.value}
showAnimation={isProcessing}
/>
)
: undefined
previewItem ? (
<RenderProcessingPreview value={previewItem.value} showAnimation={isProcessing} />
) : undefined
}
>
{group.map(({ value, index }) => (
......@@ -146,7 +147,7 @@ const AIChatBubbleContent = ({
flushProcessingGroup();
contentBlocks.push(
<Box key={`${dataId}-ai-${index}`} _notFirst={{ mt: 2 }}>
<Box key={`${dataId}-ai-${index}`}>
{renderValue({
value,
index,
......@@ -161,7 +162,7 @@ const AIChatBubbleContent = ({
flushProcessingGroup();
return (
<Flex flexDirection={'column'} fontSize={'16px'} lineHeight={1.75}>
<Flex flexDirection={'column'} gap={4} fontSize={'16px'} lineHeight={1.75}>
{contentBlocks}
</Flex>
);
......
......@@ -11,6 +11,12 @@ import { useContextSelector } from 'use-context-selector';
import { ChatBoxContext } from '../../Provider';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import AIChatLoading from '../AIChatLoading';
import {
hasAiAnswerContent,
hasAiInteractiveContent,
hasAiProcessingContent
} from './utils';
import { useTranslation } from 'next-i18next';
const ResponseTags = dynamic(() => import('../ResponseTags'));
const WholeResponseModal = dynamic(() => import('../../../../components/WholeResponseModal'));
......@@ -44,6 +50,7 @@ const AIChatBubble = ({
chatControllerProps,
children
}: AIChatBubbleProps) => {
const { t } = useTranslation();
const chatType = useContextSelector(ChatBoxContext, (v) => v.chatType);
const showWholeResponse = useContextSelector(ChatItemContext, (v) => v.showWholeResponse ?? true);
const {
......@@ -54,6 +61,12 @@ const AIChatBubble = ({
const showFooterActions = isLastValueGroup && (!isLastChild || !isChatting);
const canShowWholeResponse = chatType !== 'share' && showWholeResponse;
const showLoading = isLastChild && isLastValueGroup && isChatting;
const hasFinalOutput = chatValue.some(
(item) => hasAiAnswerContent(item) || hasAiInteractiveContent(item)
);
const hasProcessingContent = chatValue.some((item) => hasAiProcessingContent(item));
const showNoOutputTip =
isLastValueGroup && !isChatting && !chat.errorMsg && !chat.errorText && !hasFinalOutput;
return (
<Box position={'relative'} w={'100%'} maxW={'100%'}>
......@@ -72,6 +85,16 @@ const AIChatBubble = ({
isChatting={isChatting}
onOpenCiteModal={onOpenCiteModal}
/>
{showNoOutputTip && (
<Box
mt={hasProcessingContent ? 4 : 0}
fontSize="14px"
lineHeight="20px"
color="myGray.500"
>
{t('chat:no_output_content', '应用无输出内容')}
</Box>
)}
{isLastValueGroup && (
<ResponseTags
showTags={!isLastChild || !isChatting}
......@@ -90,6 +113,7 @@ const AIChatBubble = ({
{showFooterActions && (
<AIChatBubbleActions
chatControllerProps={chatControllerProps}
historyItem={chat}
questionGuides={isLastChild ? questionGuides : []}
showWholeResponse={canShowWholeResponse}
onOpenWholeModal={onOpenWholeModal}
......
......@@ -51,10 +51,10 @@ const AppChatMain = ({
h={0}
w={'100%'}
overflow={'overlay'}
px={[4, 0]}
px={[4, 6]}
pb={6}
>
<Box maxW={['100%', '92%']} h={'100%'} mx={'auto'}>
<Box maxW={'700px'} h={'100%'} mx={'auto'}>
{!!welcomeText && <WelcomeBox welcomeText={welcomeText} />}
<Box id="variable-input">
......
import React from 'react';
import { Box, Flex } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
const ChatErrorCard = ({ title, message }: { title: string; message: string }) => {
return (
<Box
w={'100%'}
maxW={'420px'}
border={'1px solid'}
borderColor={'myGray.200'}
borderRadius={'8px'}
bg={'white'}
px={'16px'}
py={'12px'}
>
<Flex alignItems={'center'} gap={'8px'} color={'myGray.700'}>
<MyIcon name={'common/warn'} w={'18px'} color={'#F79009'} flexShrink={0} />
<Box fontSize={'14px'} lineHeight={'20px'} fontWeight={500}>
{title}
</Box>
</Flex>
<Box mt={'4px'} pl={'26px'} color={'myGray.500'} fontSize={'13px'} lineHeight={'20px'}>
{message}
</Box>
</Box>
);
};
export default React.memo(ChatErrorCard);
import { Box, type BoxProps, Button, Flex } from '@chakra-ui/react';
import React, { useMemo, useState, useRef } from 'react';
import ChatController, { type ChatControllerProps } from './ChatController';
import { type ChatControllerProps } from './ChatController';
import styles from '../index.module.scss';
import { ChatRoleEnum, ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import { ChatBoxContext } from '../Provider';
......@@ -10,19 +10,17 @@ import MyIcon from '@fastgpt/web/components/common/Icon';
import { useTranslation } from 'next-i18next';
import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import { type AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import {
ChatItemContext,
type OnOpenCiteModalProps
} from '@/web/core/chat/context/chatItemContext';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { addStatisticalDataToHistoryItem } from '@/global/core/chat/utils';
import { useMemoizedFn, useSize } from 'ahooks';
import ChatBoxDivider from '../../../Divider';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import HumanChatBubble from './HumanChatBubble';
import AIChatBubble, { shouldFilterAiValue } from './AIChatBubble';
import type { ChatBoxInputType } from '../type';
import { hasAiAnswerContent } from './AIChatBubble/utils';
import ChatErrorCard from './ChatErrorCard';
import { getErrText } from '@fastgpt/global/common/error/utils';
const colorMap = {
[ChatStatusEnum.loading]: {
......@@ -89,6 +87,25 @@ const ChatItem = (props: Props) => {
() => addStatisticalDataToHistoryItem(chat),
[chat]
);
const inlineErrorInfo = useMemo(() => {
if (!chat.errorMsg && !errorText) return;
const moduleName =
errorText?.moduleName ||
chat.moduleName ||
t('common:core.module.template.ai_chat', { defaultValue: 'AI 对话' });
const errorReason = errorText?.errorText || '';
const noOutputText = t('chat:no_output', '无输出');
const isNoOutputError =
!errorReason ||
errorReason === 'chat:LLM_model_response_empty' ||
errorReason === t('chat:LLM_model_response_empty');
return {
title: `${t('chat:log.error.error_prefix')} - ${t(moduleName)}`,
message: isNoOutputError ? noOutputText : t(getErrText(errorReason))
};
}, [chat.errorMsg, chat.moduleName, errorText, t]);
const isChatLog = chatType === 'log';
......@@ -211,32 +228,37 @@ const ChatItem = (props: Props) => {
);
return (
<Box data-chat-id={chat.dataId}>
<Flex data-chat-id={chat.dataId} direction={'column'} gap={4}>
{/* Workflow status */}
{!isHumanMessage && isChatLog && !!chatStatusMap && statusBoxData && isLastChild && showRunningStatus && (
<Flex w={'100%'} alignItems={'center'} gap={2} justifyContent={styleMap.justifyContent}>
<Flex
alignItems={'center'}
px={3}
py={'1.5px'}
borderRadius="md"
bg={chatStatusMap.bg}
fontSize={'sm'}
>
<Box
className={styles.statusAnimation}
bg={chatStatusMap.color}
w="8px"
h="8px"
borderRadius={'50%'}
mt={'1px'}
/>
<Box ml={2} color={'myGray.600'}>
{statusBoxData.name}
</Box>
{!isHumanMessage &&
isChatLog &&
!!chatStatusMap &&
statusBoxData &&
isLastChild &&
showRunningStatus && (
<Flex w={'100%'} alignItems={'center'} gap={2} justifyContent={styleMap.justifyContent}>
<Flex
alignItems={'center'}
px={3}
py={'1.5px'}
borderRadius="md"
bg={chatStatusMap.bg}
fontSize={'sm'}
>
<Box
className={styles.statusAnimation}
bg={chatStatusMap.color}
w="8px"
h="8px"
borderRadius={'50%'}
mt={'1px'}
/>
<Box ml={2} color={'myGray.600'}>
{statusBoxData.name}
</Box>
</Flex>
</Flex>
</Flex>
)}
)}
{/* User Feedback Content: Admin log show */}
{isChatLog &&
......@@ -278,12 +300,9 @@ const ChatItem = (props: Props) => {
i === splitAiResponseResults.length - 1 ? (
<>
{/* error message */}
{!!chat.errorMsg && (
<Box mt={2}>
<ChatBoxDivider icon={'common/errorFill'} text={t('chat:error_message')} />
<Box fontSize={'xs'} color={'myGray.500'}>
{chat.errorMsg}
</Box>
{inlineErrorInfo && (
<Box mt={4}>
<ChatErrorCard title={inlineErrorInfo.title} message={inlineErrorInfo.message} />
</Box>
)}
{children}
......@@ -294,7 +313,6 @@ const ChatItem = (props: Props) => {
return (
<Box
key={i}
mt={['6px', 2]}
className="chat-box-card"
w={'100%'}
maxW={isPc ? '700px' : 'calc(100% - 25px)'}
......@@ -315,7 +333,6 @@ const ChatItem = (props: Props) => {
return (
<Box
key={i}
mt={['6px', 2]}
className="chat-box-card"
w={'100%'}
maxW={isPc ? '700px' : 'calc(100% - 25px)'}
......@@ -417,7 +434,7 @@ const ChatItem = (props: Props) => {
)}
</Box>
)}
</Box>
</Flex>
);
};
......
......@@ -177,11 +177,16 @@ const ChatRecordsList = ({
}}
>
<Box
pt={
item.hideInUI ? 0 : item.obj === ChatRoleEnum.Human ? 0 : '8px'
}
pb={
item.hideInUI ? 0 : item.obj === ChatRoleEnum.Human ? '4px' : '32px'
pt={0}
pb={item.hideInUI ? 0 : item.obj === ChatRoleEnum.Human ? '40px' : '32px'}
_hover={
item.obj === ChatRoleEnum.Human
? {
'& .chat-controller-hover': {
display: 'flex'
}
}
: undefined
}
>
{item.obj === ChatRoleEnum.Human && !item.hideInUI && (
......@@ -261,7 +266,6 @@ const ChatRecordsList = ({
</Box>
);
})}
{records.length > 0 && <Box h={'24px'} />}
</Box>
);
};
......
import { Box, Button, Flex, Textarea } from '@chakra-ui/react';
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
......@@ -11,7 +11,11 @@ import { WorkflowRuntimeContext } from '../../../context/workflowRuntimeContext'
import { ChatBoxContext } from '../../Provider';
import type { ChatBoxInputFormType, ChatBoxInputType, UserInputFileItemType } from '../../type';
import { useFileUpload } from '../../hooks/useFileUpload';
import VoiceInput, { type VoiceInputComponentRef } from '../../Input/VoiceInput';
const textareaLineHeight = 24;
const textareaVisibleRows = 4;
const textareaPaddingY = 10;
const textareaHeight = `${textareaLineHeight * textareaVisibleRows + textareaPaddingY * 2}px`;
type HumanChatBubbleEditFormProps = {
defaultValue: string;
......@@ -36,13 +40,10 @@ const HumanChatBubbleEditForm = ({
}: HumanChatBubbleEditFormProps) => {
const { t } = useTranslation();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const VoiceInputRef = useRef<VoiceInputComponentRef>(null);
const [value, setValue] = useState(defaultValue);
const trimmedValue = value.trim();
const [mobilePreSpeak, setMobilePreSpeak] = useState(false);
const fileSelectConfig = useContextSelector(ChatBoxContext, (v) => v.fileSelectConfig);
const whisperConfig = useContextSelector(ChatBoxContext, (v) => v.whisperConfig);
const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData);
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
......@@ -70,7 +71,6 @@ const HumanChatBubbleEditForm = ({
showSelectAudio,
showSelectCustomFileExtension,
removeFiles,
replaceFiles,
hasFileUploading
} = useFileUpload({
fileSelectConfig,
......@@ -86,6 +86,14 @@ const HumanChatBubbleEditForm = ({
showSelectAudio ||
showSelectCustomFileExtension;
const canSubmit = !hasFileUploading && trimmedValue.length > 0;
const handleSubmit = useCallback(() => {
if (!canSubmit) return;
onSubmit?.({
text: trimmedValue,
files: fileList
});
}, [canSubmit, fileList, onSubmit, trimmedValue]);
useRequest(uploadFiles, {
manual: false,
......@@ -102,56 +110,40 @@ const HumanChatBubbleEditForm = ({
textarea.selectionEnd = textarea.value.length;
}, []);
const renderVoiceInput = () => (
<VoiceInput
ref={VoiceInputRef}
handleSend={(text) => {
onSubmit?.({
text: text.trim(),
files: fileList
});
replaceFiles([]);
}}
resetInputVal={(text) => {
setMobilePreSpeak(false);
setValue(text);
}}
mobilePreSpeak={mobilePreSpeak}
setMobilePreSpeak={setMobilePreSpeak}
/>
);
return (
<Box w={'100%'} maxW={'100%'} pt={mobilePreSpeak ? '48px' : 0}>
<Box w={'100%'} maxW={'100%'}>
<Box
position={'relative'}
h={mobilePreSpeak ? '48px' : 'auto'}
borderRadius={'12px'}
border={mobilePreSpeak ? 'none' : '2px solid'}
borderColor={mobilePreSpeak ? 'transparent' : 'primary.600'}
boxShadow={mobilePreSpeak ? 'none' : '0 0 0 3px rgba(51, 112, 255, 0.16)'}
bg={mobilePreSpeak ? 'transparent' : 'white'}
border={'2px solid'}
borderColor={'primary.600'}
boxShadow={'0 0 0 3px rgba(51, 112, 255, 0.16)'}
bg={'white'}
overflow={'hidden'}
>
{!mobilePreSpeak && (
<Box px={'12px'}>
<FilePreview fileList={fileList} removeFiles={removeFiles} />
</Box>
)}
<Box px={'12px'}>
<FilePreview fileList={fileList} removeFiles={removeFiles} />
</Box>
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
h={'116px'}
rows={4}
onKeyDown={(e) => {
if (e.key !== 'Enter' || e.shiftKey || e.nativeEvent.isComposing) return;
e.preventDefault();
handleSubmit();
}}
h={textareaHeight}
rows={textareaVisibleRows}
resize={'none'}
px={'12px'}
pt={'10px'}
pb={'10px'}
pt={`${textareaPaddingY}px`}
pb={`${textareaPaddingY}px`}
border={'none'}
bg={'white'}
color={'myGray.900'}
fontSize={['md', '20px']}
fontSize={'16px'}
lineHeight={'24px'}
fontWeight={400}
_focusVisible={{
......@@ -159,94 +151,66 @@ const HumanChatBubbleEditForm = ({
boxShadow: 'none'
}}
/>
{renderVoiceInput()}
</Box>
{!mobilePreSpeak && (
<Flex
pt={'8px'}
alignItems={'center'}
justifyContent={showCancel ? 'flex-end' : 'space-between'}
gap={'8px'}
>
<Flex alignItems={'center'} gap={'8px'} color={'myGray.500'}>
<Flex
alignItems={'center'}
justifyContent={'center'}
w={'36px'}
h={'36px'}
p={'8px'}
borderRadius={'sm'}
cursor={canUploadFile ? 'pointer' : 'not-allowed'}
opacity={canUploadFile ? 1 : 0.4}
_hover={canUploadFile ? { bg: 'rgba(0, 0, 0, 0.04)' } : undefined}
onClick={(e) => {
e.stopPropagation();
if (canUploadFile) {
onOpenSelectFile();
}
}}
>
<MyTooltip label={t('chat:select_file')}>
<MyIcon name={'core/chat/fileSelect'} w={'20px'} h={'20px'} color={'#707070'} />
</MyTooltip>
</Flex>
<File onSelect={(files) => onSelectFile({ files })} />
<Flex
alignItems={'center'}
justifyContent={'center'}
w={'36px'}
h={'36px'}
p={'8px'}
borderRadius={'sm'}
cursor={whisperConfig?.open ? 'pointer' : 'not-allowed'}
opacity={whisperConfig?.open ? 1 : 0.4}
_hover={whisperConfig?.open ? { bg: 'rgba(0, 0, 0, 0.04)' } : undefined}
onClick={(e) => {
e.stopPropagation();
VoiceInputRef.current?.onSpeak?.();
}}
>
<MyTooltip label={t('common:core.chat.Record')}>
<MyIcon name={'core/chat/recordFill'} w={'20px'} h={'20px'} color={'#707070'} />
</MyTooltip>
</Flex>
<Flex
pt={'8px'}
alignItems={'center'}
justifyContent={showCancel ? 'flex-end' : 'space-between'}
gap={'8px'}
>
<Flex alignItems={'center'} gap={'8px'} color={'myGray.500'}>
<Flex
alignItems={'center'}
justifyContent={'center'}
w={'36px'}
h={'36px'}
p={'8px'}
borderRadius={'sm'}
cursor={canUploadFile ? 'pointer' : 'not-allowed'}
opacity={canUploadFile ? 1 : 0.4}
_hover={canUploadFile ? { bg: 'rgba(0, 0, 0, 0.04)' } : undefined}
onClick={(e) => {
e.stopPropagation();
if (canUploadFile) {
onOpenSelectFile();
}
}}
>
<MyTooltip label={t('chat:select_file')}>
<MyIcon name={'core/chat/fileSelect'} w={'20px'} h={'20px'} color={'#707070'} />
</MyTooltip>
</Flex>
<File onSelect={(files) => onSelectFile({ files })} />
</Flex>
{showCancel && <Box h={'22px'} w={'1px'} bg={'myGray.200'} />}
{showCancel && <Box h={'22px'} w={'1px'} bg={'myGray.200'} />}
{showCancel && (
<Button
variant={'unstyled'}
w={'69px'}
h={'36px'}
color={'primary.600'}
fontSize={'14px'}
fontWeight={500}
onClick={onCancel}
>
{t('common:Cancel')}
</Button>
)}
{showCancel && (
<Button
variant={'unstyled'}
w={'69px'}
h={'36px'}
borderRadius={'8px'}
variant={'primary'}
color={'primary.600'}
fontSize={'14px'}
isDisabled={!canSubmit}
onClick={() => {
if (!canSubmit) return;
onSubmit?.({
text: trimmedValue,
files: fileList
});
}}
fontWeight={500}
onClick={onCancel}
>
{t('common:Update')}
{t('common:Cancel')}
</Button>
</Flex>
)}
)}
<Button
w={'69px'}
h={'36px'}
borderRadius={'8px'}
variant={'primary'}
fontSize={'14px'}
isDisabled={!canSubmit}
onClick={handleSubmit}
>
{t('common:Update')}
</Button>
</Flex>
</Box>
);
};
......
......@@ -14,8 +14,6 @@ import { useSize } from 'ahooks';
import { useContextSelector } from 'use-context-selector';
import { ChatBoxContext } from '../Provider';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { useSandboxEditor } from '@/pageComponents/chat/SandboxEditor/hook';
import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext';
export type CitationRenderItem = {
type: 'dataset' | 'link';
......@@ -215,15 +213,11 @@ const ResponseTags = ({
const dataId = historyItem.dataId;
const durationSeconds = historyItem.durationSeconds || 0;
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData);
const isShowCite = useContextSelector(ChatItemContext, (v) => v.isShowCite);
const showWholeResponse = useContextSelector(ChatItemContext, (v) => v.showWholeResponse ?? true);
const {
totalQuoteList: quoteList = [],
toolCiteLinks = [],
useAgentSandbox
} = useMemo(() => {
return {
...addStatisticalDataToHistoryItem(historyItem),
......@@ -245,12 +239,6 @@ const ResponseTags = ({
onClose: onCloseWholeModal
} = useDisclosure();
const { onOpenSandboxModal, SandboxEditorModal } = useSandboxEditor({
appId,
chatId,
outLinkAuthData
});
const citationRenderList: CitationRenderItem[] = useMemo(() => {
if (!isShowCite) return [];
......@@ -299,7 +287,6 @@ const ResponseTags = ({
const notEmptyTags =
(showFooterMeta && notSharePage) ||
useAgentSandbox ||
(showFooterMeta && isPc && durationSeconds > 0);
return !showTags ? null : (
......@@ -323,20 +310,6 @@ const ResponseTags = ({
</MyTooltip>
)}
{useAgentSandbox && (
<>
<MyTag
colorSchema="green"
type="borderSolid"
cursor={'pointer'}
onClick={onOpenSandboxModal}
>
{t('chat:sandbox_files')}
</MyTag>
<SandboxEditorModal />
</>
)}
{showFooterMeta && notSharePage && showWholeResponse && (
<MyTooltip label={t('common:core.chat.response.Read complete response tips')}>
<MyTag
......
......@@ -12,7 +12,7 @@ const DesktopHomeLayout = ({ inputSlot }: DesktopHomeLayoutProps) => {
<Flex h="100%" flexDir="column" justifyContent="center" w="100%">
<DesktopHomeHero />
<Box mt={5} w="100%">
<Box mt="32px" mb="16px" w="100%">
<QuickApps />
</Box>
......
......@@ -15,7 +15,6 @@ const QuickApps = ({ variant = 'desktop' }: QuickAppsProps) => {
return quickAppList && quickAppList.length > 0 ? (
<Flex
mb={isMobile ? 0 : '2'}
mx={isMobile ? 0 : 2}
alignItems={isMobile ? 'flex-start' : 'center'}
gap={isMobile ? '12px' : 2}
......@@ -26,21 +25,21 @@ const QuickApps = ({ variant = 'desktop' }: QuickAppsProps) => {
<Flex
key={q._id}
alignItems="center"
gap={isMobile ? '8px' : 1}
h={isMobile ? '44px' : undefined}
gap="8px"
h="44px"
border="sm"
borderRadius="md"
px={isMobile ? '16px' : 2}
py={isMobile ? 0 : 1}
px="16px"
py="8px"
maxW={isMobile ? '100%' : undefined}
cursor="pointer"
_hover={{ bg: 'myGray.50' }}
_hover={{ bg: '#F0F4FF', borderColor: '#C5D7FF', color: 'primary.600' }}
bg="white"
color="myGray.600"
borderColor="myGray.200"
onClick={() => onSwitchQuickApp?.(q._id)}
>
<Avatar src={q.avatar} w={isMobile ? '24px' : 4} borderRadius="xs" />
<Avatar src={q.avatar} w="24px" borderRadius="xs" />
<Box
fontSize={isMobile ? '14px' : 'xs'}
fontWeight="500"
......
import { type BoxProps } from '@chakra-ui/react';
export const textareaMinH = '22px';
export const textareaMinH = '24px';
export const ChatInputDefaultHeight: BoxProps['h'] = '132px';
export const ChatInputDefaultHeight: BoxProps['h'] = '112px';
export const HomeChatMobileBottomGap = 0;
......
......@@ -603,6 +603,8 @@ export const useChatGenerate = ({
);
syncSidebarChatGenerateStatus(ChatGenerateStatusEnum.generating, {
hasBeenRead: false,
targetAppId: appId,
targetChatId: chatId,
title: temporaryHistoryTitle
});
......@@ -656,12 +658,15 @@ export const useChatGenerate = ({
if (!abortSignal?.signal?.aborted) {
const uncaughtErr = responseData.find((r) => r.error)?.error;
const err = uncaughtErr ?? responseData[responseData.length - 1]?.errorText;
if (err) {
toast({
title: t(getErrText(err)),
status: 'warning'
});
}
const errorMsg = err ? t(getErrText(err)) : undefined;
return {
...item,
status: ChatStatusEnum.finish,
time: new Date(),
responseData,
errorMsg
};
}
return {
......@@ -685,7 +690,7 @@ export const useChatGenerate = ({
createQuestionGuide();
}
generatingScroll(true);
generatingScroll();
if (isPc) {
TextareaDom.current?.focus();
}
......@@ -698,6 +703,8 @@ export const useChatGenerate = ({
finishChatGenerateStatus({
status: ChatGenerateStatusEnum.done,
finishedInActiveChat,
targetAppId: appId,
targetChatId: chatId,
shouldUpdateChatBoxData: (state) => state.appId === appId && state.chatId === chatId
});
} catch (err: any) {
......@@ -705,12 +712,7 @@ export const useChatGenerate = ({
return;
}
toast({
title: t(getErrText(err, t('common:core.chat.error.Chat error') as any)),
status: 'error',
duration: 5000,
isClosable: true
});
const errorMsg = t(getErrText(err, t('common:core.chat.error.Chat error') as any));
setChatRecords((state) =>
state.map((item, index) => {
......@@ -718,20 +720,22 @@ export const useChatGenerate = ({
return {
...item,
time: new Date(),
status: ChatStatusEnum.finish
status: ChatStatusEnum.finish,
errorMsg
};
})
);
if (!err?.responseText) {
resetInputVal({ text, files });
setChatRecords(newChatList.slice(0, newChatList.length - 2));
}
const finishedInActiveChat = activeChatIdRef.current === chatId;
finishChatGenerateStatus({
status: ChatGenerateStatusEnum.error,
finishedInActiveChat,
targetAppId: appId,
targetChatId: chatId,
shouldUpdateChatBoxData: (state) => state.appId === appId && state.chatId === chatId
});
}
......
......@@ -76,6 +76,7 @@ export const useSidebarChatGenerateStatus = () => {
h.chatId === targetChatId && h.appId === targetAppId
? {
...h,
...(options?.title ? { title: options.title } : {}),
chatGenerateStatus: status,
updateTime: new Date(),
...(options?.hasBeenRead !== undefined ? { hasBeenRead: options.hasBeenRead } : {})
......
......@@ -31,11 +31,15 @@ const RenderProcessingCollapse = React.memo(function RenderProcessingCollapse({
index={isExpanded ? 0 : -1}
onChange={(index) => setIsExpanded(Array.isArray(index) ? index.length > 0 : index === 0)}
>
<AccordionItem borderTop={'none'} borderBottom={'none'}>
<Box w={'full'} pb={'4px'} borderBottom={'1px solid'} borderBottomColor={'myGray.200'}>
<AccordionItem borderTop={'none'} borderBottom={'none'} lineHeight={'24px'}>
<Box w={'full'} pb={'4px'} borderBottom={'1px solid'} borderBottomColor={'myGray.100'}>
<AccordionButton
w={'auto'}
display={'inline-flex'}
w={'fit-content'}
h={'24px'}
minH={'24px'}
display={'flex'}
alignItems={'center'}
lineHeight={'24px'}
p={0}
bg={'transparent'}
border={'none'}
......@@ -44,7 +48,7 @@ const RenderProcessingCollapse = React.memo(function RenderProcessingCollapse({
_hover={{ color: 'myGray.600', bg: 'transparent' }}
_expanded={{ color: 'myGray.600' }}
>
<HStack mr={1} spacing="0">
<HStack h={'24px'} lineHeight={'24px'} mr={1} spacing="0">
<Box fontSize={'16px'} lineHeight={'24px'}>
{t(isProcessing ? 'chat:processing' : 'chat:processed')}
{isProcessing && label && (
......@@ -57,7 +61,7 @@ const RenderProcessingCollapse = React.memo(function RenderProcessingCollapse({
</Box>
</HStack>
<AccordionIcon ml={1} color={'myGray.500'} />
<AccordionIcon ml={1} w={'16px'} h={'16px'} color={'myGray.500'} />
</AccordionButton>
</Box>
{isProcessing && !isExpanded && preview && <Box mt={2}>{preview}</Box>}
......
......@@ -39,10 +39,14 @@ const RenderReasoningContent = React.memo(function RenderReasoningContent({
return (
<Accordion allowToggle defaultIndex={defaultExpanded ? 0 : undefined}>
<AccordionItem borderTop={'none'} borderBottom={'none'}>
<AccordionItem borderTop={'none'} borderBottom={'none'} lineHeight={'24px'}>
<AccordionButton
w={'auto'}
display={'inline-flex'}
w={'fit-content'}
h={'24px'}
minH={'24px'}
display={'flex'}
alignItems={'center'}
lineHeight={'24px'}
p={0}
bg={'transparent'}
border={'none'}
......@@ -51,17 +55,33 @@ const RenderReasoningContent = React.memo(function RenderReasoningContent({
_hover={{ color: 'myGray.600', bg: 'transparent' }}
_expanded={{ color: 'myGray.600' }}
>
<HStack mr={1} spacing="0">
<Flex w="24px" h="24px" alignItems="center" justifyContent="flex-start">
<MyIcon name={'core/chat/deepThinking'} fill={'myGray.500'} />
<HStack h={'24px'} lineHeight={'24px'} mr={1} spacing="0">
<Flex
w="24px"
h="24px"
flexShrink={0}
alignItems="center"
justifyContent="center"
lineHeight={0}
>
<MyIcon
name={'core/chat/deepThinking'}
w={'20px'}
h={'20px'}
fill={'myGray.500'}
display={'block'}
verticalAlign={'middle'}
/>
</Flex>
<Box fontSize={'16px'}>{t('chat:ai_reasoning')}</Box>
<Box fontSize={'16px'} lineHeight={'24px'}>
{t('chat:ai_reasoning')}
</Box>
</HStack>
<AccordionIcon ml={1} color={'myGray.500'} />
<AccordionIcon ml={1} w={'16px'} h={'16px'} color={'myGray.500'} />
</AccordionButton>
<AccordionPanel py={0} pr={0} pl={0} mt={2} color={'myGray.500'}>
<Box position={'relative'} ml={1.5}>
<Box position={'relative'} ml={3}>
<Box
pl={3}
borderLeft={'1px solid'}
......
......@@ -38,9 +38,15 @@ const RenderTool = React.memo(
return (
<Accordion allowToggle>
<AccordionItem borderTop={'none'} borderBottom={'none'}>
<AccordionItem borderTop={'none'} borderBottom={'none'} lineHeight={'24px'}>
<AccordionButton
{...accordionButtonStyle}
h={'24px'}
minH={'24px'}
w={'fit-content'}
display={'flex'}
alignItems={'center'}
lineHeight={'24px'}
p={0}
bg={'transparent'}
border={'none'}
......@@ -50,8 +56,8 @@ const RenderTool = React.memo(
_hover={{ bg: 'transparent', color: 'myGray.600' }}
_expanded={{ color: 'myGray.600' }}
>
<HStack mr={1} spacing="0">
<Flex w="24px" h="24px" alignItems="center" justifyContent="flex-start">
<HStack h={'24px'} lineHeight={'24px'} mr={1} spacing="0">
<Flex w="24px" h="24px" alignItems="center" justifyContent="center">
<Avatar src={tool.toolAvatar} w="16px" h="16px" borderRadius="sm" />
</Flex>
<Box fontSize="16px" lineHeight="24px" color="myGray.600">
......@@ -61,12 +67,12 @@ const RenderTool = React.memo(
{showAnimation && tool.response === undefined && (
<MyIcon name={'common/loading'} w={'14px'} color="myGray.500" />
)}
<AccordionIcon ml={1} color={'myGray.500'} />
<AccordionIcon ml={1} w={'16px'} h={'16px'} color={'myGray.500'} />
</AccordionButton>
<AccordionPanel
py={0}
px={0}
mt={3}
mt={2}
borderRadius={'md'}
overflow={'hidden'}
maxH={'500px'}
......
......@@ -9,13 +9,15 @@ import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation';
import {
WHOLE_RESPONSE_SIDE_TAB_PANEL_PADDING,
WHOLE_RESPONSE_SIDE_TAB_WIDTH,
WholeResponseSideTab
} from './SideTab';
import { WholeResponseContent } from './WholeResponseContent';
import { flattenResponse, getSideTabItems } from './responseData';
import { flattenResponse, getSideTabItems, getSideTabMaxDepth } from './responseData';
const RequestIdDetailModal = dynamic(() => import('@/components/core/ai/requestId'));
const sideTabBaseWidth = 204;
const sideTabDeepTreeExtraWidth = 50;
const sideTabDeepTreeMinDepth = 4;
export const ResponseBox = React.memo(function ResponseBox({
response,
......@@ -51,6 +53,10 @@ export const ResponseBox = React.memo(function ResponseBox({
);
const sliderResponseList = useMemo(() => getSideTabItems(response), [response]);
const sideTabWidth = useMemo(() => {
const maxDepth = getSideTabMaxDepth(sliderResponseList);
return `${sideTabBaseWidth + (maxDepth >= sideTabDeepTreeMinDepth ? sideTabDeepTreeExtraWidth : 0)}px`;
}, [sliderResponseList]);
const {
isOpen: isOpenMobileModal,
......@@ -70,7 +76,7 @@ export const ResponseBox = React.memo(function ResponseBox({
borderRadius={'12px'}
>
<Box
w={`${WHOLE_RESPONSE_SIDE_TAB_WIDTH}px`}
w={sideTabWidth}
flexShrink={0}
borderRight={'1px solid'}
borderColor={'myGray.200'}
......
......@@ -6,43 +6,14 @@ import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation';
import type { SideTabItemType } from './types';
// 桌面端左侧栏总宽度。外层容器和内部列表都依赖这个值,避免两边分别写死后宽度错位。
export const WHOLE_RESPONSE_SIDE_TAB_WIDTH = 220;
// Chakra 的 p={3} 当前等价于 12px;这里显式写成数字,便于和内容宽度做同源计算。
export const WHOLE_RESPONSE_SIDE_TAB_PANEL_PADDING = 12;
// 内部列表宽度必须等于侧栏总宽度减去左右 padding,否则会出现横向滚动或右侧留白异常。
const SIDE_TAB_CONTENT_WIDTH =
WHOLE_RESPONSE_SIDE_TAB_WIDTH - WHOLE_RESPONSE_SIDE_TAB_PANEL_PADDING * 2;
const SIDE_TAB_ROOT_PADDING = 8;
// 详情树可能继续嵌套,但左侧栏只展示有限层级缩进,超过后保持同一缩进避免文字被挤没。
const SIDE_TAB_MAX_CHILD_DEPTH = 3;
const SIDE_TAB_AVATAR_SIZE = 24;
const SIDE_TAB_TEXT_GAP = 8;
// 折叠图标实际是 24px,再加右侧 4px 间距;只有存在 children 的节点才会预留这段空间。
const SIDE_TAB_ACTION_SLOT_WIDTH = 28;
// 给节点名称保底的可读宽度,用来反推每一层 child 可以增加多少缩进。
const SIDE_TAB_MIN_TEXT_WIDTH = 44;
/**
* 侧边栏宽度、容器 padding 和子节点缩进需要一起计算。
*
* 外层容器增加宽度时,内容区宽度会跟着变;这里保留最多 3 层可见缩进,
* 同时给头像、操作按钮和节点名称留出最小展示空间,避免改外层宽度后
* 子节点文字被压到几乎不可读。
*/
const SIDE_TAB_CHILD_INDENT = Math.max(
0,
Math.floor(
(SIDE_TAB_CONTENT_WIDTH -
SIDE_TAB_ROOT_PADDING -
SIDE_TAB_AVATAR_SIZE -
SIDE_TAB_TEXT_GAP -
SIDE_TAB_ACTION_SLOT_WIDTH -
SIDE_TAB_MIN_TEXT_WIDTH) /
SIDE_TAB_MAX_CHILD_DEPTH
)
);
const SIDE_TAB_CHILD_INDENT = 28;
const getSideTabLeftPadding = (index: number) => {
const safeIndex = Math.min(index, SIDE_TAB_MAX_CHILD_DEPTH);
......@@ -76,9 +47,9 @@ const NormalSideTabItem = ({
_hover={{ background: 'myGray.100' }}
py={'6px'}
pl={leftPad}
// 只有可展开节点才有右侧箭头,叶子节点不预留空按钮位,尽量把宽度还给名称。
pr={children ? `${SIDE_TAB_ACTION_SLOT_WIDTH}px` : '4px'}
width={'100%'}
pr={'8px'}
w={'100%'}
minW={0}
cursor={'pointer'}
borderRadius={'6px'}
position={'relative'}
......@@ -94,15 +65,16 @@ const NormalSideTabItem = ({
h={`${SIDE_TAB_AVATAR_SIZE}px`}
borderRadius={'4px'}
/>
<Box ml={2} minW={0} flex={'1 1 auto'}>
<Box ml={2} flex={'1 1 0'} minW={0}>
<Box
fontSize={'12px'}
lineHeight={'16px'}
fontWeight={500}
color={'myGray.900'}
letterSpacing={'0.5px'}
noOfLines={3}
wordBreak={'break-all'}
overflow={'hidden'}
whiteSpace={'nowrap'}
textOverflow={'ellipsis'}
>
{t(sideBarItem.moduleName as any, sideBarItem.moduleNameArgs)}
</Box>
......@@ -112,22 +84,24 @@ const NormalSideTabItem = ({
fontWeight={500}
color={'myGray.500'}
letterSpacing={'0.5px'}
noOfLines={1}
overflow={'hidden'}
whiteSpace={'nowrap'}
textOverflow={'ellipsis'}
>
{t(sideBarItem.runningTime as any) + 's'}
</Box>
</Box>
{children && (
<Box
h={`${SIDE_TAB_AVATAR_SIZE}px`}
w={`${SIDE_TAB_AVATAR_SIZE}px`}
position={'absolute'}
right={'4px'}
top={'50%'}
transform={'translateY(-50%)'}
<Flex
h={'24px'}
w={'20px'}
flexShrink={0}
alignItems={'center'}
justifyContent={'center'}
ml={1}
>
{children}
</Box>
</Flex>
)}
</Flex>
);
......@@ -150,7 +124,7 @@ const AccordionSideTabItem = ({
return (
<>
<Flex align={'center'} position={'relative'}>
<Flex align={'center'} position={'relative'} w={'100%'}>
<NormalSideTabItem
index={index}
value={value}
......@@ -170,7 +144,7 @@ const AccordionSideTabItem = ({
</NormalSideTabItem>
</Flex>
{isShowAccordion && (
<Flex flexDirection={'column'} gap={1} position={'relative'}>
<Flex flexDirection={'column'} gap={1} position={'relative'} w={'100%'}>
{sideBarItem.children.map((item) => (
<SideTabItem
value={value}
......@@ -200,7 +174,7 @@ const SideTabItem = ({
if (!sideBarItem) return null;
return sideBarItem.children.length !== 0 ? (
<Box>
<Box w={'100%'}>
<AccordionSideTabItem
sideBarItem={sideBarItem}
onChange={onChange}
......@@ -225,7 +199,7 @@ export const WholeResponseSideTab = ({
isMobile?: boolean;
}) => {
return (
<Flex flexDirection={'column'} gap={1}>
<Flex flexDirection={'column'} gap={1} w={isMobile ? 'auto' : '100%'}>
{response.map((item) => (
<Flex
key={item.id}
......@@ -235,8 +209,8 @@ export const WholeResponseSideTab = ({
m={0}
mb={isMobile ? 3 : 0}
borderRadius={'md'}
// 桌面端固定使用内容宽度;移动端走自适应卡片宽度,不受桌面侧栏配置影响。
w={isMobile ? 'auto' : `${SIDE_TAB_CONTENT_WIDTH}px`}
w={isMobile ? 'auto' : '100%'}
minW={0}
>
<SideTabItem value={value} onChange={onChange} sideBarItem={item} index={0} />
</Flex>
......
......@@ -40,3 +40,14 @@ export const getSideTabItems = (response: ChatHistoryItemResType[]): SideTabItem
};
});
};
export const getSideTabMaxDepth = (items: SideTabItemType[], depth = 1): number => {
if (items.length === 0) return 0;
return items.reduce((maxDepth, item) => {
const childDepth =
item.children.length > 0 ? getSideTabMaxDepth(item.children, depth + 1) : depth;
return Math.max(maxDepth, childDepth);
}, depth);
};
......@@ -113,7 +113,8 @@ const AppCard = ({
onClick={() =>
window.open(
`/chat?appId=${appId}&pane=${ChatSidebarPaneEnum.RECENTLY_USED_APPS}`,
'_blank'
'_blank',
'noopener'
)
}
/>
......
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import React, { useMemo } from 'react';
import { SmallCloseIcon } from '@chakra-ui/icons';
import { Box, Flex, IconButton } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { Box, Flex } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { type StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
......@@ -21,11 +18,10 @@ import ChatRecordContextProvider, {
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import MyBox from '@fastgpt/web/components/common/MyBox';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
import VariablePopover from '@/components/core/chat/ChatContainer/components/VariablePopover';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
import { ChatTypeEnum } from '@/components/core/chat/ChatContainer/ChatBox/constants';
import { useSandboxEditor, useSandboxStatus } from '@/pageComponents/chat/SandboxEditor/hook';
import { getAppChatConfig, getGuideModule } from '@fastgpt/global/core/workflow/utils';
import RunPreviewHeader from './RunPreviewHeader';
type Props = {
isOpen: boolean;
......@@ -63,7 +59,6 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose, chatId }: Props) =>
const datasetCiteData = useContextSelector(ChatItemContext, (v) => v.datasetCiteData);
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
const isVariableVisible = useContextSelector(ChatItemContext, (v) => v.isVariableVisible);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
// Sandbox: Status Hook 负责网络同步,UI Hook 负责弹窗渲染
......@@ -136,53 +131,18 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose, chatId }: Props) =>
<CloseIcon mt={1} onClick={onClose} />
</Flex>
) : (
<Flex
py={2.5}
px={5}
whiteSpace={'nowrap'}
bg={'myGray.25'}
borderBottom={'1px solid #F4F4F7'}
>
<Flex fontSize={'16px'} fontWeight={'bold'} alignItems={'center'} mr={3}>
<MyIcon name={'common/paused'} w={'14px'} mr={2.5} />
<MyTooltip label={chatId ? t('common:chat_chatId', { chatId }) : ''}>
<Box
cursor={'pointer'}
onClick={() => {
copyData(chatId);
}}
>
{t('common:core.chat.Run test')}
</Box>
</MyTooltip>
</Flex>
{!isVariableVisible && <VariablePopover chatType={ChatTypeEnum.test} />}
<Box flex={1} />
<SandboxEntryIcon mr={2} onOpen={onOpenSandboxModal} />
<MyTooltip label={t('common:core.chat.Restart')}>
<IconButton
mr={2}
className="chat"
size={'smSquare'}
icon={<MyIcon name={'common/clearLight'} w={'14px'} />}
variant={'whiteDanger'}
borderRadius={'md'}
aria-label={'delete'}
onClick={restartChat}
/>
</MyTooltip>
<MyTooltip label={t('common:Close')}>
<IconButton
icon={<SmallCloseIcon fontSize={'22px'} />}
variant={'grayBase'}
size={'smSquare'}
aria-label={''}
onClick={onClose}
bg={'none'}
/>
</MyTooltip>
</Flex>
<RunPreviewHeader
title={t('common:core.chat.Run test')}
chatId={chatId}
chatIdLabel={chatId ? t('common:chat_chatId', { chatId }) : ''}
restartLabel={t('common:core.chat.Restart')}
closeLabel={t('common:Close')}
SandboxEntryIcon={SandboxEntryIcon}
onCopyChatId={() => copyData(chatId)}
onOpenSandboxModal={onOpenSandboxModal}
onRestart={restartChat}
onClose={onClose}
/>
)}
<Flex flex={'1 0 0'} alignItems={'end'} h={'100%'}>
......
import React from 'react';
import { Box, Flex, IconButton, type IconButtonProps } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { ChatTypeEnum } from '@/components/core/chat/ChatContainer/ChatBox/constants';
import ChatVariableButton from '@/pageComponents/chat/ChatWindow/ChatVariableButton';
const RunPreviewHeader = ({
title,
chatId,
chatIdLabel,
restartLabel,
closeLabel,
SandboxEntryIcon,
onCopyChatId,
onOpenSandboxModal,
onRestart,
onClose
}: {
title: string;
chatId: string;
chatIdLabel: string;
restartLabel: string;
closeLabel: string;
SandboxEntryIcon: React.ComponentType<
Omit<IconButtonProps, 'name' | 'onClick' | 'aria-label'> & { onOpen: () => void }
>;
onCopyChatId: () => void;
onOpenSandboxModal: () => void;
onRestart: () => void;
onClose: () => void;
}) => {
return (
<Flex
minH="56px"
px="24px"
bg="white"
fontWeight={500}
color="myGray.900"
alignItems="center"
justifyContent="flex-start"
position="relative"
>
<MyTooltip label={chatId ? chatIdLabel : ''}>
<Box cursor="pointer" onClick={onCopyChatId}>
{title}
</Box>
</MyTooltip>
<Flex position="absolute" right="24px" alignItems="center" gap={2}>
<ChatVariableButton chatType={ChatTypeEnum.test} />
<SandboxEntryIcon onOpen={onOpenSandboxModal} />
<MyTooltip label={restartLabel}>
<IconButton
className="chat"
size="smSquare"
icon={<MyIcon name="common/clearLight" w="14px" />}
variant="whiteDanger"
borderRadius="md"
aria-label={restartLabel}
onClick={onRestart}
/>
</MyTooltip>
<MyTooltip label={closeLabel}>
<IconButton
icon={<MyIcon name="common/closeLight" w="16px" />}
variant="grayBase"
size="smSquare"
aria-label={closeLabel}
onClick={onClose}
bg="none"
/>
</MyTooltip>
</Flex>
</Flex>
);
};
export default React.memo(RunPreviewHeader);
......@@ -10,6 +10,7 @@ import { useContextSelector } from 'use-context-selector';
import { ChatContext } from '@/web/core/chat/context/chatContext';
import ChatSliderMobileDrawer from '@/pageComponents/chat/slider/ChatSliderMobileDrawer';
import { ChatPageContext } from '@/web/core/chat/context/chatPageContext';
import { useSystemStore } from '@/web/common/system/useSystemStore';
enum ChatAllAppTabEnum {
FAVOURITE_APPS = 'favouriteApps',
......@@ -23,7 +24,10 @@ enum ChatAllAppTabEnum {
const ChatAllApp = () => {
const { t } = useTranslation();
const { isPc } = useSystem();
const { feConfigs } = useSystemStore();
const showFavouriteApps = !!feConfigs.isPlus;
const [tab, setTab] = useState(ChatAllAppTabEnum.FAVOURITE_APPS);
const activeTab = showFavouriteApps ? tab : ChatAllAppTabEnum.TEAM_APPS;
const [showMobileSearch, setShowMobileSearch] = useState(false);
const [mobileSearchKey, setMobileSearchKey] = useState('');
......@@ -32,16 +36,20 @@ const ChatAllApp = () => {
const tabOptions = useMemo(
() => [
{
label: t('chat:sidebar.favourite_apps'),
value: ChatAllAppTabEnum.FAVOURITE_APPS
},
...(showFavouriteApps
? [
{
label: t('chat:sidebar.favourite_apps'),
value: ChatAllAppTabEnum.FAVOURITE_APPS
}
]
: []),
{
label: t('chat:sidebar.team_apps'),
value: ChatAllAppTabEnum.TEAM_APPS
}
],
[t]
[showFavouriteApps, t]
);
if (!isPc) {
......@@ -94,7 +102,7 @@ const ChatAllApp = () => {
<Box px="16px" py="8px" flexShrink={0}>
<FillRowTabs<ChatAllAppTabEnum>
list={tabOptions}
value={tab}
value={activeTab}
onChange={setTab}
outerPadding="4px"
outerHeight="40px"
......@@ -140,10 +148,10 @@ const ChatAllApp = () => {
)}
<Box flex="1 0 0" h={0} overflow="hidden">
{tab === ChatAllAppTabEnum.FAVOURITE_APPS && (
{showFavouriteApps && activeTab === ChatAllAppTabEnum.FAVOURITE_APPS && (
<ChatFavouriteApp hideMobileHeader mobileSearchKey={mobileSearchKey} />
)}
{tab === ChatAllAppTabEnum.TEAM_APPS && (
{activeTab === ChatAllAppTabEnum.TEAM_APPS && (
<ChatTeamApp hideMobileHeader mobileSearchKey={mobileSearchKey} />
)}
</Box>
......@@ -156,7 +164,7 @@ const ChatAllApp = () => {
<Box px={[4, 6]} pt={[4, 6]}>
<FillRowTabs<ChatAllAppTabEnum>
list={tabOptions}
value={tab}
value={activeTab}
onChange={setTab}
outerPadding="4px"
outerHeight="40px"
......@@ -166,8 +174,10 @@ const ChatAllApp = () => {
</Box>
<Box flex="1 0 0" h={0}>
{tab === ChatAllAppTabEnum.FAVOURITE_APPS && <ChatFavouriteApp />}
{tab === ChatAllAppTabEnum.TEAM_APPS && <ChatTeamApp />}
{showFavouriteApps && activeTab === ChatAllAppTabEnum.FAVOURITE_APPS && (
<ChatFavouriteApp />
)}
{activeTab === ChatAllAppTabEnum.TEAM_APPS && <ChatTeamApp />}
</Box>
</Flex>
);
......
......@@ -277,7 +277,7 @@ const ChatFavouriteApp = ({ hideMobileHeader = false, mobileSearchKey }: Props)
<Box
flex={['1 0 60px', '1 0 72px']}
mt={3}
pt={3}
pr={8}
textAlign={'justify'}
wordBreak={'break-all'}
......
......@@ -205,6 +205,7 @@ const MobileDrawer = ({ onCloseDrawer, appId }: { onCloseDrawer: () => void; app
myApps.map((item) => (
<Flex justify={'center'} key={item.appId}>
<Flex
gap={2}
py={2.5}
px={2}
width={'100%'}
......@@ -220,7 +221,7 @@ const MobileDrawer = ({ onCloseDrawer, appId }: { onCloseDrawer: () => void; app
})}
>
<Avatar src={item.avatar} w={'24px'} borderRadius={'sm'} />
<Box ml={2} className={'textEllipsis'}>
<Box className={'textEllipsis'}>
{item.name}
</Box>
</Flex>
......@@ -276,10 +277,10 @@ const MobileHeader = ({
/>
)}
<Flex px={3} alignItems={'center'} flex={'1 0 0'} w={0} justifyContent={'center'}>
<Flex alignItems={'center'} onClick={toggleDrawer}>
<Flex alignItems={'center'} gap={1} onClick={toggleDrawer}>
<Avatar borderRadius={'sm'} src={avatar} w={'1rem'} />
<Box ml={1} className="textEllipsis">
<Box overflow={'hidden'} whiteSpace={'nowrap'} textOverflow={'clip'}>
{name}
</Box>
......@@ -314,23 +315,23 @@ export const PcHeader = ({
return (
<>
<MyTooltip label={chatId ? t('common:chat_chatId', { chatId }) : ''}>
<Box
mr={3}
maxW={'200px'}
className="textEllipsis"
color={'myGray.900'}
cursor={'pointer'}
onClick={() => {
copyData(chatId);
}}
>
{title}
</Box>
</MyTooltip>
<MyTag>
<Box pr={3} maxW={'200px'} minW={0}>
<MyTooltip label={chatId ? t('common:chat_chatId', { chatId }) : ''}>
<Box
className="textEllipsis"
color={'myGray.900'}
cursor={'pointer'}
onClick={() => {
copyData(chatId);
}}
>
{title}
</Box>
</MyTooltip>
</Box>
<MyTag gap={1}>
<MyIcon name={'history'} w={'14px'} />
<Box ml={1}>
<Box>
{totalRecordsCount === 0
? t('common:core.chat.New Chat')
: t('common:core.chat.History Amount', { amount: totalRecordsCount })}
......@@ -338,12 +339,14 @@ export const PcHeader = ({
</MyTag>
{!!chatModels && chatModels.length > 0 && (
<MyTooltip label={chatModels.join(',')}>
<MyTag ml={2} colorSchema={'green'}>
<MyIcon name={'core/chat/chatModelTag'} w={'14px'} />
<Box ml={1} maxW={'200px'} className="textEllipsis">
{chatModels.join(',')}
</Box>
</MyTag>
<Box pl={2}>
<MyTag gap={1} colorSchema={'green'}>
<MyIcon name={'core/chat/chatModelTag'} w={'14px'} />
<Box maxW={'200px'} className="textEllipsis">
{chatModels.join(',')}
</Box>
</MyTag>
</Box>
</MyTooltip>
)}
</>
......
......@@ -75,7 +75,7 @@ const CollectionQuoteItem = ({
}}
>
{updated && (
<Flex mt={2}>
<Flex pt={2}>
<Box
bg={'green.50'}
border={'1px solid'}
......
......@@ -232,6 +232,7 @@ const CollectionReader = ({
w={'full'}
h={'56px'}
px={4}
gap={3}
alignItems={'center'}
borderBottom={'1px solid'}
borderColor={'myGray.150'}
......@@ -251,7 +252,7 @@ const CollectionReader = ({
{isDeleted ? (
<Flex
ml={3}
gap={1}
borderRadius={'sm'}
py={1}
px={2}
......@@ -260,7 +261,7 @@ const CollectionReader = ({
alignItems={'center'}
fontSize={'11px'}
>
<MyIcon name="common/info" w={'14px'} mr={1} color={'red.600'} />
<MyIcon name="common/info" w={'14px'} color={'red.600'} />
{t('chat:chat.quote.deleted')}
</Flex>
) : null}
......
......@@ -46,10 +46,10 @@ const QuoteItem = ({
}}
onClick={onClick}
>
<Flex gap={2} alignItems={'center'} mb={'8px'}>
<Flex gap={2} alignItems={'center'} pb={'8px'}>
<Box alignItems={'center'} fontSize={'10px'} fontWeight={500} display={'inline-flex'}>
<Flex>
<MyIcon name={icon as any} mr={1} flexShrink={0} w={'12px'} />
<Flex gap={1}>
<MyIcon name={icon as any} flexShrink={0} w={'12px'} />
<Box
className={'textEllipsis'}
wordBreak={'break-all'}
......@@ -72,6 +72,7 @@ const QuoteItem = ({
</>
) : (
<Flex
gap={1}
justifyContent={'center'}
alignItems={'center'}
h={'full'}
......@@ -79,7 +80,7 @@ const QuoteItem = ({
bg={'#FAFAFA'}
color={'myGray.500'}
>
<MyIcon name="common/info" w={'14px'} mr={1} color={'myGray.500'} />
<MyIcon name="common/info" w={'14px'} color={'myGray.500'} />
{t('chat:chat.quote.deleted')}
</Flex>
)}
......
......@@ -16,12 +16,11 @@ const ScoreTag = (score: { primaryScore?: ScoreItemType; secondaryScore: ScoreIt
<Flex flexDir={'column'} gap={4}>
{score.secondaryScore.map((item, i) => (
<Box fontSize={'sm'} key={i}>
<Flex alignItems={'flex-start'} lineHeight={1.2} mb={1}>
<Flex alignItems={'flex-start'} gap={'2px'} lineHeight={1.2} pb={1}>
<Box
px={'5px'}
borderWidth={'1px'}
borderRadius={'sm'}
mr={'2px'}
{...(scoreTheme[i] && scoreTheme[i])}
>
<Box transform={'scale(0.9)'}>#{item.index + 1}</Box>
......
......@@ -134,7 +134,7 @@ const AddFavouriteAppModal = ({ onClose, onRefresh }: Props) => {
minH={0}
>
<Flex h="100%" direction="column" minH={0} py={4} overflow="hidden">
<Box mb={2} px={4}>
<Box pb={2} px={4}>
<SearchInput
placeholder={t('chat:setting.favourite.search_placeholder')}
value={searchAppNameValue}
......@@ -143,7 +143,7 @@ const AddFavouriteAppModal = ({ onClose, onRefresh }: Props) => {
/>
</Box>
<Box mb={2} py={1} px={4} fontSize="sm" minH={8} display="flex" alignItems="center">
<Box pb={2} py={1} px={4} fontSize="sm" minH={8} display="flex" alignItems="center">
{searchAppNameValue && (
<Box
w="100%"
......@@ -157,7 +157,7 @@ const AddFavouriteAppModal = ({ onClose, onRefresh }: Props) => {
</Box>
)}
{!searchAppNameValue && paths.length === 0 && (
<Flex flex={1} alignItems="center">
<Flex flex={1} alignItems="center" gap={1}>
<Box
fontSize={['xs', 'sm']}
py={0.5}
......@@ -173,7 +173,7 @@ const AddFavouriteAppModal = ({ onClose, onRefresh }: Props) => {
>
{t('common:root_folder')}
</Box>
<MyIcon name="common/line" color="myGray.500" mx={1} w="5px" />
<MyIcon name="common/line" color="myGray.500" w="5px" />
</Flex>
)}
{!searchAppNameValue && paths.length > 0 && (
......@@ -201,6 +201,7 @@ const AddFavouriteAppModal = ({ onClose, onRefresh }: Props) => {
<Box key={item._id} userSelect={'none'}>
<Flex
align="center"
gap={2.5}
pr={2}
pl={4}
py={1.5}
......@@ -229,7 +230,7 @@ const AddFavouriteAppModal = ({ onClose, onRefresh }: Props) => {
)}
</Box>
<Avatar src={item.avatar} w={7} h={7} borderRadius="sm" ml={3} mr={2.5} />
<Avatar src={item.avatar} w={7} h={7} borderRadius="sm" />
<Box flex={1} minW={0}>
<Box fontSize="sm" color={'myGray.900'} lineHeight={1}>
......@@ -241,7 +242,7 @@ const AddFavouriteAppModal = ({ onClose, onRefresh }: Props) => {
</Box>
{item.type === AppTypeEnum.folder && (
<Box mr={10}>
<Box pr={10}>
<ChevronRightIcon w={5} h={5} color="myGray.500" strokeWidth="1px" />
</Box>
)}
......@@ -254,7 +255,7 @@ const AddFavouriteAppModal = ({ onClose, onRefresh }: Props) => {
<GridItem minH={0}>
<VStack spacing={2} alignItems="stretch">
<Box mb={3} px={4} pt={4} fontSize="sm" color="myGray.600">
<Box pb={3} px={4} pt={4} fontSize="sm" color="myGray.600">
{t('chat:setting.favourite.selected_list', {
num: selectedApps.length
})}
......
......@@ -296,8 +296,14 @@ const FavouriteAppSetting = ({ Header }: Props) => {
</Box>
{/* 名称列 */}
<Box w={[3 / 10, 2.5 / 10]} display="flex" alignItems="center" pr={4}>
<Avatar src={row.avatar} borderRadius={'xs'} w={'20px'} mr={2} />
<Box
w={[3 / 10, 2.5 / 10]}
display="flex"
alignItems="center"
gap={2}
pr={4}
>
<Avatar src={row.avatar} borderRadius={'xs'} w={'20px'} />
<Box
fontWeight={'medium'}
whiteSpace={'nowrap'}
......
......@@ -175,7 +175,7 @@ const AddQuickAppModal = ({ selectedIds, onClose, onConfirm }: Props) => {
minW={0}
>
<Flex h="100%" direction="column" minH={0} py={4} overflow="hidden">
<Box mb={2} px={4}>
<Box pb={2} px={4}>
<SearchInput
placeholder={t('chat:setting.favourite.search_placeholder')}
value={searchAppName}
......@@ -187,7 +187,7 @@ const AddQuickAppModal = ({ selectedIds, onClose, onConfirm }: Props) => {
/>
</Box>
<Box mb={2} py={1} px={4} fontSize="sm" minH={8} display="flex" alignItems="center">
<Box pb={2} py={1} px={4} fontSize="sm" minH={8} display="flex" alignItems="center">
{searchAppName && (
<Box
w="100%"
......@@ -201,7 +201,7 @@ const AddQuickAppModal = ({ selectedIds, onClose, onConfirm }: Props) => {
</Box>
)}
{!searchAppName && paths.length === 0 && (
<Flex flex={1} alignItems="center">
<Flex flex={1} alignItems="center" gap={1}>
<Box
fontSize={['xs', 'sm']}
py={0.5}
......@@ -217,7 +217,7 @@ const AddQuickAppModal = ({ selectedIds, onClose, onConfirm }: Props) => {
>
{t('common:root_folder')}
</Box>
<MyIcon name="common/line" color="myGray.500" mx={1} w="5px" />
<MyIcon name="common/line" color="myGray.500" w="5px" />
</Flex>
)}
{!searchAppName && paths.length > 0 && (
......@@ -246,6 +246,7 @@ const AddQuickAppModal = ({ selectedIds, onClose, onConfirm }: Props) => {
<Flex
align="center"
minW={0}
gap={2.5}
pr={2}
pl={4}
py={1.5}
......@@ -279,8 +280,6 @@ const AddQuickAppModal = ({ selectedIds, onClose, onConfirm }: Props) => {
w={7}
h={7}
borderRadius="sm"
ml={3}
mr={2.5}
flexShrink={0}
/>
......@@ -299,7 +298,7 @@ const AddQuickAppModal = ({ selectedIds, onClose, onConfirm }: Props) => {
</Box>
{item.type === AppTypeEnum.folder && (
<Box mr={10} flexShrink={0}>
<Box pr={10} flexShrink={0}>
<ChevronRightIcon w={5} h={5} color="myGray.500" strokeWidth="1px" />
</Box>
)}
......@@ -312,7 +311,7 @@ const AddQuickAppModal = ({ selectedIds, onClose, onConfirm }: Props) => {
<GridItem minH={0} minW={0}>
<VStack spacing={2} alignItems="stretch" h="100%" minH={0} minW={0}>
<Box mb={3} px={4} pt={4} fontSize="sm" color="myGray.600">
<Box pb={3} px={4} pt={4} fontSize="sm" color="myGray.600">
{t('chat:setting.favourite.selected_list', {
num: `${checkedQuickApps.length} / 4`
})}
......@@ -371,7 +370,7 @@ const AddQuickAppModal = ({ selectedIds, onClose, onConfirm }: Props) => {
w={'16px'}
/>
</Box>
<Flex alignItems="center" flex={1} minW={0}>
<Flex alignItems="center" gap={2} flex={1} minW={0}>
<Avatar
src={app.avatar}
borderRadius={'sm'}
......@@ -383,7 +382,6 @@ const AddQuickAppModal = ({ selectedIds, onClose, onConfirm }: Props) => {
minW={0}
className="textEllipsis"
userSelect="none"
ml={2}
>
{app.name}
</Box>
......
......@@ -177,7 +177,7 @@ const HomepageSetting = ({ Header, onDiagramShow }: Props) => {
{/* QUICK APPS */}
<Box fontWeight={'500'}>
<Flex fontWeight={'500'} fontSize="14px" mb={2} alignItems={'center'} gap={2}>
<Flex fontWeight={'500'} fontSize="14px" pb={2} alignItems={'center'} gap={2}>
<Box>{t('chat:setting.home.quick_apps')}</Box>
</Flex>
......@@ -235,7 +235,7 @@ const HomepageSetting = ({ Header, onDiagramShow }: Props) => {
<Flex
fontWeight={'500'}
fontSize="14px"
mb={2}
pb={2}
justifyContent={'space-between'}
alignItems={'center'}
gap={2}
......@@ -322,7 +322,7 @@ const HomepageSetting = ({ Header, onDiagramShow }: Props) => {
{/* SLOGAN */}
<Box fontWeight={'500'}>
<Flex fontWeight={'500'} fontSize="14px" mb={2} alignItems={'center'} gap={2}>
<Flex fontWeight={'500'} fontSize="14px" pb={2} alignItems={'center'} gap={2}>
<Box>{t('chat:setting.home.slogan')}</Box>
<Button
......@@ -347,7 +347,7 @@ const HomepageSetting = ({ Header, onDiagramShow }: Props) => {
{/* DIALOGUE TIPS */}
<Box fontWeight={'500'}>
<Flex fontWeight={'500'} fontSize="14px" mb={2} alignItems={'center'} gap={2}>
<Flex fontWeight={'500'} fontSize="14px" pb={2} alignItems={'center'} gap={2}>
<Box>{t('chat:setting.home.dialogue_tips')}</Box>
<Button
......@@ -392,7 +392,7 @@ const HomepageSetting = ({ Header, onDiagramShow }: Props) => {
</Flex>
<Box fontWeight={'500'}>
<Flex fontWeight={'500'} fontSize="14px" mb={2} alignItems={'center'} gap={2}>
<Flex fontWeight={'500'} fontSize="14px" pb={2} alignItems={'center'} gap={2}>
<Box>{t('chat:setting.home.home_tab_title')}</Box>
<Button
......@@ -418,7 +418,7 @@ const HomepageSetting = ({ Header, onDiagramShow }: Props) => {
{/* LOGO */}
<Box fontWeight={'500'}>
<Flex fontWeight={'500'} fontSize="14px" alignItems={'center'} gap={2} mb={2}>
<Flex fontWeight={'500'} fontSize="14px" alignItems={'center'} gap={2} pb={2}>
<Box>{t('chat:setting.copyright.logo')}</Box>
<Button
......
......@@ -137,7 +137,7 @@ const ToolSelectModal = ({ onClose, ...props }: Props & { onClose: () => void })
</Box>
{/* Tag filter */}
{allTags.length > 0 && (
<Box mt={3} mb={-1} px={[3, 6]}>
<Box pt={3} mb={-1} px={[3, 6]}>
<ToolTagFilterBox
size="sm"
tags={allTags}
......@@ -148,11 +148,11 @@ const ToolSelectModal = ({ onClose, ...props }: Props & { onClose: () => void })
)}
{/* route components */}
{!searchKey && parentId && (
<Flex mt={2} px={[3, 6]}>
<Flex pt={2} px={[3, 6]}>
<FolderPath paths={paths} FirstPathDom={null} onClick={onUpdateParentId} />
</Flex>
)}
<MyBox isLoading={isLoading} mt={2} pb={3} flex={'1 0 0'} h={0}>
<MyBox isLoading={isLoading} pt={2} pb={3} flex={'1 0 0'} h={0}>
<Box px={[3, 6]} overflow={'overlay'} height={'100%'}>
<RenderList
templates={templates}
......@@ -286,7 +286,7 @@ const RenderList = React.memo(function RenderList({
return (
<>
{templates.length > 0 ? (
<Grid gridTemplateColumns={gridStyle.gridTemplateColumns} rowGap={3} columnGap={3} mt={3}>
<Grid gridTemplateColumns={gridStyle.gridTemplateColumns} rowGap={3} columnGap={3} pt={3}>
{templates.map((template) => {
const selected = selectedTools.some((tool) => tool.pluginId === template.id);
......@@ -296,21 +296,21 @@ const RenderList = React.memo(function RenderList({
placement={'right'}
label={
<Box py={2}>
<Flex alignItems={'center'}>
<Flex alignItems={'center'} gap={3}>
<MyAvatar
src={template.avatar}
w={'1.75rem'}
objectFit={'contain'}
borderRadius={'sm'}
/>
<Box fontWeight={'bold'} ml={3} color={'myGray.900'} flex={'1'}>
<Box fontWeight={'bold'} color={'myGray.900'} flex={'1'}>
{template.name}
</Box>
<Box color={'myGray.500'}>
By {template.author || feConfigs?.systemTitle}
</Box>
</Flex>
<Box mt={2} color={'myGray.500'} maxH={'100px'} overflow={'hidden'}>
<Box pt={2} color={'myGray.500'} maxH={'100px'} overflow={'hidden'}>
{template.intro || t('common:core.workflow.Not intro')}
</Box>
<CostTooltip cost={template.currentCost} hasTokenFee={template.hasTokenFee} />
......@@ -318,6 +318,7 @@ const RenderList = React.memo(function RenderList({
}
>
<Flex
gap={3}
alignItems={'center'}
py={gridStyle.py}
px={3}
......@@ -332,7 +333,7 @@ const RenderList = React.memo(function RenderList({
borderRadius={'sm'}
flexShrink={0}
/>
<Box flex={'1 0 0'} ml={3}>
<Box flex={'1 0 0'}>
<Box
color={'myGray.900'}
fontWeight={'500'}
......
......@@ -75,9 +75,8 @@ const ChatSetting = () => {
<Flex flexDir="column" h="100%">
{!isPc && (
<>
<Flex borderBottom="sm" color="myGray.900" py={2} flexShrink="0">
<Flex borderBottom="sm" color="myGray.900" py={2} px={3} flexShrink="0">
<MyIcon
ml={3}
w="20px"
color="myGray.900"
name="core/chat/sidebar/menu"
......
......@@ -113,7 +113,7 @@ const List = ({ appType }: { appType: AppTypeEnum | 'all' }) => {
</HStack>
<Box
flex={['1 0 60px', '1 0 72px']}
mt={3}
pt={3}
pr={8}
textAlign={'justify'}
wordBreak={'break-all'}
......
......@@ -61,6 +61,7 @@ const AppTypeTag = ({ type }: { type: AppTypeEnum }) => {
<Flex
bg={'myGray.100'}
color={'myGray.600'}
gap={1}
py={0.5}
pl={2}
pr={3}
......@@ -68,7 +69,7 @@ const AppTypeTag = ({ type }: { type: AppTypeEnum }) => {
whiteSpace={'nowrap'}
>
<MyIcon name={data.icon as any} w={'0.8rem'} color={'myGray.500'} />
<Box ml={1} fontSize={'mini'}>
<Box fontSize={'mini'}>
{data.label}
</Box>
</Flex>
......
......@@ -70,6 +70,7 @@ const MyApps = ({ hideMobileHeader = false, mobileSearchKey }: MyAppsProps) => {
{!isPc && !hideMobileHeader && (
<Flex
py={4}
px={3}
color="myGray.900"
gap={2}
alignItems={'center'}
......@@ -77,7 +78,6 @@ const MyApps = ({ hideMobileHeader = false, mobileSearchKey }: MyAppsProps) => {
justifyContent={'space-between'}
>
<MyIcon
ml={3}
w="20px"
color="myGray.500"
name="core/chat/sidebar/menu"
......
......@@ -32,6 +32,7 @@ import ChatWindowHeader from './ChatWindowHeader';
import MyIcon from '@fastgpt/web/components/common/Icon';
import ToolMenu from '@/pageComponents/chat/ToolMenu';
import { mobileChatHeaderIconButtonStyle } from './headerIconButtonStyle';
import { useSandboxEditor, useSandboxStatus } from '@/pageComponents/chat/SandboxEditor/hook';
const CustomPluginRunBox = dynamic(() => import('@/pageComponents/chat/CustomPluginRunBox'));
......@@ -51,15 +52,24 @@ const AppChatWindow = () => {
const showSkillReferences = useContextSelector(ChatItemContext, (v) => v.showSkillReferences);
const onChangeChatId = useContextSelector(ChatContext, (v) => v.onChangeChatId);
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const isCurrentChatReady = chatBoxData.appId === appId && chatBoxData.chatId === chatId;
const chatWindowTitle =
chatBoxData.title?.trim() || t('common:core.chat.New Chat', { defaultValue: '新对话' });
isCurrentChatReady && chatBoxData.title?.trim()
? chatBoxData.title
: t('common:core.chat.New Chat', { defaultValue: '新对话' });
const datasetCiteData = useContextSelector(ChatItemContext, (v) => v.datasetCiteData);
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables);
const clearChatRecords = useContextSelector(ChatItemContext, (v) => v.clearChatRecords);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const isCurrentChatReady = chatBoxData.appId === appId && chatBoxData.chatId === chatId;
const { SandboxEntryIcon } = useSandboxStatus({ appId, chatId, outLinkAuthData });
const { SandboxEditorModal, onOpenSandboxModal } = useSandboxEditor({
appId,
chatId,
outLinkAuthData
});
const chatSettings = useContextSelector(ChatPageContext, (v) => v.chatSettings);
const pane = useContextSelector(ChatPageContext, (v) => v.pane);
......@@ -144,10 +154,14 @@ const AppChatWindow = () => {
const newTitle = getChatTitleFromChatMessage(GPTMessages2Chats({ messages: histories })[0]);
onUpdateHistoryTitle({ chatId, newTitle });
setChatBoxData((state) => ({
...state,
title: newTitle
}));
setChatBoxData((state) =>
state.appId === appId && state.chatId === chatId
? {
...state,
title: newTitle
}
: state
);
refreshRecentlyUsed();
......@@ -171,7 +185,10 @@ const AppChatWindow = () => {
return (
<Flex h={'100%'} flexDirection={['column', 'row']}>
{/* set window title and icon */}
<NextHead title={chatBoxData.app.name} icon={chatBoxData.app.avatar} />
<NextHead
title={isCurrentChatReady ? chatBoxData.app.name : undefined}
icon={isCurrentChatReady ? chatBoxData.app.avatar : undefined}
/>
{/* show history slider */}
{isPc ? (
......@@ -203,6 +220,7 @@ const AppChatWindow = () => {
title={chatWindowTitle}
history={chatRecords}
chatType={ChatTypeEnum.chat}
rightActions={<SandboxEntryIcon onOpen={onOpenSandboxModal} />}
/>
) : (
<Flex
......@@ -226,7 +244,9 @@ const AppChatWindow = () => {
fontSize="16px"
fontWeight={500}
color="myGray.900"
className="textEllipsis"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="clip"
>
{chatWindowTitle}
</Box>
......@@ -244,7 +264,10 @@ const AppChatWindow = () => {
appId={appId}
chatId={chatId}
outLinkAuthData={outLinkAuthData}
onNewChat={() => onChangeChatId(getNanoid())}
onNewChat={() => {
clearChatRecords();
onChangeChatId(getNanoid());
}}
onStartChat={onStartChat}
/>
) : (
......@@ -260,6 +283,7 @@ const AppChatWindow = () => {
/>
)}
</Box>
<SandboxEditorModal />
</Flex>
</Flex>
);
......
......@@ -53,17 +53,17 @@ const ModelOptionLabel = React.memo(function ModelOptionLabel({
noOfLines?: ResponsiveValue<number>;
}) {
return (
<Flex alignItems={'center'} flex={'1 1 0'} minW={0} overflow={'hidden'}>
<Flex alignItems={'center'} gap={1} flex={'1 1 0'} minW={0} overflow={'hidden'}>
<Box noOfLines={noOfLines ?? 1} flex={'1 1 0'} minW={0} overflow={'hidden'}>
{name}
</Box>
{showTestModeTip && (
<Box ml={1} flexShrink={0} pointerEvents={'auto'}>
<Box flexShrink={0} pointerEvents={'auto'}>
<TestModeBetaTag />
</Box>
)}
{showMultimodalTip && (
<Box ml={1} flexShrink={0} pointerEvents={'auto'}>
<Box flexShrink={0} pointerEvents={'auto'}>
<MultimodalTag />
</Box>
)}
......@@ -83,10 +83,9 @@ const SelectedModelLabel = React.memo(function SelectedModelLabel({
noOfLines?: ResponsiveValue<number>;
}) {
return (
<Flex alignItems={'center'} py={1} minW={0} overflow={'hidden'} w={'100%'}>
<Flex alignItems={'center'} gap={2} py={1} minW={0} overflow={'hidden'} w={'100%'}>
<Avatar
borderRadius={'0'}
mr={2}
src={avatar || HUGGING_FACE_ICON}
w={avatarSize}
fallbackSrc={HUGGING_FACE_ICON}
......@@ -164,10 +163,9 @@ const OneRowSelector = ({
return {
value: item.value,
label: (
<Flex alignItems={'center'} py={1} w={'100%'} minW={0}>
<Flex alignItems={'center'} gap={2} py={1} w={'100%'} minW={0}>
<Avatar
borderRadius={'0'}
mr={2}
src={avatar || HUGGING_FACE_ICON}
w={avatarSize}
fallbackSrc={HUGGING_FACE_ICON}
......@@ -289,10 +287,9 @@ const MultipleRowSelector = ({
children: { label: string | React.ReactNode; value: string }[];
}>((provider) => ({
label: (
<Flex alignItems={'center'} py={1}>
<Flex alignItems={'center'} gap={2} py={1}>
<Avatar
borderRadius={'0'}
mr={2}
src={provider?.avatar || HUGGING_FACE_ICON}
fallbackSrc={HUGGING_FACE_ICON}
w={avatarSize}
......
......@@ -59,7 +59,7 @@ const ChatVariableContent = ({
isUnChange={chatType === ChatTypeEnum.log}
/>
{showSubmitButton && (
<Flex justifyContent="flex-end" mt={8}>
<Flex justifyContent="flex-end" pt={8}>
<Button
w="69px"
h="32px"
......@@ -112,7 +112,7 @@ export const ChatVariableDrawer = ({
<Flex justifyContent="center" py="16px">
<Drawer.Handle style={{ backgroundColor: 'var(--chakra-colors-myGray-400)' }} />
</Flex>
<Flex alignItems="center" mb={4}>
<Flex alignItems="center" pb={4}>
<Box fontSize="16px" fontWeight={600} color="myGray.900">
{t('common:core.module.Variable')}
</Box>
......@@ -245,7 +245,7 @@ const ChatVariableButton = ({ chatType }: ChatVariableButtonProps) => {
maxH={popoverMaxHeight}
>
<Box bg="white" p="24px" h="100%" overflowY="auto" overflowX="hidden">
<Box fontSize="16px" lineHeight="24px" fontWeight={600} color="myGray.900" mb={6}>
<Box fontSize="16px" lineHeight="24px" fontWeight={600} color="myGray.900" pb={6}>
{label}
</Box>
<ChatVariableContent
......
......@@ -8,11 +8,13 @@ import type { ChatTypeEnum } from '@/components/core/chat/ChatContainer/ChatBox/
const ChatWindowHeader = ({
title,
history,
chatType
chatType,
rightActions
}: {
title?: string;
history: ChatItemMiniType[];
chatType: ChatTypeEnum;
rightActions?: React.ReactNode;
}) => {
const hasHistory = history.length > 0;
......@@ -31,6 +33,7 @@ const ChatWindowHeader = ({
<Flex position="absolute" right={5} alignItems="center" gap={2}>
<ChatVariableButton chatType={chatType} />
{hasHistory && <MarkdownExportButton history={history} />}
{rightActions}
</Flex>
</Flex>
);
......
......@@ -52,6 +52,7 @@ import ChatWindowHeader from './ChatWindowHeader';
import ToolMenu from '@/pageComponents/chat/ToolMenu';
import MobileModelSelectorDrawer from './MobileModelSelectorDrawer';
import { mobileChatHeaderIconButtonStyle } from './headerIconButtonStyle';
import { useSandboxEditor, useSandboxStatus } from '@/pageComponents/chat/SandboxEditor/hook';
const defaultFileSelectConfig: AppFileSelectConfigType = {
maxFiles: 20,
......@@ -100,6 +101,12 @@ const HomeChatWindow = () => {
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const isCurrentChatReady = chatBoxData.appId === appId && chatBoxData.chatId === chatId;
const { SandboxEntryIcon } = useSandboxStatus({ appId, chatId, outLinkAuthData });
const { SandboxEditorModal, onOpenSandboxModal } = useSandboxEditor({
appId,
chatId,
outLinkAuthData
});
const availableModels = useMemo(
() => llmModelList.map((model) => ({ value: model.model, label: model.name })),
......@@ -274,10 +281,14 @@ const HomeChatWindow = () => {
const newTitle = getChatTitleFromChatMessage(GPTMessages2Chats({ messages: histories })[0]);
onUpdateHistoryTitle({ chatId, newTitle });
setChatBoxData((state) => ({
...state,
title: newTitle
}));
setChatBoxData((state) =>
state.appId === appId && state.chatId === chatId
? {
...state,
title: newTitle
}
: state
);
refreshRecentlyUsed();
......@@ -359,10 +370,12 @@ const HomeChatWindow = () => {
_notLast={{ mb: 1 }}
borderRadius={'md'}
>
<Checkbox size={'sm'} isChecked={isSelected} mr={3} />
<Flex alignItems="center" gap={2}>
<Avatar src={tool.avatar} w={5} borderRadius="xs" />
<Box fontSize="sm">{tool.name}</Box>
<Flex alignItems="center" gap={3} minW={0}>
<Checkbox size={'sm'} isChecked={isSelected} />
<Flex alignItems="center" gap={2} minW={0}>
<Avatar src={tool.avatar} w={5} borderRadius="xs" />
<Box fontSize="sm">{tool.name}</Box>
</Flex>
</Flex>
</MenuItem>
);
......@@ -423,6 +436,7 @@ const HomeChatWindow = () => {
title={chatBoxData?.title}
history={chatRecords}
chatType={ChatTypeEnum.home}
rightActions={<SandboxEntryIcon onOpen={onOpenSandboxModal} />}
/>
) : (
<Flex
......@@ -440,7 +454,7 @@ const HomeChatWindow = () => {
{...mobileChatHeaderIconButtonStyle}
onClick={onOpenSlider}
/>
<Flex alignItems="center" minW={0} onClick={onOpenModelDrawer}>
<Flex alignItems="center" gap={1} minW={0} onClick={onOpenModelDrawer}>
<Box
fontSize="16px"
fontWeight={500}
......@@ -450,7 +464,7 @@ const HomeChatWindow = () => {
>
{selectedModelData?.name || selectedModel}
</Box>
<MyIcon name="core/chat/chevronDown" w="16px" h="16px" color="myGray.500" ml={1} />
<MyIcon name="core/chat/chevronDown" w="16px" h="16px" color="myGray.500" />
</Flex>
<Box minW="36px">
<ToolMenu history={chatRecords} chatType={ChatTypeEnum.home} />
......@@ -485,6 +499,7 @@ const HomeChatWindow = () => {
onSwitchQuickApp={handleSwitchQuickApp}
/>
</Box>
<SandboxEditorModal />
</Flex>
</Flex>
);
......
......@@ -104,6 +104,7 @@ const MobileModelSelectorDrawer = ({ isOpen, modelList, value, onChange, onClose
key={provider.id}
h="44px"
alignItems="center"
gap="4px"
px={2}
borderRadius="6px"
onClick={() => setActiveProviderId(provider.id)}
......@@ -113,7 +114,6 @@ const MobileModelSelectorDrawer = ({ isOpen, modelList, value, onChange, onClose
fallbackSrc={HUGGING_FACE_ICON}
w="24px"
borderRadius="0"
mr="4px"
/>
<Box flex="1" fontSize="16px" color="myGray.900">
{provider.name}
......
......@@ -25,7 +25,7 @@ const CustomPluginRunBox = (props: PluginRunBoxProps) => {
return isPc ? (
<Grid gridTemplateColumns={'450px 1fr'} h={'100%'}>
<Box px={3} py={4} borderRight={'base'} h={'100%'} overflowY={'auto'} w={'100%'}>
<Box color={'myGray.900'} mb={5}>
<Box color={'myGray.900'} pb={5}>
{t('common:Input')}
</Box>
<PluginRunBox {...props} showTab={PluginRunBoxTabEnum.input} />
......
......@@ -142,7 +142,16 @@ const EditorContent = ({
if (language === 'markdown') {
const { metadata, bodyContent, hasMetadata } = parseMarkdownFrontmatter(content);
return (
<Box h="full" overflowY="auto" bg="white" px={4} py={4}>
<Box
h="full"
overflowY="auto"
bg="white"
px={4}
py={4}
display="flex"
flexDir="column"
gap={6}
>
{hasMetadata && <MarkdownMetadataCard metadata={metadata} />}
<Markdown source={bodyContent} />
</Box>
......
......@@ -78,7 +78,6 @@ const renderValue = (val: any) => {
const MarkdownMetadataCard = ({ metadata }: Props) => {
return (
<Box
mb={6}
p={4}
bg="myGray.25"
borderRadius="6px"
......
......@@ -242,7 +242,22 @@ export const useSandboxStatus = ({
<IconButton
variant={'whiteBase'}
size={'smSquare'}
icon={<MyIcon name={'core/app/sandbox/file'} w={'16px'} />}
color={'myGray.600'}
_hover={{
color: 'primary.600'
}}
icon={
<MyIcon
name={'core/chat/monitor'}
w={'16px'}
color={'currentColor'}
sx={{
'& path': {
fill: 'currentColor'
}
}}
/>
}
onClick={onOpen}
{...props}
aria-label={t('chat:sandbox_entry_tooltip')}
......
......@@ -35,6 +35,7 @@ const ToolMenu = ({
const onChangeChatId = useContextSelector(ChatContext, (v) => v.onChangeChatId);
const chatData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const clearChatRecords = useContextSelector(ChatItemContext, (v) => v.clearChatRecords);
const variables = useContextSelector(
ChatItemContext,
(v) => v.chatBoxData?.app?.chatConfig?.variables ?? []
......@@ -108,6 +109,7 @@ const ToolMenu = ({
icon: 'core/chat/chatLight',
label: t('common:core.chat.New Chat'),
onClick: () => {
clearChatRecords();
onChangeChatId();
setSandboxExists(false);
}
......@@ -138,8 +140,8 @@ const ToolMenu = ({
...(!isPc && sandboxExists
? [
{
icon: 'core/app/sandbox/file' as const,
label: t('chat:sandox.files'),
icon: 'core/chat/monitor' as const,
label: t('app:use_agent_sandbox'),
onClick: () => onOpenSandboxModal()
}
]
......
......@@ -21,7 +21,7 @@ type Props = {
const ChatSliderHeader = ({ title, banner }: Props) => {
const { t } = useTranslation();
const { isPc } = useSystem();
const { setChatId } = useChatStore();
const { appId: activeAppId, setChatId } = useChatStore();
const pane = useContextSelector(ChatPageContext, (v) => v.pane);
const handlePaneChange = useContextSelector(ChatPageContext, (v) => v.handlePaneChange);
......@@ -31,15 +31,16 @@ const ChatSliderHeader = ({ title, banner }: Props) => {
const appName = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.app.name);
const appAvatar = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.app.avatar);
const currentAppId = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.appId);
const isCurrentAppReady = currentAppId === activeAppId;
const onCloseSlider = useContextSelector(ChatContext, (v) => v.onCloseSlider);
const isHomePane = pane === ChatSidebarPaneEnum.HOME && currentAppId === homeAppId;
const isHomePane = pane === ChatSidebarPaneEnum.HOME && activeAppId === homeAppId;
const isAllAppsPane = pane === ChatSidebarPaneEnum.ALL_APPS;
return isPc ? (
<Flex py={4} px={[2, 2]} gap={2} alignItems={'center'} fontSize={'sm'}>
{!title && <Avatar src={appAvatar} borderRadius={'md'} />}
{!title && <Avatar src={isCurrentAppReady ? appAvatar : undefined} borderRadius={'md'} />}
<Box
flex={'1 0 0'}
......@@ -49,7 +50,7 @@ const ChatSliderHeader = ({ title, banner }: Props) => {
color={title ? 'myGray.900' : 'inherit'}
className={'textEllipsis'}
>
{title || appName}
{title || (isCurrentAppReady ? appName : '')}
</Box>
</Flex>
) : (
......
......@@ -27,6 +27,7 @@ const ChatSliderList = () => {
const onChangeChatId = useContextSelector(ChatContext, (v) => v.onChangeChatId);
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
const clearChatRecords = useContextSelector(ChatItemContext, (v) => v.clearChatRecords);
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const concatHistory = useMemo(() => {
......@@ -244,6 +245,7 @@ const ChatSliderList = () => {
onClick: () => {
onDelHistory(item.id);
if (item.id === activeChatId) {
clearChatRecords();
onChangeChatId();
setCiteModalData(undefined);
}
......
......@@ -88,11 +88,11 @@ const MobileClearHistoryConfirm = ({
</Box>
</HStack>
<Box mt="24px" fontSize="14px" lineHeight="22px" color="myGray.900">
<Box pt="24px" fontSize="14px" lineHeight="22px" color="myGray.900">
{t('chat:mobile_clear_history_confirm_tip')}
</Box>
<HStack mt="24px" justifyContent="flex-end" spacing="12px">
<HStack pt="24px" justifyContent="flex-end" spacing="12px">
<Button
minH="32px"
px="14px"
......@@ -133,6 +133,7 @@ const ChatSliderMenu = ({ menuConfirmButtonText }: Props) => {
const onChangeChatId = useContextSelector(ChatContext, (v) => v.onChangeChatId);
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
const clearChatRecords = useContextSelector(ChatItemContext, (v) => v.clearChatRecords);
const ClearHistoryTrigger = (
<Box h={'100%'}>
......@@ -163,8 +164,6 @@ const ChatSliderMenu = ({ menuConfirmButtonText }: Props) => {
px={0}
minH={'36px'}
pb={['12px', 0]}
mt={isPc ? 2 : 0}
mb={isPc ? 3 : 0}
justify={['space-between', '']}
alignItems={'center'}
gap={isPc ? 2 : 0}
......@@ -200,6 +199,7 @@ const ChatSliderMenu = ({ menuConfirmButtonText }: Props) => {
}
overflow={'hidden'}
onClick={() => {
clearChatRecords();
onChangeChatId();
setCiteModalData(undefined);
}}
......@@ -210,7 +210,10 @@ const ChatSliderMenu = ({ menuConfirmButtonText }: Props) => {
histories.length > 0 && (
<MobileClearHistoryConfirm
Trigger={ClearHistoryTrigger}
onConfirm={() => onClearHistory()}
onConfirm={() => {
clearChatRecords();
return onClearHistory();
}}
/>
)
)}
......@@ -220,7 +223,10 @@ const ChatSliderMenu = ({ menuConfirmButtonText }: Props) => {
Trigger={ClearHistoryTrigger}
type="delete"
content={menuConfirmButtonText || t('common:Delete')}
onConfirm={() => onClearHistory()}
onConfirm={() => {
clearChatRecords();
return onClearHistory();
}}
/>
)}
</Flex>
......
import { Drawer, DrawerOverlay, DrawerContent, useTheme } from '@chakra-ui/react';
import { Box, Drawer, DrawerOverlay, DrawerContent, useTheme } from '@chakra-ui/react';
import React from 'react';
import MyBox from '@fastgpt/web/components/common/MyBox';
import ChatSliderHeader from '@/pageComponents/chat/slider/ChatSliderHeader';
......@@ -63,7 +63,9 @@ const ChatSliderMobileDrawer = ({
{showHeader && <ChatSliderHeader title={title} banner={banner} />}
{showMenu && (
<MyDivider h="0.5px" bg="myGray.100" my="16px" mx={2} w="calc(100% - 16px)" />
<Box px={2} py="16px">
<MyDivider h="0.5px" bg="myGray.100" />
</Box>
)}
{showMenu && <ChatSliderMenu menuConfirmButtonText={menuConfirmButtonText} />}
......
......@@ -13,6 +13,7 @@ const ChatSliderMobileNewChatButton = () => {
const { t } = useTranslation();
const onChangeChatId = useContextSelector(ChatContext, (v) => v.onChangeChatId);
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
const clearChatRecords = useContextSelector(ChatItemContext, (v) => v.clearChatRecords);
return (
<Button
......@@ -31,6 +32,7 @@ const ChatSliderMobileNewChatButton = () => {
_active={{ bg: '#3370FF' }}
leftIcon={<MyIcon name="core/chat/chatLight" w="16px" h="16px" color="white" fill="white" />}
onClick={() => {
clearChatRecords();
onChangeChatId();
setCiteModalData(undefined);
}}
......
......@@ -24,6 +24,7 @@ const ChatHistorySidebar = ({ title, banner, menuConfirmButtonText, footerSlot }
w={'100%'}
h={'100%'}
px={4}
gap={[0, 3]}
bg={'white'}
borderRight={['', theme.borders.base]}
borderRightColor={['', 'myGray.200']}
......
......@@ -272,6 +272,7 @@ const NavigationSection = () => {
(v) => v.pane === ChatSidebarPaneEnum.ALL_APPS
);
const handlePaneChange = useContextSelector(ChatPageContext, (v) => v.handlePaneChange);
const showHome = feConfigs.isPlus && isEnableHome;
return (
<Flex mt={4} flexDirection={'column'} gap={'12px'} px={4}>
......@@ -283,51 +284,43 @@ const NavigationSection = () => {
{isCollapsed ? (
<AnimatedSection show={true}>
<Flex flexDir="column" gap={0}>
{feConfigs.isPlus && (
<>
{isEnableHome && (
<ActionButton
icon="core/chat/sidebar/home"
isCollapsed={true}
isActive={isHomeActive}
onClick={() => handlePaneChange(ChatSidebarPaneEnum.HOME)}
/>
)}
<ActionButton
icon="common/app"
isCollapsed={true}
isActive={isAllAppsActive}
onClick={() => handlePaneChange(ChatSidebarPaneEnum.ALL_APPS)}
/>
</>
{showHome && (
<ActionButton
icon="core/chat/sidebar/home"
isCollapsed={true}
isActive={isHomeActive}
onClick={() => handlePaneChange(ChatSidebarPaneEnum.HOME)}
/>
)}
<ActionButton
icon="common/app"
isCollapsed={true}
isActive={isAllAppsActive}
onClick={() => handlePaneChange(ChatSidebarPaneEnum.ALL_APPS)}
/>
</Flex>
</AnimatedSection>
) : (
<AnimatedSection show={true}>
<Flex flexDir="column" gap={0}>
{feConfigs.isPlus && (
<>
{isEnableHome && (
<ActionButton
icon="core/chat/sidebar/home"
text={t('chat:sidebar.home')}
isCollapsed={false}
isActive={isHomeActive}
onClick={() => handlePaneChange(ChatSidebarPaneEnum.HOME)}
/>
)}
<ActionButton
icon="common/app"
text={t('chat:sidebar.all_apps')}
isCollapsed={false}
isActive={isAllAppsActive}
onClick={() => handlePaneChange(ChatSidebarPaneEnum.ALL_APPS)}
/>
</>
{showHome && (
<ActionButton
icon="core/chat/sidebar/home"
text={t('chat:sidebar.home')}
isCollapsed={false}
isActive={isHomeActive}
onClick={() => handlePaneChange(ChatSidebarPaneEnum.HOME)}
/>
)}
<ActionButton
icon="common/app"
text={t('chat:sidebar.all_apps')}
isCollapsed={false}
isActive={isAllAppsActive}
onClick={() => handlePaneChange(ChatSidebarPaneEnum.ALL_APPS)}
/>
</Flex>
</AnimatedSection>
)}
......@@ -504,9 +497,11 @@ const ChatSlider = ({ activeAppId }: Props) => {
{/* recently used apps */}
<AnimatedSection show={!isCollapsed} display={'flex'} flexDir={'column'} flex={'1 0 0'}>
<MyDivider h={1} my={1} mx="16px" w="calc(100% - 32px)" />
<Box px="16px" py={1}>
<MyDivider h={1} />
</Box>
<HStack px={3} my={2} color={'myGray.500'} fontSize={'sm'} justifyContent={'space-between'}>
<HStack px={3} py={2} color={'myGray.500'} fontSize={'sm'} justifyContent={'space-between'}>
<Box
whiteSpace={'nowrap'}
overflow={'hidden'}
......@@ -518,7 +513,16 @@ const ChatSlider = ({ activeAppId }: Props) => {
</Box>
</HStack>
<MyBox flex={'1 0 0'} h={0} overflow={'overlay'} px={4} position={'relative'}>
<MyBox
flex={'1 0 0'}
h={0}
overflow={'overlay'}
px={4}
position={'relative'}
display={'flex'}
flexDirection={'column'}
gap={'12px'}
>
{myApps.map((item) => (
<Flex
key={item.appId}
......@@ -526,7 +530,6 @@ const ChatSlider = ({ activeAppId }: Props) => {
gap={2}
h={'44px'}
minH={'44px'}
mb={'12px'}
cursor={'pointer'}
borderRadius={'md'}
alignItems={'center'}
......
......@@ -4,6 +4,8 @@ import { getS3ChatSource } from '@fastgpt/service/common/s3/sources/chat';
import { authChatCrud } from '@/service/support/permission/auth/chat';
import { PresignChatFileGetUrlSchema } from '@fastgpt/global/openapi/core/chat/file/api';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { isAuthorizedChatFileS3Key } from '@fastgpt/service/common/s3/utils';
import { ChatErrEnum } from '@fastgpt/global/common/error/code/chat';
async function handler(req: ApiRequestProps): Promise<string> {
const { key, appId, mode, outLinkAuthData } = parseApiInput({
......@@ -11,7 +13,7 @@ async function handler(req: ApiRequestProps): Promise<string> {
bodySchema: PresignChatFileGetUrlSchema
}).body;
await authChatCrud({
const authRes = await authChatCrud({
req,
authToken: true,
authApiKey: true,
......@@ -19,6 +21,10 @@ async function handler(req: ApiRequestProps): Promise<string> {
...outLinkAuthData
});
if (!isAuthorizedChatFileS3Key({ key, appId, uid: authRes.uid })) {
return Promise.reject(ChatErrEnum.unAuthChat);
}
const { url } = await getS3ChatSource().createGetChatFileURL({ key, external: true, mode });
return url;
......
......@@ -4,6 +4,7 @@ import type { GetHelperBotFilePreviewParamsType } from '@fastgpt/global/openapi/
import { authCert } from '@fastgpt/service/support/permission/auth/common';
import { getS3HelperBotSource } from '@fastgpt/service/common/s3/sources/helperbot';
import { ChatErrEnum } from '@fastgpt/global/common/error/code/chat';
import { isAuthorizedHelperBotFileS3Key } from '@fastgpt/service/common/s3/utils';
async function handler(req: ApiRequestProps<GetHelperBotFilePreviewParamsType>): Promise<string> {
const { key, mode } = req.body;
......@@ -12,9 +13,7 @@ async function handler(req: ApiRequestProps<GetHelperBotFilePreviewParamsType>):
authToken: true
});
const { type, chatId, userId: uid, filename } = getS3HelperBotSource().parseKey(key);
if (userId !== uid) {
if (!isAuthorizedHelperBotFileS3Key({ key, userId })) {
return Promise.reject(ChatErrEnum.unAuthChat);
}
......
......@@ -5,43 +5,45 @@ import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { NextAPI } from '@/service/middleware/entry';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
import { authChatCrud } from '@/service/support/permission/auth/chat';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
/* clear all chat histories of an app */
export async function handler(req: ApiRequestProps, res: NextApiResponse) {
const { appId, shareId, outLinkUid, teamId, teamToken } = ClearChatHistoriesSchema.parse(
req.query
);
const { query } = parseApiInput({ req, querySchema: ClearChatHistoriesSchema });
const { appId, shareId, outLinkUid, teamId, teamToken } = query;
const { tmbId, uid, authType } = await authChatCrud({
const { appId: authAppId, tmbId, uid, authType } = await authChatCrud({
req,
authToken: true,
authApiKey: true,
...req.query
...query
});
const matchAppId = appId || authAppId;
if (!matchAppId) return Promise.reject('Param are error');
const match = await (async () => {
if (shareId && outLinkUid && authType === 'outLink') {
return {
appId,
appId: matchAppId,
outLinkUid: uid
};
}
if (teamId && teamToken && authType === 'teamDomain') {
return {
appId,
appId: matchAppId,
outLinkUid: uid
};
}
if (authType === 'token') {
return {
appId,
appId: matchAppId,
tmbId,
source: ChatSourceEnum.online
};
}
if (authType === 'apikey') {
return {
appId,
appId: matchAppId,
source: ChatSourceEnum.api
};
}
......@@ -54,7 +56,7 @@ export async function handler(req: ApiRequestProps, res: NextApiResponse) {
await MongoChat.updateMany(
{
appId,
...match,
chatId: { $in: list.map((item) => item.chatId) }
},
{
......
......@@ -5,22 +5,29 @@ import { authChatCrud } from '@/service/support/permission/auth/chat';
import { NextAPI } from '@/service/middleware/entry';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant';
/* delete single chat history (soft delete) */
export async function handler(req: ApiRequestProps, res: NextApiResponse) {
const { appId, chatId } = parseApiInput({ req, querySchema: DelChatHistorySchema }).query;
const { query } = parseApiInput({ req, querySchema: DelChatHistorySchema });
const { appId, chatId } = query;
await authChatCrud({
...req.query,
const { appId: authAppId, authType, uid } = await authChatCrud({
...query,
req,
authToken: true,
authApiKey: true
});
const matchAppId = appId || authAppId;
if (!matchAppId) return Promise.reject('Param are error');
await MongoChat.updateOne(
{
appId,
chatId
appId: matchAppId,
chatId,
...(authType === AuthUserTypeEnum.outLink || authType === AuthUserTypeEnum.teamDomain
? { outLinkUid: uid }
: {})
},
{
$set: {
......
......@@ -2,12 +2,10 @@ import { DatasetSourceReadTypeEnum } from '@fastgpt/global/core/dataset/constant
import { rawText2Chunks, readDatasetSourceRawText } from '@fastgpt/service/core/dataset/read';
import { NextAPI } from '@/service/middleware/entry';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import {
OwnerPermissionVal,
WritePermissionVal
} from '@fastgpt/global/support/permission/constant';
import { authCollectionFile } from '@fastgpt/service/support/permission/auth/file';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { authDatasetFileKey } from '@fastgpt/service/support/permission/auth/file';
import { authDataset } from '@fastgpt/service/support/permission/dataset/auth';
import { isAuthorizedDatasetFileS3Key } from '@fastgpt/service/common/s3/utils';
import {
computedCollectionChunkSettings,
getLLMMaxChunkSize
......@@ -42,14 +40,21 @@ async function handler(
throw new Error('sourceId is empty');
}
if (
type === DatasetSourceReadTypeEnum.fileLocal &&
!isAuthorizedDatasetFileS3Key({ key: sourceId, datasetId })
) {
return Promise.reject(CommonErrEnum.unAuthFile);
}
const fileAuthRes =
type === DatasetSourceReadTypeEnum.fileLocal
? await authCollectionFile({
? await authDatasetFileKey({
req,
authToken: true,
authApiKey: true,
fileId: sourceId,
per: OwnerPermissionVal
per: WritePermissionVal
})
: undefined;
......
......@@ -4,7 +4,10 @@ import { authDataset } from '@fastgpt/service/support/permission/dataset/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { addHours } from 'date-fns';
import { S3Buckets } from '@fastgpt/service/common/s3/config/constants';
import { isS3ObjectKey, jwtSignS3DownloadToken } from '@fastgpt/service/common/s3/utils';
import {
isAuthorizedTempFileS3Key,
jwtSignS3DownloadToken
} from '@fastgpt/service/common/s3/utils';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import {
GetSearchTestImagePreviewUrlsBodySchema,
......@@ -29,7 +32,7 @@ async function handler(
});
const result = keys
.filter((key) => isS3ObjectKey(key, 'temp') && key.startsWith(`temp/${teamId}/`))
.filter((key) => isAuthorizedTempFileS3Key({ key, teamId }))
.map((key) => ({
key,
previewUrl: jwtSignS3DownloadToken({
......
......@@ -12,7 +12,7 @@ import { getRerankModel } from '@fastgpt/service/core/ai/model';
import { addAuditLog } from '@fastgpt/service/support/user/audit/util';
import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants';
import { getI18nDatasetType } from '@fastgpt/service/support/user/audit/util';
import { isS3ObjectKey } from '@fastgpt/service/common/s3/utils';
import { isAuthorizedTempFileS3Key } from '@fastgpt/service/common/s3/utils';
import { getS3DatasetSource } from '@fastgpt/service/common/s3/sources/dataset';
import {
SearchDatasetTestBodySchema,
......@@ -63,8 +63,8 @@ export async function handler(
// Search-test images must be temp objects created by this team. Client-supplied keys are not
// proof of ownership, so reject dataset/chat/foreign-team keys before any S3 read happens.
const validQueryImageKeys = queryImageUrls.filter(
(key) => isS3ObjectKey(key, 'temp') && key.startsWith(`temp/${teamId}/`)
const validQueryImageKeys = queryImageUrls.filter((key) =>
isAuthorizedTempFileS3Key({ key, teamId })
);
if (validQueryImageKeys.length !== queryImageUrls.length) {
......
......@@ -146,14 +146,20 @@ const ChatLogin = ({ onSuccess }: { onSuccess: (res: LoginSuccessResponseType) =
const ChatContent = (props: ChatPageProps) => {
const { appId: pageAppId, isStandalone } = props;
const { appId: storeAppId, chatId } = useChatStore();
const { appId: storeAppId, chatId, source } = useChatStore();
const { setUserInfo } = useUserStore();
const isInitedUser = useContextSelector(ChatPageContext, (v) => v.isInitedUser);
const userInfo = useContextSelector(ChatPageContext, (v) => v.userInfo);
// 优先使用 store 中的 appId:handlePaneChange 会同步写入,比 page props 更早与 chatId 对齐
const currentAppId = storeAppId || pageAppId;
// 首次入口若还停在 detail/share 等其它 source,以页面入口 appId 为准等待 store 归位;站内切换时 store 会先更新,用 store 保持无感切换。
const entryAppId = pageAppId;
const currentAppId =
source === ChatSourceEnum.online ? storeAppId || entryAppId : entryAppId || storeAppId;
const currentChatId =
source === ChatSourceEnum.online && storeAppId === currentAppId ? chatId : '';
const isChatStoreReady =
source === ChatSourceEnum.online && (!currentAppId || storeAppId === currentAppId);
const chatHistoryProviderParams = useMemo(
() => ({ appId: currentAppId, source: ChatSourceEnum.online }),
......@@ -164,10 +170,9 @@ const ChatContent = (props: ChatPageProps) => {
return {
appId: currentAppId,
type: GetChatTypeEnum.normal,
chatId
chatId: currentChatId
};
}, [currentAppId, chatId]);
}, [currentAppId, currentChatId]);
const loginSuccess = useCallback(
async (res: LoginSuccessResponseType) => {
setUserInfo(res.user);
......@@ -189,6 +194,14 @@ const ChatContent = (props: ChatPageProps) => {
return <ChatLogin onSuccess={loginSuccess} />;
}
if (!isChatStoreReady) {
return (
<PageContainer isLoading flex={'1'} p={4}>
<NextHead />
</PageContainer>
);
}
// show main chat interface
return (
<ChatContextProvider params={chatHistoryProviderParams}>
......
......@@ -102,6 +102,7 @@ const OutLink = (props: Props) => {
const onCloseSlider = useContextSelector(ChatContext, (v) => v.onCloseSlider);
const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables);
const clearChatRecords = useContextSelector(ChatItemContext, (v) => v.clearChatRecords);
const isPlugin = useContextSelector(ChatItemContext, (v) => v.isPlugin);
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
......@@ -376,7 +377,10 @@ const OutLink = (props: Props) => {
appId={appId}
chatId={chatId}
outLinkAuthData={outLinkAuthData}
onNewChat={() => onChangeChatId(getNanoid())}
onNewChat={() => {
clearChatRecords();
onChangeChatId(getNanoid());
}}
onStartChat={startChat}
/>
) : (
......
......@@ -34,7 +34,7 @@ export const defaultResponseShow = {
canDownloadSource: true
};
type AuthChatCommonProps = {
appId: string;
appId?: string;
shareId?: string;
outLinkUid?: string;
teamId?: string;
......@@ -54,7 +54,8 @@ export async function authChatCrud({
}: AuthModeType &
AuthChatCommonProps & {
chatId?: string;
}): Promise<{
}): Promise<{
appId?: string;
teamId: string;
tmbId: string; // 本轮鉴权的 uid
uid: string; // chat 里的实际的 uid(outlinkUid??tmbId)
......@@ -66,9 +67,9 @@ export async function authChatCrud({
canDownloadSource: boolean;
authType?: `${AuthUserTypeEnum}`;
}> {
if (!appId) return Promise.reject(ChatErrEnum.unAuthChat);
if (spaceTeamId && teamToken) {
if (!appId) return Promise.reject(ChatErrEnum.unAuthChat);
const { uid, tmbId, tags } = await authTeamSpaceToken({
teamId: spaceTeamId,
teamToken
......@@ -132,10 +133,12 @@ export async function authChatCrud({
appId: shareChatAppId
} = await authOutLink({ shareId, outLinkUid });
if (String(shareChatAppId) !== appId) return Promise.reject(ChatErrEnum.unAuthChat);
const resolvedAppId = String(shareChatAppId);
if (appId && resolvedAppId !== appId) return Promise.reject(ChatErrEnum.unAuthChat);
if (!chatId) {
return {
appId: resolvedAppId,
teamId: String(outLinkConfig.teamId),
tmbId: String(outLinkConfig.tmbId),
uid,
......@@ -149,10 +152,11 @@ export async function authChatCrud({
};
}
const chat = await MongoChat.findOne({ appId, chatId }).lean();
const chat = await MongoChat.findOne({ appId: resolvedAppId, chatId }).lean();
if (!chat) {
return {
appId: resolvedAppId,
teamId: String(outLinkConfig.teamId),
tmbId: String(outLinkConfig.tmbId),
uid,
......@@ -168,6 +172,7 @@ export async function authChatCrud({
return {
teamId: String(outLinkConfig.teamId),
tmbId: String(outLinkConfig.tmbId),
appId: resolvedAppId,
chat,
uid,
showCite: outLinkConfig.showCite ?? false,
......@@ -180,6 +185,8 @@ export async function authChatCrud({
}
// Cookie
if (!appId) return Promise.reject(ChatErrEnum.unAuthChat);
const { teamId, tmbId, permission, authType } = await authApp({
req: props.req,
authToken: true,
......
......@@ -197,7 +197,7 @@ const ChatContextProvider = ({
const { runAsync: onDelHistory, loading: isDeletingHistory } = useRequest(
(chatId: string) =>
delChatHistoryById({
appId: historyAppId,
...(historyAppId ? { appId: historyAppId } : {}),
chatId,
...outLinkAuthData
}),
......@@ -213,7 +213,7 @@ const ChatContextProvider = ({
const { runAsync: onClearHistories, loading: isClearingHistory } = useRequest(
() =>
delClearChatHistories({
appId: historyAppId,
...(historyAppId ? { appId: historyAppId } : {}),
...outLinkAuthData
}),
{
......@@ -280,7 +280,8 @@ const ChatContextProvider = ({
}
if (scopedHistories.length > 0) {
onChangeChatId(scopedHistories[0].chatId, true);
// 跨应用恢复历史时必须重新拉 init,否则 chatBoxData 会停留在上一个应用。
onChangeChatId(scopedHistories[0].chatId);
}
}, [historyAppId, histories, isPaginationLoading, onChangeChatId]);
......
......@@ -190,30 +190,32 @@ const ChatItemContextProvider = ({
[variablesForm]
);
const [datasetCiteData, setCiteModalData] = useState<QuoteDataType>();
const resetUIState = useCallback(() => {
setCiteModalData(undefined);
setIsVariableVisible(true);
setPluginRunTab(PluginRunBoxTabEnum.input);
}, []);
const clearChatRecords = useCallback(() => {
const variables = chatBoxData?.app?.chatConfig?.variables || [];
const values = variablesForm.getValues();
const nextVariables: Record<string, any> = {};
variables.forEach((item) => {
if (item.defaultValue !== undefined) {
values.variables[item.key] = item.defaultValue;
} else {
values.variables[item.key] = '';
}
nextVariables[item.key] = item.defaultValue ?? '';
});
variablesForm.reset({
...values,
variables: nextVariables,
chatStarted: false
});
variablesForm.reset(values);
resetUIState();
ChatBoxRef.current?.restartChat?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatBoxData?.app?.chatConfig?.variables]);
const [datasetCiteData, setCiteModalData] = useState<QuoteDataType>();
const resetUIState = useCallback(() => {
setCiteModalData(undefined);
setIsVariableVisible(true);
setPluginRunTab(PluginRunBoxTabEnum.input);
}, []);
}, [chatBoxData?.app?.chatConfig?.variables, resetUIState]);
const contextValue = useMemo(() => {
return {
......
......@@ -70,7 +70,14 @@ export const ChatPageContextProvider = ({
}) => {
const router = useRouter();
const { feConfigs } = useSystemStore();
const { setSource, setAppId, setLastPane, setLastChatAppId, lastPane } = useChatStore();
const {
appId: activeAppId,
setSource,
setAppId,
setLastPane,
setLastChatAppId,
lastPane
} = useChatStore();
const { userInfo } = useUserStore();
const { pane = lastPane || ChatSidebarPaneEnum.HOME } = router.query as {
......@@ -80,6 +87,8 @@ export const ChatPageContextProvider = ({
const [collapse, setCollapse] = useState<CollapseStatusType>(defaultCollapseStatus);
const [recentlyUsedAppPlaceholders, setRecentlyUsedAppPlaceholders] =
useState<GetRecentlyUsedAppsResponseType>([]);
// Home App 是门户页背后的隐藏应用;缓存它的 id,避免移动/桌面切换时被最近使用列表短暂暴露。
const [cachedHomeAppId, setCachedHomeAppId] = useState('');
// Get recently used apps
const { data: myApps = [], refresh: refreshRecentlyUsed } = useRequest(
......@@ -96,8 +105,8 @@ export const ChatPageContextProvider = ({
// Initialize chat page state
useMount(async () => {
if (routeAppId) setAppId(routeAppId);
setSource('online');
if (routeAppId) setAppId(routeAppId);
});
// Sync appId to store as route/appId changes
......@@ -135,7 +144,24 @@ export const ChatPageContextProvider = ({
}
);
const homeAppId = chatSettings?.appId;
const homeAppId =
chatSettings?.appId ||
cachedHomeAppId ||
(feConfigs.isPlus && pane === ChatSidebarPaneEnum.HOME ? activeAppId : '');
useEffect(() => {
if (!feConfigs.isPlus) {
setCachedHomeAppId('');
return;
}
const nextHomeAppId = chatSettings?.appId;
if (!nextHomeAppId) return;
setCachedHomeAppId((current) => (current === nextHomeAppId ? current : nextHomeAppId));
}, [chatSettings?.appId, feConfigs.isPlus]);
const upsertRecentlyUsedAppPlaceholder = useCallback(
(app: RecentlyUsedAppPlaceholderInput) => {
const { appId, name, avatar } = app;
......@@ -186,14 +212,18 @@ export const ChatPageContextProvider = ({
setAppId(_id);
}
await router.replace({
query: {
...router.query,
appId: _id,
pane: newPane,
tab
}
});
await router.replace(
{
query: {
...router.query,
appId: _id,
pane: newPane,
tab
}
},
undefined,
{ shallow: true }
);
setLastPane(newPane);
setLastChatAppId(_id);
......@@ -202,10 +232,18 @@ export const ChatPageContextProvider = ({
);
useEffect(() => {
if (!Object.values(ChatSidebarPaneEnum).includes(pane)) {
handlePaneChange(ChatSidebarPaneEnum.HOME);
if (Object.values(ChatSidebarPaneEnum).includes(pane)) return;
handlePaneChange(feConfigs.isPlus ? ChatSidebarPaneEnum.HOME : ChatSidebarPaneEnum.ALL_APPS);
}, [feConfigs.isPlus, handlePaneChange, pane]);
useEffect(() => {
if (feConfigs.isPlus) return;
if (![ChatSidebarPaneEnum.ALL_APPS, ChatSidebarPaneEnum.RECENTLY_USED_APPS].includes(pane)) {
handlePaneChange(ChatSidebarPaneEnum.ALL_APPS);
}
}, [handlePaneChange, pane]);
}, [feConfigs.isPlus, handlePaneChange, pane]);
const logos: Pick<ChatSettingType, 'wideLogoUrl' | 'squareLogoUrl'> = useMemo(
() => ({
......
......@@ -102,24 +102,43 @@ const createCustomStorage = () => {
export const useChatStore = create<State>()(
devtools(
persist(
immer((set, get) => ({
immer((set) => ({
source: undefined,
setSource(e) {
set((state) => {
// 分享会话的恢复必须依赖 shareId + outLinkUid,不能只靠 lastChatId 的 source 前缀。
if (
e !== ChatSourceEnum.share &&
!state.chatId &&
state.lastChatId &&
state.lastChatId.startsWith(e)
) {
state.chatId = state.lastChatId.split('-')[1];
} else if (e !== get().source) {
// 来源改变,强制重置 chatId
state.chatId = getNanoid(24);
if (state.source === e) {
state.source = e;
return;
}
// source 切换但 appId 不变时,setAppId 不会触发,必须在这里按 source + appId 归档和恢复 chatId。
const currentCacheKey = getAppChatIdCacheKey({
source: state.source,
appId: state.appId,
outLinkAuthData: state.outLinkAuthData
});
if (currentCacheKey && state.chatId) {
state.appChatIdMap[currentCacheKey] = state.chatId;
}
const nextCacheKey = getAppChatIdCacheKey({
source: e,
appId: state.appId,
outLinkAuthData: state.outLinkAuthData
});
const restoredAppChatId = nextCacheKey
? state.appChatIdMap[nextCacheKey]
: undefined;
// 分享会话的恢复必须依赖 shareId + outLinkUid,不能只靠 lastChatId 的 source 前缀。
const lastChatPrefix = `${e}-`;
const restoredLastChatId =
e !== ChatSourceEnum.share && state.lastChatId?.startsWith(lastChatPrefix)
? state.lastChatId.slice(lastChatPrefix.length)
: undefined;
state.chatId = restoredAppChatId || restoredLastChatId || getNanoid(24);
state.source = e;
state.lastChatId = `${e}-${state.chatId}`;
});
},
appId: '',
......
import { ChatErrEnum } from '@fastgpt/global/common/error/code/chat';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
authChatCrud: vi.fn(),
createGetChatFileURL: vi.fn()
}));
vi.mock('@/service/middleware/entry', () => ({
NextAPI: (handler: unknown) => handler
}));
vi.mock('@/service/support/permission/auth/chat', () => ({
authChatCrud: mocks.authChatCrud
}));
vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({
getS3ChatSource: () => ({
createGetChatFileURL: mocks.createGetChatFileURL
})
}));
import handler from '@/pages/api/core/chat/file/presignChatFileGetUrl';
const appId = '507f1f77bcf86cd799439011';
const uid = 'user-id';
const chatId = 'chat-id';
const presignHandler = handler as unknown as (req: ApiRequestProps) => Promise<string>;
const callHandler = (body: Record<string, unknown>) =>
presignHandler({
body
} as ApiRequestProps);
describe('presignChatFileGetUrl', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.authChatCrud.mockResolvedValue({
teamId: 'team-id',
uid
});
mocks.createGetChatFileURL.mockResolvedValue({
url: 'https://example.com/download-token'
});
});
it('signs a chat file only when the key belongs to the authorized app and uid', async () => {
await expect(
callHandler({
appId,
key: `chat/${appId}/${uid}/${chatId}/demo.pdf`
})
).resolves.toBe('https://example.com/download-token');
expect(mocks.createGetChatFileURL).toHaveBeenCalledWith({
key: `chat/${appId}/${uid}/${chatId}/demo.pdf`,
external: true,
mode: undefined
});
});
it('rejects a key from another app before signing', async () => {
await expect(
callHandler({
appId,
key: `chat/507f1f77bcf86cd799439099/${uid}/${chatId}/demo.pdf`
})
).rejects.toBe(ChatErrEnum.unAuthChat);
expect(mocks.createGetChatFileURL).not.toHaveBeenCalled();
});
it('rejects a key from another chat uid before signing', async () => {
await expect(
callHandler({
appId,
key: `chat/${appId}/victim-uid/${chatId}/demo.pdf`
})
).rejects.toBe(ChatErrEnum.unAuthChat);
expect(mocks.createGetChatFileURL).not.toHaveBeenCalled();
});
});
import { ChatErrEnum } from '@fastgpt/global/common/error/code/chat';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
authCert: vi.fn(),
createGetFileURL: vi.fn()
}));
vi.mock('@/service/middleware/entry', () => ({
NextAPI: (handler: unknown) => handler
}));
vi.mock('@fastgpt/service/support/permission/auth/common', () => ({
authCert: mocks.authCert
}));
vi.mock('@fastgpt/service/common/s3/sources/helperbot', () => ({
getS3HelperBotSource: () => ({
createGetFileURL: mocks.createGetFileURL
})
}));
import handler from '@/pages/api/core/chat/helperBot/getFilePreviewUrl';
const previewHandler = handler as unknown as (req: ApiRequestProps) => Promise<string>;
const callHandler = (body: Record<string, unknown>) =>
previewHandler({
body
} as ApiRequestProps);
describe('getFilePreviewUrl', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.authCert.mockResolvedValue({
userId: 'user-1'
});
mocks.createGetFileURL.mockResolvedValue({
url: 'https://example.com/helper-bot-file'
});
});
it('signs a helper bot file only when the key belongs to the current user', async () => {
await expect(
callHandler({
key: 'helperBot/topAgent/user-1/chat-1/demo.pdf'
})
).resolves.toBe('https://example.com/helper-bot-file');
expect(mocks.createGetFileURL).toHaveBeenCalledWith({
key: 'helperBot/topAgent/user-1/chat-1/demo.pdf',
external: true,
mode: undefined
});
});
it('rejects a helper bot file key from another user', async () => {
await expect(
callHandler({
key: 'helperBot/topAgent/user-2/chat-1/demo.pdf'
})
).rejects.toBe(ChatErrEnum.unAuthChat);
expect(mocks.createGetFileURL).not.toHaveBeenCalled();
});
it('rejects a malformed helper bot file key before signing', async () => {
await expect(
callHandler({
key: 'topAgent/chat-1/user-1/demo.pdf'
})
).rejects.toBe(ChatErrEnum.unAuthChat);
expect(mocks.createGetFileURL).not.toHaveBeenCalled();
});
});
import { DatasetSourceReadTypeEnum } from '@fastgpt/global/core/dataset/constants';
import { CommonErrEnum } from '@fastgpt/global/common/error/code/common';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
authDatasetFileKey: vi.fn(),
authDataset: vi.fn(),
readDatasetSourceRawText: vi.fn(),
rawText2Chunks: vi.fn(),
replaceS3KeyToPreviewUrl: vi.fn((value: string) => value)
}));
vi.mock('@/service/middleware/entry', () => ({
NextAPI: (handler: unknown) => handler
}));
vi.mock('@fastgpt/service/support/permission/auth/file', () => ({
authDatasetFileKey: mocks.authDatasetFileKey
}));
vi.mock('@fastgpt/service/support/permission/dataset/auth', () => ({
authDataset: mocks.authDataset
}));
vi.mock('@fastgpt/service/core/dataset/read', () => ({
readDatasetSourceRawText: mocks.readDatasetSourceRawText,
rawText2Chunks: mocks.rawText2Chunks
}));
vi.mock('@fastgpt/service/core/ai/model', () => ({
getEmbeddingModel: vi.fn(() => ({})),
getLLMModel: vi.fn(() => ({}))
}));
vi.mock('@fastgpt/global/core/dataset/training/utils', () => ({
computedCollectionChunkSettings: vi.fn(() => ({
chunkTriggerType: 'minSize',
chunkTriggerMinSize: 100,
chunkSize: 500,
paragraphChunkDeep: 1,
paragraphChunkMinSize: 100,
chunkSplitter: ''
})),
getLLMMaxChunkSize: vi.fn(() => 1000)
}));
vi.mock('@fastgpt/service/core/dataset/utils', () => ({
replaceS3KeyToPreviewUrl: mocks.replaceS3KeyToPreviewUrl
}));
import handler from '@/pages/api/core/dataset/file/getPreviewChunks';
const datasetId = '507f1f77bcf86cd799439011';
const previewHandler = handler as unknown as (req: ApiRequestProps) => Promise<unknown>;
const callHandler = (body: Record<string, unknown>) =>
previewHandler({
body
} as ApiRequestProps);
describe('getPreviewChunks', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.authDatasetFileKey.mockResolvedValue({
tmbId: 'tmb-a',
isRoot: false
});
mocks.authDataset.mockResolvedValue({
teamId: 'team-a',
tmbId: 'tmb-a',
dataset: {
agentModel: 'gpt',
vectorModel: 'embedding'
}
});
mocks.readDatasetSourceRawText.mockResolvedValue({
rawText: 'hello'
});
mocks.rawText2Chunks.mockResolvedValue([
{
q: 'hello',
a: ''
}
]);
});
it('rejects a local file key that is not under the target dataset before auth or read', async () => {
await expect(
callHandler({
type: DatasetSourceReadTypeEnum.fileLocal,
datasetId,
overlapRatio: 0.2,
sourceId: 'dataset/507f1f77bcf86cd799439099/secret.pdf'
})
).rejects.toBe(CommonErrEnum.unAuthFile);
expect(mocks.authDatasetFileKey).not.toHaveBeenCalled();
expect(mocks.authDataset).not.toHaveBeenCalled();
expect(mocks.readDatasetSourceRawText).not.toHaveBeenCalled();
});
it('previews a local file key under the target dataset', async () => {
await expect(
callHandler({
type: DatasetSourceReadTypeEnum.fileLocal,
datasetId,
overlapRatio: 0.2,
sourceId: `dataset/${datasetId}/demo.pdf`
})
).resolves.toMatchObject({
total: 1
});
expect(mocks.authDatasetFileKey).toHaveBeenCalledWith(
expect.objectContaining({
fileId: `dataset/${datasetId}/demo.pdf`
})
);
expect(mocks.readDatasetSourceRawText).toHaveBeenCalledWith(
expect.objectContaining({
datasetId,
sourceId: `dataset/${datasetId}/demo.pdf`
})
);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment