This commit is contained in:
heheer 2025-12-22 17:26:47 +08:00
parent afd9e6c439
commit 56b0165f3d
No known key found for this signature in database
GPG Key ID: 37DCB43201661540
14 changed files with 266 additions and 175 deletions

View File

@ -27,6 +27,8 @@ import { getS3ChatSource } from '../../common/s3/sources/chat';
import { MongoAppChatLog } from './logs/chatLogsSchema';
import { MongoAppRegistration } from '../../support/appRegistration/schema';
import { MongoMcpKey } from '../../support/mcp/schema';
import { type ClientSession } from '../../common/mongo';
import { MongoAppRecord } from './record/schema';
export const beforeUpdateAppFormat = ({ nodes }: { nodes?: StoreNodeItemType[] }) => {
if (!nodes) return;
@ -181,6 +183,8 @@ export const deleteAppDataProcessor = async ({
await MongoAppRegistration.deleteMany({ appId });
// 删除应用从MCP key apps数组中移除
await MongoMcpKey.updateMany({ teamId, 'apps.appId': appId }, { $pull: { apps: { appId } } });
// 删除应用使用记录
await MongoAppRecord.deleteMany({ appId });
// 删除应用本身
await MongoApp.deleteOne({ _id: appId });
@ -214,13 +218,10 @@ export async function updateParentFoldersUpdateTime({
}): Promise<void> {
if (!parentId) return;
const parentApp = await MongoApp.findById(parentId).lean();
const parentApp = await MongoApp.findById(parentId, 'parentId');
if (!parentApp) return;
// Only update if parent is a folder
if (AppFolderTypeList.includes(parentApp.type)) {
await MongoApp.findByIdAndUpdate(parentId, { updateTime: new Date() }, { session });
}
await MongoApp.findByIdAndUpdate(parentId, { updateTime: new Date() }, { session });
// Recursively update parent folders
await updateParentFoldersUpdateTime({

View File

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

View File

@ -0,0 +1,69 @@
import { z } from 'zod';
// Zod schemas
export const AppRecordSchemaZod = z.object({
_id: z.string().optional(),
tmbId: z.string(),
teamId: z.string(),
appId: z.string(),
lastUsedTime: z.date()
});
// 创建应用记录时的 schema不包含 _id
export const CreateAppRecordSchemaZod = AppRecordSchemaZod.omit({
_id: true
});
// 更新应用记录时的 schema部分字段可选
export const UpdateAppRecordSchemaZod = AppRecordSchemaZod.partial().omit({
_id: true,
tmbId: true,
teamId: true,
appId: true
});
// 查询参数的 schema
export const AppRecordQuerySchemaZod = z.object({
tmbId: z.string().optional(),
teamId: z.string().optional(),
appId: z.string().optional(),
lastUsedTime: z
.union([
z.date(),
z.object({
gte: z.date().optional(),
lte: z.date().optional(),
gt: z.date().optional(),
lt: z.date().optional()
})
])
.optional()
});
// TypeScript types inferred from Zod schemas
export type AppRecordType = z.infer<typeof AppRecordSchemaZod>;
export type CreateAppRecordType = z.infer<typeof CreateAppRecordSchemaZod>;
export type UpdateAppRecordType = z.infer<typeof UpdateAppRecordSchemaZod>;
export type AppRecordQueryType = z.infer<typeof AppRecordQuerySchemaZod>;
// 兼容旧版本的类型定义(保持向后兼容)
export type {
AppRecordType as AppRecordSchemaType,
CreateAppRecordType as AppRecordCreateType,
UpdateAppRecordType as AppRecordUpdateType
};
// 应用记录统计类型
export const AppRecordStatsSchemaZod = z.object({
totalRecords: z.number().min(0),
uniqueApps: z.number().min(0),
mostUsedApp: z
.object({
appId: z.string(),
usageCount: z.number().min(0)
})
.optional(),
lastUsedTime: z.date().optional()
});
export type AppRecordStatsType = z.infer<typeof AppRecordStatsSchemaZod>;

View File

@ -1,5 +1,5 @@
import { mongoSessionRun } from '../../../common/mongo/sessionRun';
import { MongoAppUsage } from './schema';
import { MongoAppRecord } from './schema';
export const recordAppUsage = async ({
appId,
@ -11,7 +11,7 @@ export const recordAppUsage = async ({
teamId: string;
}) => {
await mongoSessionRun(async (session) => {
await MongoAppUsage.findOneAndUpdate(
await MongoAppRecord.findOneAndUpdate(
{ tmbId, appId },
{
$set: {
@ -26,24 +26,24 @@ export const recordAppUsage = async ({
}
);
// 保留最新的50条记录删除超出限制的旧记录
const threshold = await MongoAppUsage.findOne(
const records = await MongoAppRecord.find(
{ tmbId },
{ lastUsedTime: 1 },
{ _id: 1 },
{
session,
sort: { lastUsedTime: -1 },
skip: 49,
lean: true
}
);
if (threshold) {
await MongoAppUsage.deleteMany(
if (records.length > 50) {
const toDeleteRecords = records.slice(50);
const toDeleteIds = toDeleteRecords.map((record) => record._id);
await MongoAppRecord.deleteMany(
{
tmbId,
_id: { $ne: threshold._id },
lastUsedTime: { $lte: threshold.lastUsedTime }
_id: { $in: toDeleteIds }
},
{ session }
);

View File

@ -1,49 +0,0 @@
import {
TeamCollectionName,
TeamMemberCollectionName
} from '@fastgpt/global/support/user/team/constant';
import { getMongoModel, Schema } from '../../../common/mongo';
export const AppUsageCollectionName = 'app_usages';
const AppUsageSchema = new Schema(
{
tmbId: {
type: Schema.Types.ObjectId,
ref: TeamMemberCollectionName,
required: true
},
teamId: {
type: Schema.Types.ObjectId,
ref: TeamCollectionName,
required: true
},
appId: {
type: Schema.Types.ObjectId,
ref: 'apps',
required: true
},
lastUsedTime: {
type: Date,
default: () => new Date()
}
},
{
minimize: false,
timestamps: false
}
);
AppUsageSchema.index({ tmbId: 1, lastUsedTime: -1 }); // 查询用户最近使用的应用
AppUsageSchema.index({ tmbId: 1, appId: 1 }, { unique: true }); // 防止重复记录
AppUsageSchema.index({ teamId: 1, appId: 1 }); // 用于清理权限失效的记录
export const MongoAppUsage = getMongoModel<AppUsageType>(AppUsageCollectionName, AppUsageSchema);
export type AppUsageType = {
_id?: string;
tmbId: string;
teamId: string;
appId: string;
lastUsedTime: Date;
};

View File

@ -32,9 +32,10 @@ const CustomPluginRunBox = dynamic(() => import('@/pageComponents/chat/CustomPlu
type Props = {
myApps: AppListItemType[];
refreshRecentlyUsed?: () => void;
};
const AppChatWindow = ({ myApps }: Props) => {
const AppChatWindow = ({ myApps, refreshRecentlyUsed }: Props) => {
const { userInfo } = useUserStore();
const { chatId, appId, outLinkAuthData } = useChatStore();
@ -122,9 +123,19 @@ const AppChatWindow = ({ myApps }: Props) => {
title: newTitle
}));
refreshRecentlyUsed?.();
return { responseText, isNewChat: forbidLoadChat.current };
},
[appId, chatId, onUpdateHistoryTitle, setChatBoxData, forbidLoadChat, isShowCite]
[
appId,
chatId,
onUpdateHistoryTitle,
setChatBoxData,
forbidLoadChat,
isShowCite,
refreshRecentlyUsed
]
);
return (

View File

@ -51,6 +51,7 @@ import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
type Props = {
myApps: AppListItemType[];
refreshRecentlyUsed?: () => void;
};
const defaultFileSelectConfig: AppFileSelectConfigType = {
@ -68,7 +69,7 @@ const defaultWhisperConfig: AppWhisperConfigType = {
autoTTSResponse: false
};
const HomeChatWindow = ({ myApps }: Props) => {
const HomeChatWindow = ({ myApps, refreshRecentlyUsed }: Props) => {
const { t } = useTranslation();
const { isPc } = useSystem();
@ -232,6 +233,8 @@ const HomeChatWindow = ({ myApps }: Props) => {
title: newTitle
}));
refreshRecentlyUsed?.();
return { responseText, isNewChat: forbidLoadChat.current };
}
@ -281,6 +284,8 @@ const HomeChatWindow = ({ myApps }: Props) => {
title: newTitle
}));
refreshRecentlyUsed?.();
return { responseText, isNewChat: forbidLoadChat.current };
}
);

View File

@ -12,7 +12,7 @@ export const useChat = (appId: string) => {
const [isInitedUser, setIsInitedUser] = useState(false);
// get app list
const { data: myApps = [] } = useRequest2(() => getRecentlyUsedApps(), {
const { data: myApps = [], refresh } = useRequest2(() => getRecentlyUsedApps(), {
manual: false,
errorToast: '',
refreshDeps: [userInfo],
@ -43,6 +43,7 @@ export const useChat = (appId: string) => {
return {
isInitedUser,
userInfo,
myApps
myApps,
refreshRecentlyUsed: refresh
};
};

View File

@ -1,11 +1,11 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { MongoAppUsage } from '@fastgpt/service/core/app/usage/schema';
import { MongoAppRecord } from '@fastgpt/service/core/app/record/schema';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { addSourceMember } from '@fastgpt/service/support/user/utils';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import type { AppListItemType } from '@fastgpt/global/core/app/type';
import { AppPermission } from '@fastgpt/global/support/permission/app/controller';
export type GetRecentlyUsedAppsResponse = AppListItemType[];
@ -13,57 +13,37 @@ async function handler(
req: ApiRequestProps<{}, {}>,
_res: ApiResponseType<GetRecentlyUsedAppsResponse>
): Promise<GetRecentlyUsedAppsResponse> {
const { tmbId, teamId } = await authUserPer({
const { tmbId } = await authUserPer({
req,
authToken: true,
authApiKey: true
});
const recentUsages = await MongoAppUsage.find(
const recentRecords = await MongoAppRecord.find(
{ tmbId },
{ appId: 1 },
{ sort: { lastUsedTime: -1 }, limit: 20 }
).lean();
if (!recentUsages.length) return [];
if (!recentRecords.length) return [];
const appIds = recentUsages.map((usage) => usage.appId);
const appIds = recentRecords.map((record) => record.appId);
// 并发检查权限
const results = await Promise.allSettled(
appIds.map((appId) =>
authApp({ req, authToken: true, authApiKey: true, appId, per: ReadPermissionVal })
.then(({ app }) => ({ appId, app }))
.catch(() => ({ appId, app: null }))
)
);
const apps = await MongoApp.find(
{
_id: { $in: appIds },
deleteTime: null
},
'_id parentId tmbId name avatar intro type updateTime pluginData inheritPermission'
).lean();
const validApps: {
appId: string;
app: NonNullable<Awaited<ReturnType<typeof authApp>>['app']>;
}[] = [];
const invalidAppIds: string[] = [];
for (const result of results) {
if (result.status === 'fulfilled') {
const { appId, app } = result.value;
if (app) {
validApps.push({ appId, app });
} else {
invalidAppIds.push(appId);
}
}
}
// 异步清理无效记录
if (invalidAppIds.length) {
MongoAppUsage.deleteMany({ tmbId, teamId, appId: { $in: invalidAppIds } }).catch((err) =>
console.error('Failed to clean invalid app usage records:', err)
);
}
const appMap = new Map(apps.map((app) => [String(app._id), app]));
const sortedApps = recentRecords
.map((record) => appMap.get(String(record.appId)))
.filter((app) => app != null);
return addSourceMember({
list: validApps.map(({ app }) => ({
list: sortedApps.map((app) => ({
_id: app._id,
parentId: app.parentId,
tmbId: app.tmbId,
@ -73,7 +53,10 @@ async function handler(
type: app.type,
updateTime: app.updateTime,
pluginData: app.pluginData,
permission: app.permission,
permission: new AppPermission({
role: 0,
isOwner: String(app.tmbId) === String(tmbId)
}),
inheritPermission: app.inheritPermission
}))
});

View File

@ -30,6 +30,10 @@ async function handler(req: ApiRequestProps<PostPublishAppProps>, res: NextApiRe
nodes
});
await updateParentFoldersUpdateTime({
parentId: app.parentId
});
if (autoSave) {
await mongoSessionRun(async (session) => {
await MongoAppVersion.updateOne(
@ -62,11 +66,6 @@ async function handler(req: ApiRequestProps<PostPublishAppProps>, res: NextApiRe
session
}
);
await updateParentFoldersUpdateTime({
parentId: app.parentId,
session
});
});
addAuditLog({
@ -128,11 +127,6 @@ async function handler(req: ApiRequestProps<PostPublishAppProps>, res: NextApiRe
session
}
);
await updateParentFoldersUpdateTime({
parentId: app.parentId,
session
});
});
(async () => {

View File

@ -1,6 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@fastgpt/service/common/response';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { getGuideModule, getAppChatConfig } from '@fastgpt/global/core/workflow/utils';
import { getChatModelNameListByModules } from '@/service/core/app/workflow';
import type { InitChatProps, InitChatResponse } from '@/global/core/chat/api.d';
@ -11,6 +12,8 @@ import { NextAPI } from '@/service/middleware/entry';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { presignVariablesFileUrls } from '@fastgpt/service/core/chat/utils';
import { MongoAppRecord } from '@fastgpt/service/core/app/record/schema';
import { AppErrEnum } from '@fastgpt/global/common/error/code/app';
async function handler(
req: NextApiRequest,
@ -25,57 +28,75 @@ async function handler(
});
}
// auth app permission
const [{ app, tmbId }, chat] = await Promise.all([
authApp({
req,
authToken: true,
authApiKey: true,
appId,
per: ReadPermissionVal
}),
chatId ? MongoChat.findOne({ appId, chatId }) : undefined
]);
// auth chat permission
if (chat && !app.permission.hasReadChatLogPer && String(tmbId) !== String(chat?.tmbId)) {
return Promise.reject(ChatErrEnum.unAuthChat);
}
// get app and history
const { nodes, chatConfig } = await getAppLatestVersion(app._id, app);
const pluginInputs =
chat?.pluginInputs ??
nodes?.find((node) => node.flowNodeType === FlowNodeTypeEnum.pluginInput)?.inputs ??
[];
const variables = await presignVariablesFileUrls({
variables: chat?.variables,
variableConfig: chat?.variableList
});
return {
chatId,
appId,
title: chat?.title,
userAvatar: undefined,
variables,
app: {
chatConfig: getAppChatConfig({
chatConfig,
systemConfigNode: getGuideModule(nodes),
storeVariables: chat?.variableList,
storeWelcomeText: chat?.welcomeText,
isPublicFetch: false
try {
// auth app permission
const [{ app, tmbId }, chat] = await Promise.all([
authApp({
req,
authToken: true,
authApiKey: true,
appId,
per: ReadPermissionVal
}),
chatModels: getChatModelNameListByModules(nodes),
name: app.name,
avatar: app.avatar,
intro: app.intro,
type: app.type,
pluginInputs
chatId ? MongoChat.findOne({ appId, chatId }) : undefined
]);
// auth chat permission
if (chat && !app.permission.hasReadChatLogPer && String(tmbId) !== String(chat?.tmbId)) {
return Promise.reject(ChatErrEnum.unAuthChat);
}
};
// get app and history
const { nodes, chatConfig } = await getAppLatestVersion(app._id, app);
const pluginInputs =
chat?.pluginInputs ??
nodes?.find((node) => node.flowNodeType === FlowNodeTypeEnum.pluginInput)?.inputs ??
[];
const variables = await presignVariablesFileUrls({
variables: chat?.variables,
variableConfig: chat?.variableList
});
return {
chatId,
appId,
title: chat?.title,
userAvatar: undefined,
variables,
app: {
chatConfig: getAppChatConfig({
chatConfig,
systemConfigNode: getGuideModule(nodes),
storeVariables: chat?.variableList,
storeWelcomeText: chat?.welcomeText,
isPublicFetch: false
}),
chatModels: getChatModelNameListByModules(nodes),
name: app.name,
avatar: app.avatar,
intro: app.intro,
type: app.type,
pluginInputs
}
};
} catch (error: any) {
if (error === AppErrEnum.unAuthApp) {
const { tmbId, teamId } = await authUserPer({
req,
authToken: true,
authApiKey: true
});
await MongoAppRecord.deleteMany({
tmbId,
teamId,
appId
});
}
return Promise.reject(error);
}
}
export default NextAPI(handler);

View File

@ -27,7 +27,7 @@ import {
} from '@fastgpt/service/core/chat/saveChat';
import { responseWrite } from '@fastgpt/service/common/response';
import { authOutLinkChatStart } from '@/service/support/permission/auth/outLink';
import { recordAppUsage } from '@fastgpt/service/core/app/usage/utils';
import { recordAppUsage } from '@fastgpt/service/core/app/record/utils';
import { pushResult2Remote, addOutLinkUsage } from '@fastgpt/service/support/outLink/tools';
import { getUsageSourceByAuthType } from '@fastgpt/global/support/wallet/usage/tools';
import { authTeamSpaceToken } from '@/service/support/permission/auth/team';

View File

@ -27,7 +27,7 @@ import {
} from '@fastgpt/service/core/chat/saveChat';
import { responseWrite } from '@fastgpt/service/common/response';
import { authOutLinkChatStart } from '@/service/support/permission/auth/outLink';
import { recordAppUsage } from '@fastgpt/service/core/app/usage/utils';
import { recordAppUsage } from '@fastgpt/service/core/app/record/utils';
import { pushResult2Remote, addOutLinkUsage } from '@fastgpt/service/support/outLink/tools';
import { getUsageSourceByAuthType } from '@fastgpt/global/support/wallet/usage/tools';
import { authTeamSpaceToken } from '@/service/support/permission/auth/team';

View File

@ -33,7 +33,13 @@ import { MongoOutLink } from '@fastgpt/service/support/outLink/schema';
import { addLog } from '@fastgpt/service/common/system/log';
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
const Chat = ({
myApps,
refreshRecentlyUsed
}: {
myApps: AppListItemType[];
refreshRecentlyUsed: () => void;
}) => {
const { isPc } = useSystem();
const { appId } = useChatStore();
@ -62,7 +68,9 @@ const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
{(!datasetCiteData || isPc) && (
<PageContainer flex="1 0 0" w={0} position="relative">
{/* home chat window */}
{pane === ChatSidebarPaneEnum.HOME && <HomeChatWindow myApps={myApps} />}
{pane === ChatSidebarPaneEnum.HOME && (
<HomeChatWindow myApps={myApps} refreshRecentlyUsed={refreshRecentlyUsed} />
)}
{/* favourite apps */}
{pane === ChatSidebarPaneEnum.FAVORITE_APPS && <ChatFavouriteApp />}
@ -71,7 +79,9 @@ const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
{pane === ChatSidebarPaneEnum.TEAM_APPS && <ChatTeamApp />}
{/* recently used apps chat window */}
{pane === ChatSidebarPaneEnum.RECENTLY_USED_APPS && <AppChatWindow myApps={myApps} />}
{pane === ChatSidebarPaneEnum.RECENTLY_USED_APPS && (
<AppChatWindow myApps={myApps} refreshRecentlyUsed={refreshRecentlyUsed} />
)}
{/* setting */}
{pane === ChatSidebarPaneEnum.SETTING && <ChatSetting />}
@ -103,7 +113,7 @@ const Render = (props: {
const { chatId } = useChatStore();
const { setUserInfo } = useUserStore();
const { feConfigs } = useSystemStore();
const { isInitedUser, userInfo, myApps } = useChat(appId);
const { isInitedUser, userInfo, myApps, refreshRecentlyUsed } = useChat(appId);
const chatHistoryProviderParams = useMemo(
() => ({ appId, source: ChatSourceEnum.online }),
@ -154,7 +164,7 @@ const Render = (props: {
isShowFullText={props.showFullText}
>
<ChatRecordContextProvider params={chatRecordProviderParams}>
<Chat myApps={myApps} />
<Chat myApps={myApps} refreshRecentlyUsed={refreshRecentlyUsed} />
</ChatRecordContextProvider>
</ChatItemContextProvider>
</ChatContextProvider>