V4.9.6 feature (#4565)

* Dashboard submenu (#4545)

* add app submenu (#4452)

* add app submenu

* fix

* width & i18n

* optimize submenu code (#4515)

* optimize submenu code

* fix

* fix

* fix

* fix ts

* perf: dashboard sub menu

* doc

---------

Co-authored-by: heheer <heheer@sealos.io>

* feat: value format test

* doc

* Mcp export (#4555)

* feat: mcp server

* feat: mcp server

* feat: mcp server build

* update doc

* perf: path selector (#4556)

* perf: path selector

* fix: docker file path

* perf: add image endpoint to dataset search (#4557)

* perf: add image endpoint to dataset search

* fix: mcp_server url

* human in loop (#4558)

* Support interactive nodes for loops, and enhance the function of merging nested and loop node history messages. (#4552)

* feat: add LoopInteractive definition

* feat: Support LoopInteractive type and update related logic

* fix: Refactor loop handling logic and improve output value initialization

* feat: Add mergeSignId to dispatchLoop and dispatchRunAppNode responses

* feat: Enhance mergeChatResponseData to recursively merge plugin details and improve response handling

* refactor: Remove redundant comments in mergeChatResponseData for clarity

* perf: loop interactive

* perf: human in loop

---------

Co-authored-by: Theresa <63280168+sd0ric4@users.noreply.github.com>

* mcp server ui

* integrate mcp (#4549)

* integrate mcp

* delete unused code

* fix ts

* bug fix

* fix

* support whole mcp tools

* add try catch

* fix

* fix

* fix ts

* fix test

* fix ts

* fix: interactive in v1 completions

* doc

* fix: router path

* fix mcp integrate (#4563)

* fix mcp integrate

* fix ui

* fix: mcp ux

* feat: mcp call title

* remove repeat loading

* fix mcp tools avatar (#4564)

* fix

* fix avatar

* fix update version

* update doc

* fix: value format

* close server and remove cache

* perf: avatar

---------

Co-authored-by: heheer <heheer@sealos.io>
Co-authored-by: Theresa <63280168+sd0ric4@users.noreply.github.com>
This commit is contained in:
Archer
2025-04-16 22:18:51 +08:00
committed by GitHub
parent ab799e13cd
commit 952412f648
166 changed files with 6318 additions and 1263 deletions

View File

@@ -4,7 +4,7 @@ import { useRouter } from 'next/router';
const NonePage = () => {
const router = useRouter();
useEffect(() => {
router.push('/app/list');
router.push('/dashboard/apps');
}, [router]);
return <div></div>;

View File

@@ -48,7 +48,7 @@ function Error() {
if (modelError) {
router.push('/account/model');
} else {
router.push('/app/list');
router.push('/dashboard/apps');
}
}, 2000);
}, []);

View File

@@ -0,0 +1,33 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { authCert } from '@fastgpt/service/support/permission/auth/common';
import { getAppBasicInfoByIds } from '@fastgpt/service/core/app/controller';
export type getBasicInfoQuery = {};
export type getBasicInfoBody = {
ids: string[];
};
export type getBasicInfoResponse = {
id: string;
name: string;
avatar: string;
}[];
async function handler(
req: ApiRequestProps<getBasicInfoBody, getBasicInfoQuery>,
res: ApiResponseType<any>
): Promise<getBasicInfoResponse> {
const { ids } = req.body;
const { teamId } = await authCert({ req, authToken: true });
const apps = await getAppBasicInfoByIds({
teamId,
ids
});
return apps;
}
export default NextAPI(handler);

View File

@@ -0,0 +1,67 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { TeamAppCreatePermissionVal } from '@fastgpt/global/support/permission/user/constant';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { CreateAppBody, onCreateApp } from '../create';
import { ToolType } from '@fastgpt/global/core/app/type';
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import {
getMCPToolRuntimeNode,
getMCPToolSetRuntimeNode
} from '@fastgpt/global/core/app/mcpTools/utils';
export type createMCPToolsQuery = {};
export type createMCPToolsBody = Omit<
CreateAppBody,
'type' | 'modules' | 'edges' | 'chatConfig'
> & {
url: string;
toolList: ToolType[];
};
export type createMCPToolsResponse = {};
async function handler(
req: ApiRequestProps<createMCPToolsBody, createMCPToolsQuery>,
res: ApiResponseType<createMCPToolsResponse>
): Promise<createMCPToolsResponse> {
const { name, avatar, toolList, url, parentId } = req.body;
const { teamId, tmbId, userId } = parentId
? await authApp({ req, appId: parentId, per: TeamAppCreatePermissionVal, authToken: true })
: await authUserPer({ req, authToken: true, per: TeamAppCreatePermissionVal });
await mongoSessionRun(async (session) => {
const mcpToolsId = await onCreateApp({
name,
avatar,
parentId,
teamId,
tmbId,
type: AppTypeEnum.toolSet,
modules: [getMCPToolSetRuntimeNode({ url, toolList, name, avatar })],
session
});
for (const tool of toolList) {
await onCreateApp({
name: tool.name,
avatar,
parentId: mcpToolsId,
teamId,
tmbId,
type: AppTypeEnum.tool,
intro: tool.description,
modules: [getMCPToolRuntimeNode({ tool, url })],
session
});
}
});
return {};
}
export default NextAPI(handler);

View File

@@ -0,0 +1,43 @@
import { NextAPI } from '@/service/middleware/entry';
import { ToolType } from '@fastgpt/global/core/app/type';
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
export type getMCPToolsQuery = {};
export type getMCPToolsBody = { url: string };
export type getMCPToolsResponse = ToolType[];
async function handler(
req: ApiRequestProps<getMCPToolsBody, getMCPToolsQuery>,
res: ApiResponseType<getMCPToolsResponse[]>
): Promise<getMCPToolsResponse> {
const { url } = req.body;
const client = new Client({
name: 'FastGPT-MCP-client',
version: '1.0.0'
});
const tools = await (async () => {
try {
const transport = new SSEClientTransport(new URL(url));
await client.connect(transport);
const response = await client.listTools();
return response.tools || [];
} catch (error) {
console.error('Error fetching MCP tools:', error);
return Promise.reject(error);
} finally {
await client.close();
}
})();
return tools as ToolType[];
}
export default NextAPI(handler);

View File

@@ -0,0 +1,45 @@
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { NextAPI } from '@/service/middleware/entry';
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
export type RunToolTestQuery = {};
export type RunToolTestBody = {
params: Record<string, any>;
url: string;
toolName: string;
};
export type RunToolTestResponse = any;
async function handler(
req: ApiRequestProps<RunToolTestBody, RunToolTestQuery>,
res: ApiResponseType<RunToolTestResponse>
): Promise<RunToolTestResponse> {
const { params, url, toolName } = req.body;
const client = new Client({
name: 'FastGPT-MCP-client',
version: '1.0.0'
});
const result = await (async () => {
try {
const transport = new SSEClientTransport(new URL(url));
await client.connect(transport);
return await client.callTool({
name: toolName,
arguments: params
});
} catch (error) {
console.error('Error running MCP tool test:', error);
return Promise.reject(error);
} finally {
await client.close();
}
})();
return result;
}
export default NextAPI(handler);

View File

@@ -0,0 +1,138 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { AppDetailType, ToolType } from '@fastgpt/global/core/app/type';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { ManagePermissionVal } from '@fastgpt/global/support/permission/constant';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import { isEqual } from 'lodash';
import { ClientSession } from 'mongoose';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { onDelOneApp } from '../del';
import { onCreateApp } from '../create';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import {
getMCPToolRuntimeNode,
getMCPToolSetRuntimeNode
} from '@fastgpt/global/core/app/mcpTools/utils';
import { MCPToolSetData } from '@/pageComponents/dashboard/apps/MCPToolsEditModal';
import { MongoAppVersion } from '@fastgpt/service/core/app/version/schema';
export type updateMCPToolsQuery = {};
export type updateMCPToolsBody = {
appId: string;
url: string;
toolList: ToolType[];
};
export type updateMCPToolsResponse = {};
async function handler(
req: ApiRequestProps<updateMCPToolsBody, updateMCPToolsQuery>,
res: ApiResponseType<updateMCPToolsResponse>
): Promise<updateMCPToolsResponse> {
const { appId, url, toolList } = req.body;
const { app } = await authApp({ req, authToken: true, appId, per: ManagePermissionVal });
const toolSetNode = app.modules.find((item) => item.flowNodeType === FlowNodeTypeEnum.toolSet);
const toolSetData = toolSetNode?.inputs[0].value as MCPToolSetData;
await mongoSessionRun(async (session) => {
if (
!isEqual(toolSetData, {
url,
toolList
})
) {
await updateMCPChildrenTool({
parentApp: app,
toolSetData: {
url,
toolList
},
session
});
}
await MongoApp.findByIdAndUpdate(
appId,
{
modules: [getMCPToolSetRuntimeNode({ url, toolList, name: app.name, avatar: app.avatar })]
},
{ session }
);
await MongoAppVersion.updateOne(
{
appId
},
{
$set: {
nodes: [getMCPToolSetRuntimeNode({ url, toolList, name: app.name, avatar: app.avatar })]
}
},
{ session }
);
});
return {};
}
export default NextAPI(handler);
const updateMCPChildrenTool = async ({
parentApp,
toolSetData,
session
}: {
parentApp: AppDetailType;
toolSetData: MCPToolSetData;
session: ClientSession;
}) => {
const { teamId, tmbId } = parentApp;
const dbTools = await MongoApp.find({
parentId: parentApp._id,
teamId
});
for await (const tool of dbTools) {
if (!toolSetData.toolList.find((t) => t.name === tool.name)) {
await onDelOneApp({
teamId,
appId: tool._id,
session
});
}
}
for await (const tool of toolSetData.toolList) {
if (!dbTools.find((t) => t.name === tool.name)) {
await onCreateApp({
name: tool.name,
avatar: parentApp.avatar,
parentId: parentApp._id,
teamId,
tmbId,
type: AppTypeEnum.tool,
intro: tool.description,
modules: [getMCPToolRuntimeNode({ tool, url: toolSetData.url })],
session
});
}
}
for await (const tool of toolSetData.toolList) {
const dbTool = dbTools.find((t) => t.name === tool.name);
if (dbTool) {
await MongoApp.findByIdAndUpdate(
dbTool._id,
{
modules: [getMCPToolRuntimeNode({ tool, url: toolSetData.url })]
},
{ session }
);
}
}
};

View File

@@ -99,6 +99,16 @@ async function handler(req: ApiRequestProps<AppUpdateBody, AppUpdateQuery>) {
await refreshSourceAvatar(avatar, app.avatar, session);
if (app.type === AppTypeEnum.toolSet && avatar) {
await MongoApp.updateMany(
{ parentId: appId, teamId: app.teamId },
{
avatar
},
{ session }
);
}
return MongoApp.findByIdAndUpdate(
appId,
{

View File

@@ -31,7 +31,7 @@ import {
getLastInteractiveValue,
getMaxHistoryLimitFromNodes,
getWorkflowEntryNodeIds,
initWorkflowEdgeStatus,
storeEdges2RuntimeEdges,
rewriteNodeOutputByHistories,
storeNodes2RuntimeNodes,
textAdaptGptResponse
@@ -43,7 +43,11 @@ import { getPluginInputsFromStoreNodes } from '@fastgpt/global/core/app/plugin/u
import { getChatItems } from '@fastgpt/service/core/chat/controller';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { getSystemTime } from '@fastgpt/global/common/time/timezone';
import { ChatRoleEnum, ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import {
ChatItemValueTypeEnum,
ChatRoleEnum,
ChatSourceEnum
} from '@fastgpt/global/core/chat/constants';
import { saveChat, updateInteractiveChat } from '@fastgpt/service/core/chat/saveChat';
export type Props = {
@@ -97,6 +101,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
});
const isPlugin = app.type === AppTypeEnum.plugin;
const isTool = app.type === AppTypeEnum.tool;
const userQuestion: UserChatItemType = await (async () => {
if (isPlugin) {
@@ -106,6 +111,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
files: variables.files
});
}
if (isTool) {
return {
obj: ChatRoleEnum.Human,
value: [
{
type: ChatItemValueTypeEnum.text,
text: { content: 'tool test' }
}
]
};
}
const latestHumanChat = chatMessages.pop() as UserChatItemType;
if (!latestHumanChat) {
@@ -175,7 +191,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
chatId,
responseChatItemId,
runtimeNodes,
runtimeEdges: initWorkflowEdgeStatus(edges, interactive),
runtimeEdges: storeEdges2RuntimeEdges(edges, interactive),
variables,
query: removeEmptyUserInput(userQuestion.value),
lastInteractive: interactive,

View File

@@ -0,0 +1,77 @@
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 { TeamErrEnum } from '@fastgpt/global/common/error/code/team';
import { CommonErrEnum } from '@fastgpt/global/common/error/code/common';
import { authAppByTmbId } from '@fastgpt/service/support/permission/app/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { MongoMcpKey } from '@fastgpt/service/support/mcp/schema';
import { McpAppType } from '@fastgpt/global/support/mcp/type';
export type createQuery = {};
export type createBody = {
name: string;
apps: McpAppType[];
};
export type createResponse = {};
async function handler(
req: ApiRequestProps<createBody, createQuery>,
res: ApiResponseType<any>
): Promise<createResponse> {
const { teamId, tmbId, permission } = await authUserPer({
req,
authToken: true,
authApiKey: true
});
if (!permission.hasApikeyCreatePer) {
return Promise.reject(TeamErrEnum.unPermission);
}
let { name, apps } = req.body;
if (!apps.length) {
return Promise.reject(CommonErrEnum.missingParams);
}
// Count mcp length
const totalMcp = await MongoMcpKey.countDocuments({ teamId });
if (totalMcp >= 100) {
return Promise.reject('暂时只支持100个MCP服务');
}
// 对 apps 中的 id 进行去重,确保每个应用只出现一次
const uniqueAppIds = new Set();
apps = apps.filter((app) => {
if (uniqueAppIds.has(app.appId)) {
return false; // 过滤掉重复的 app id
}
uniqueAppIds.add(app.appId);
return true;
});
// Check app read permission
await Promise.all(
apps.map((app) =>
authAppByTmbId({
tmbId,
appId: app.appId,
per: ReadPermissionVal
})
)
);
await MongoMcpKey.create({
teamId,
tmbId,
name,
apps
});
return {};
}
export default NextAPI(handler);

View File

@@ -0,0 +1,34 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { authMcp } from '@fastgpt/service/support/permission/mcp/auth';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { MongoMcpKey } from '@fastgpt/service/support/mcp/schema';
export type deleteQuery = {
id: string;
};
export type deleteBody = {};
export type deleteResponse = {};
async function handler(
req: ApiRequestProps<deleteBody, deleteQuery>,
res: ApiResponseType<any>
): Promise<deleteResponse> {
const { id } = req.query;
await authMcp({
req,
authToken: true,
authApiKey: true,
mcpId: id,
per: WritePermissionVal
});
await MongoMcpKey.deleteOne({ _id: id });
return {};
}
export default NextAPI(handler);

View File

@@ -0,0 +1,34 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { authCert } from '@fastgpt/service/support/permission/auth/common';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { MongoMcpKey } from '@fastgpt/service/support/mcp/schema';
import { McpKeyType } from '@fastgpt/global/support/mcp/type';
export type listQuery = {};
export type listBody = {};
export type listResponse = McpKeyType[];
async function handler(
req: ApiRequestProps<listBody, listQuery>,
res: ApiResponseType<any>
): Promise<listResponse> {
const { teamId, tmbId, permission } = await authUserPer({
req,
authToken: true,
authApiKey: true
});
const list = await (async () => {
if (permission.hasManagePer) {
return await MongoMcpKey.find({ teamId }).lean().sort({ _id: -1 });
}
return await MongoMcpKey.find({ teamId, tmbId }).lean().sort({ _id: -1 });
})();
return list;
}
export default NextAPI(handler);

View File

@@ -0,0 +1,197 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { MongoMcpKey } from '@fastgpt/service/support/mcp/schema';
import { CommonErrEnum } from '@fastgpt/global/common/error/code/common';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { AppSchema } from '@fastgpt/global/core/app/type';
import { getUserChatInfoAndAuthTeamPoints } from '@fastgpt/service/support/permission/auth/team';
import { getAppLatestVersion } from '@fastgpt/service/core/app/version/controller';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { AIChatItemType, UserChatItemType } from '@fastgpt/global/core/chat/type';
import {
getPluginRunUserQuery,
updatePluginInputByVariables
} from '@fastgpt/global/core/workflow/utils';
import { getPluginInputsFromStoreNodes } from '@fastgpt/global/core/app/plugin/utils';
import {
ChatItemValueTypeEnum,
ChatRoleEnum,
ChatSourceEnum
} from '@fastgpt/global/core/chat/constants';
import {
getWorkflowEntryNodeIds,
storeEdges2RuntimeEdges,
storeNodes2RuntimeNodes
} from '@fastgpt/global/core/workflow/runtime/utils';
import { WORKFLOW_MAX_RUN_TIMES } from '@fastgpt/service/core/workflow/constants';
import { dispatchWorkFlow } from '@fastgpt/service/core/workflow/dispatch';
import { getChatTitleFromChatMessage, removeEmptyUserInput } from '@fastgpt/global/core/chat/utils';
import { saveChat } from '@fastgpt/service/core/chat/saveChat';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { createChatUsage } from '@fastgpt/service/support/wallet/usage/controller';
import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
export type toolCallQuery = {};
export type toolCallBody = {
key: string;
toolName: string;
inputs: Record<string, any>;
};
export type toolCallResponse = {};
const dispatchApp = async (app: AppSchema, variables: Record<string, any>) => {
const isPlugin = app.type === AppTypeEnum.plugin;
const { timezone, externalProvider } = await getUserChatInfoAndAuthTeamPoints(app.tmbId);
// Get app latest version
const { nodes, edges, chatConfig } = await getAppLatestVersion(app._id, app);
const userQuestion: UserChatItemType = (() => {
if (isPlugin) {
return getPluginRunUserQuery({
pluginInputs: getPluginInputsFromStoreNodes(nodes || app.modules),
variables
});
}
return {
obj: ChatRoleEnum.Human,
value: [
{
type: ChatItemValueTypeEnum.text,
text: {
content: variables.question
}
}
]
};
})();
let runtimeNodes = storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes));
if (isPlugin) {
// Assign values to runtimeNodes using variables
runtimeNodes = updatePluginInputByVariables(runtimeNodes, variables);
// Plugin runtime does not need global variables(It has been injected into the pluginInputNode)
variables = {};
} else {
delete variables.question;
variables.system_fileUrlList = variables.fileUrlList;
delete variables.fileUrlList;
}
const chatId = getNanoid();
const { flowUsages, assistantResponses, newVariables, flowResponses } = await dispatchWorkFlow({
chatId,
timezone,
externalProvider,
mode: 'chat',
runningAppInfo: {
id: String(app._id),
teamId: String(app.teamId),
tmbId: String(app.tmbId)
},
runningUserInfo: {
teamId: String(app.teamId),
tmbId: String(app.tmbId)
},
uid: String(app.tmbId),
runtimeNodes,
runtimeEdges: storeEdges2RuntimeEdges(edges),
variables,
query: removeEmptyUserInput(userQuestion.value),
chatConfig,
histories: [],
stream: false,
maxRunTimes: WORKFLOW_MAX_RUN_TIMES
});
// Save chat
const aiResponse: AIChatItemType & { dataId?: string } = {
obj: ChatRoleEnum.AI,
value: assistantResponses,
[DispatchNodeResponseKeyEnum.nodeResponse]: flowResponses
};
const newTitle = isPlugin ? 'Mcp call' : getChatTitleFromChatMessage(userQuestion);
await saveChat({
chatId,
appId: app._id,
teamId: app.teamId,
tmbId: app.tmbId,
nodes,
appChatConfig: chatConfig,
variables: newVariables,
isUpdateUseTime: false, // owner update use time
newTitle,
source: ChatSourceEnum.mcp,
content: [userQuestion, aiResponse]
});
// Push usage
createChatUsage({
appName: app.name,
appId: app._id,
teamId: app.teamId,
tmbId: app.tmbId,
source: UsageSourceEnum.mcp,
flowUsages
});
// Get MCP response type
const responseContent = (() => {
if (isPlugin) {
const output = flowResponses.find(
(item) => item.moduleType === FlowNodeTypeEnum.pluginOutput
);
if (output) {
return JSON.stringify(output.pluginOutput);
} else {
return 'Can not get response from plugin';
}
}
return assistantResponses
.map((item) => item?.text?.content)
.filter(Boolean)
.join('\n');
})();
return responseContent;
};
async function handler(
req: ApiRequestProps<toolCallBody, toolCallQuery>,
res: ApiResponseType<any>
): Promise<toolCallResponse> {
const { key, toolName, inputs } = req.body;
const mcp = await MongoMcpKey.findOne({ key }, { apps: 1 }).lean();
if (!mcp) {
return Promise.reject(CommonErrEnum.invalidResource);
}
// Get app list
const appList = await MongoApp.find({
_id: { $in: mcp.apps.map((app) => app.appId) },
type: { $in: [AppTypeEnum.simple, AppTypeEnum.workflow, AppTypeEnum.plugin] }
}).lean();
const app = appList.find((app) => {
const mcpApp = mcp.apps.find((mcpApp) => String(mcpApp.appId) === String(app._id))!;
return toolName === mcpApp.toolName;
});
if (!app) {
return Promise.reject(CommonErrEnum.missingParams);
}
return await dispatchApp(app, inputs);
}
export default NextAPI(handler);

View File

@@ -0,0 +1,152 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { MongoMcpKey } from '@fastgpt/service/support/mcp/schema';
import { CommonErrEnum } from '@fastgpt/global/common/error/code/common';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { authAppByTmbId } from '@fastgpt/service/support/permission/app/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { getAppLatestVersion } from '@fastgpt/service/core/app/version/controller';
import { Tool } from '@modelcontextprotocol/sdk/types';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { toolValueTypeList } from '@fastgpt/global/core/workflow/constants';
import { AppChatConfigType } from '@fastgpt/global/core/app/type';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
export type listToolsQuery = { key: string };
export type listToolsBody = {};
export type listToolsResponse = {};
const pluginNodes2InputSchema = (nodes: StoreNodeItemType[]) => {
const pluginInput = nodes.find((node) => node.flowNodeType === FlowNodeTypeEnum.pluginInput);
const schema: Tool['inputSchema'] = {
type: 'object',
properties: {},
required: []
};
pluginInput?.inputs.forEach((input) => {
const jsonSchema = (
toolValueTypeList.find((type) => type.value === input.valueType) || toolValueTypeList[0]
)?.jsonSchema;
schema.properties![input.key] = {
...jsonSchema,
description: input.description,
enum: input.enum?.split('\n').filter(Boolean) || undefined
};
if (input.required) {
// @ts-ignore
schema.required.push(input.key);
}
});
return schema;
};
const workflow2InputSchema = (chatConfig?: AppChatConfigType) => {
const schema: Tool['inputSchema'] = {
type: 'object',
properties: {
question: {
type: 'string',
description: 'Question from user'
},
...(chatConfig?.fileSelectConfig?.canSelectFile || chatConfig?.fileSelectConfig?.canSelectImg
? {
fileUrlList: {
type: 'array',
items: {
type: 'string'
},
description: 'File linkage'
}
}
: {})
},
required: ['question']
};
chatConfig?.variables?.forEach((item) => {
const jsonSchema = (
toolValueTypeList.find((type) => type.value === item.valueType) || toolValueTypeList[0]
)?.jsonSchema;
schema.properties![item.key] = {
...jsonSchema,
description: item.description,
enum: item.enums?.map((enumItem) => enumItem.value) || undefined
};
if (item.required) {
// @ts-ignore
schema.required!.push(item.key);
}
});
return schema;
};
async function handler(
req: ApiRequestProps<listToolsBody, listToolsQuery>,
res: ApiResponseType<any>
): Promise<Tool[]> {
const { key } = req.query;
const mcp = await MongoMcpKey.findOne({ key }, { apps: 1 }).lean();
if (!mcp) {
return Promise.reject(CommonErrEnum.invalidResource);
}
// Get app list
const appList = await MongoApp.find(
{
_id: { $in: mcp.apps.map((app) => app.appId) },
type: { $in: [AppTypeEnum.simple, AppTypeEnum.workflow, AppTypeEnum.plugin] }
},
{ name: 1, intro: 1 }
).lean();
// Filter not permission app
const permissionAppList = await Promise.all(
appList.filter(async (app) => {
try {
await authAppByTmbId({ tmbId: mcp.tmbId, appId: app._id, per: ReadPermissionVal });
return true;
} catch (error) {
return false;
}
})
);
// Get latest version
const versionList = await Promise.all(
permissionAppList.map((app) => getAppLatestVersion(app._id, app))
);
// Compute mcp tools
const tools = versionList.map<Tool>((version, index) => {
const app = permissionAppList[index];
const mcpApp = mcp.apps.find((mcpApp) => String(mcpApp.appId) === String(app._id))!;
const isPlugin = !!version.nodes.find(
(node) => node.flowNodeType === FlowNodeTypeEnum.pluginInput
);
return {
name: mcpApp.toolName,
description: mcpApp.description,
inputSchema: isPlugin
? pluginNodes2InputSchema(version.nodes)
: workflow2InputSchema(version.chatConfig)
};
});
return tools;
}
export default NextAPI(handler);

View File

@@ -0,0 +1,66 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { authMcp } from '../../../../../../../packages/service/support/permission/mcp/auth';
import { ReadPermissionVal, WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { authAppByTmbId } from '@fastgpt/service/support/permission/app/auth';
import { MongoMcpKey } from '@fastgpt/service/support/mcp/schema';
import { McpAppType } from '@fastgpt/global/support/mcp/type';
export type updateQuery = {};
export type updateBody = {
id: string;
name: string;
apps: McpAppType[];
};
export type updateResponse = {};
async function handler(
req: ApiRequestProps<updateBody, updateQuery>,
res: ApiResponseType<any>
): Promise<updateResponse> {
let { id: mcpId, name, apps } = req.body;
const { tmbId } = await authMcp({
req,
authToken: true,
authApiKey: true,
mcpId,
per: WritePermissionVal
});
// 对 apps 中的 id 进行去重,确保每个应用只出现一次
const uniqueAppIds = new Set();
apps = apps.filter((app) => {
if (uniqueAppIds.has(app.appId)) {
return false; // 过滤掉重复的 app id
}
uniqueAppIds.add(app.appId);
return true;
});
// Check app read permission
await Promise.all(
apps.map((app) =>
authAppByTmbId({
tmbId,
appId: app.appId,
per: ReadPermissionVal
})
)
);
await MongoMcpKey.updateOne(
{ _id: mcpId },
{
$set: {
...(name && { name }),
apps
}
}
);
return {};
}
export default NextAPI(handler);

View File

@@ -11,7 +11,7 @@ import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type.d'
import {
getWorkflowEntryNodeIds,
getMaxHistoryLimitFromNodes,
initWorkflowEdgeStatus,
storeEdges2RuntimeEdges,
storeNodes2RuntimeNodes,
textAdaptGptResponse,
getLastInteractiveValue
@@ -289,9 +289,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
chatId,
responseChatItemId,
runtimeNodes,
runtimeEdges: initWorkflowEdgeStatus(edges, interactive),
runtimeEdges: storeEdges2RuntimeEdges(edges, interactive),
variables,
query: removeEmptyUserInput(userQuestion.value),
lastInteractive: interactive,
chatConfig,
histories: newHistories,
stream,

View File

@@ -11,7 +11,7 @@ import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type.d'
import {
getWorkflowEntryNodeIds,
getMaxHistoryLimitFromNodes,
initWorkflowEdgeStatus,
storeEdges2RuntimeEdges,
storeNodes2RuntimeNodes,
textAdaptGptResponse,
getLastInteractiveValue
@@ -288,7 +288,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
chatId,
responseChatItemId,
runtimeNodes,
runtimeEdges: initWorkflowEdgeStatus(edges, interactive),
runtimeEdges: storeEdges2RuntimeEdges(edges, interactive),
variables,
query: removeEmptyUserInput(userQuestion.value),
lastInteractive: interactive,

View File

@@ -21,6 +21,10 @@ const Plugin = dynamic(() => import('@/pageComponents/app/detail/Plugin'), {
ssr: false,
loading: () => <Loading fixed={false} />
});
const MCPTools = dynamic(() => import('@/pageComponents/app/detail/MCPTools'), {
ssr: false,
loading: () => <Loading fixed={false} />
});
const AppDetail = () => {
const { setAppId, setSource } = useChatStore();
@@ -42,6 +46,7 @@ const AppDetail = () => {
{appDetail.type === AppTypeEnum.simple && <SimpleEdit />}
{appDetail.type === AppTypeEnum.workflow && <Workflow />}
{appDetail.type === AppTypeEnum.plugin && <Plugin />}
{appDetail.type === AppTypeEnum.toolSet && <MCPTools />}
</>
)}
</Box>

View File

@@ -89,7 +89,7 @@ const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
// reset all chat tore
if (e?.code === 501) {
setLastChatAppId('');
router.replace('/app/list');
router.replace('/dashboard/apps');
} else {
router.replace({
query: {
@@ -259,7 +259,7 @@ const Render = (props: { appId: string; isStandalone?: string }) => {
status: 'error',
title: t('common:core.chat.You need to a chat app')
});
router.replace('/app/list');
router.replace('/dashboard/apps');
} else {
router.replace({
query: {

View File

@@ -0,0 +1,113 @@
import DashboardContainer from '@/pageComponents/dashboard/Container';
import PluginCard from '@/pageComponents/dashboard/SystemPlugin/ToolCard';
import { serviceSideProps } from '@/web/common/i18n/utils';
import { getSystemPlugTemplates } from '@/web/core/app/api/plugin';
import { Box, Flex, Grid } from '@chakra-ui/react';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useRouter } from 'next/router';
import { useMemo, useState } from 'react';
import { useTranslation } from 'next-i18next';
import MyBox from '@fastgpt/web/components/common/MyBox';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
const SystemTools = () => {
const { t } = useTranslation();
const router = useRouter();
const { type, pluginGroupId } = router.query as { type?: string; pluginGroupId?: string };
const { isPc } = useSystem();
const [searchKey, setSearchKey] = useState('');
const { data: plugins = [], loading: isLoading } = useRequest2(getSystemPlugTemplates, {
manual: false
});
const currentPlugins = useMemo(() => {
return plugins
.filter((plugin) => {
if (!type || type === 'all') return true;
return plugin.templateType === type;
})
.filter((item) => {
if (!searchKey) return true;
const regx = new RegExp(searchKey, 'i');
return regx.test(`${item.name}${item.intro}${item.instructions}`);
});
}, [plugins, searchKey, type]);
return (
<DashboardContainer>
{({ pluginGroups, MenuIcon }) => {
const currentGroup = pluginGroups.find((group) => group.groupId === pluginGroupId);
const groupTemplateTypeIds =
currentGroup?.groupTypes
?.map((type) => type.typeId)
.reduce(
(acc, cur) => {
acc[cur] = true;
return acc;
},
{} as Record<string, boolean>
) || {};
const filterPluginsByGroup = currentPlugins.filter((plugin) => {
if (!currentGroup) return true;
return groupTemplateTypeIds[plugin.templateType];
});
return (
<MyBox isLoading={isLoading} h={'100%'}>
<Box p={6} h={'100%'} overflowY={'auto'}>
<Flex alignItems={'center'} justifyContent={'space-between'}>
{isPc ? (
<Box fontSize={'lg'} color={'myGray.900'} fontWeight={500}>
{t('common:core.module.template.System Plugin')}
</Box>
) : (
MenuIcon
)}
<Box flex={'0 0 200px'}>
<SearchInput
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder={t('common:plugin.Search plugin')}
/>
</Box>
</Flex>
<Grid
gridTemplateColumns={[
'1fr',
'repeat(2,1fr)',
'repeat(2,1fr)',
'repeat(3,1fr)',
'repeat(4,1fr)'
]}
gridGap={4}
alignItems={'stretch'}
py={5}
>
{filterPluginsByGroup.map((item) => (
<PluginCard key={item.id} item={item} groups={pluginGroups} />
))}
</Grid>
{filterPluginsByGroup.length === 0 && <EmptyTip />}
</Box>
</MyBox>
);
}}
</DashboardContainer>
);
};
export default SystemTools;
export async function getServerSideProps(content: any) {
return {
props: {
...(await serviceSideProps(content, ['app']))
}
};
}

View File

@@ -11,7 +11,7 @@ import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { postCreateAppFolder } from '@/web/core/app/api/app';
import type { EditFolderFormType } from '@fastgpt/web/components/common/MyModal/EditFolderModal';
import { useContextSelector } from 'use-context-selector';
import AppListContextProvider, { AppListContext } from '@/pageComponents/app/list/context';
import AppListContextProvider, { AppListContext } from '@/pageComponents/dashboard/apps/context';
import FolderPath from '@/components/common/folder/Path';
import { useRouter } from 'next/router';
import FolderSlideCard from '@/components/common/folder/SlideCard';
@@ -22,25 +22,25 @@ import {
getCollaboratorList,
postUpdateAppCollaborators
} from '@/web/core/app/api/collaborator';
import type { CreateAppType } from '@/pageComponents/app/list/CreateModal';
import type { CreateAppType } from '@/pageComponents/dashboard/apps/CreateModal';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import MyBox from '@fastgpt/web/components/common/MyBox';
import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import MyIcon from '@fastgpt/web/components/common/Icon';
import TemplateMarketModal from '@/pageComponents/app/list/TemplateMarketModal';
import MyImage from '@fastgpt/web/components/common/Image/MyImage';
import JsonImportModal from '@/pageComponents/app/list/JsonImportModal';
import JsonImportModal from '@/pageComponents/dashboard/apps/JsonImportModal';
import { PermissionValueType } from '@fastgpt/global/support/permission/type';
import DashboardContainer from '@/pageComponents/dashboard/Container';
import List from '@/pageComponents/dashboard/apps/List';
import MCPToolsEditModal from '@/pageComponents/dashboard/apps/MCPToolsEditModal';
const CreateModal = dynamic(() => import('@/pageComponents/app/list/CreateModal'));
const CreateModal = dynamic(() => import('@/pageComponents/dashboard/apps/CreateModal'));
const EditFolderModal = dynamic(
() => import('@fastgpt/web/components/common/MyModal/EditFolderModal')
);
const HttpEditModal = dynamic(() => import('@/pageComponents/app/list/HttpPluginEditModal'));
const List = dynamic(() => import('@/pageComponents/app/list/List'));
const HttpEditModal = dynamic(() => import('@/pageComponents/dashboard/apps/HttpPluginEditModal'));
const MyApps = () => {
const MyApps = ({ MenuIcon }: { MenuIcon: JSX.Element }) => {
const { t } = useTranslation();
const router = useRouter();
const { isPc } = useSystem();
@@ -66,13 +66,17 @@ const MyApps = () => {
onOpen: onOpenCreateHttpPlugin,
onClose: onCloseCreateHttpPlugin
} = useDisclosure();
const {
isOpen: isOpenCreateMCPTools,
onOpen: onOpenCreateMCPTools,
onClose: onCloseCreateMCPTools
} = useDisclosure();
const {
isOpen: isOpenJsonImportModal,
onOpen: onOpenJsonImportModal,
onClose: onCloseJsonImportModal
} = useDisclosure();
const [editFolder, setEditFolder] = useState<EditFolderFormType>();
const [templateModalType, setTemplateModalType] = useState<AppTypeEnum | 'all'>();
const { runAsync: onCreateFolder } = useRequest2(postCreateAppFolder, {
onSuccess() {
@@ -91,6 +95,19 @@ const MyApps = () => {
errorToast: 'Error'
});
const appTypeName = useMemo(() => {
const map: Record<AppTypeEnum | 'all', string> = {
all: t('common:core.module.template.Team app'),
[AppTypeEnum.simple]: t('app:type.Simple bot'),
[AppTypeEnum.workflow]: t('app:type.Workflow bot'),
[AppTypeEnum.plugin]: t('app:type.Plugin'),
[AppTypeEnum.httpPlugin]: t('app:type.Http plugin'),
[AppTypeEnum.folder]: t('common:Folder'),
[AppTypeEnum.toolSet]: t('app:type.MCP tools'),
[AppTypeEnum.tool]: t('app:type.MCP tools')
};
return map[appType] || map['all'];
}, [appType, t]);
const RenderSearchInput = useMemo(
() => (
<InputGroup maxW={['auto', '250px']} position={'relative'}>
@@ -120,10 +137,11 @@ const MyApps = () => {
return (
<Flex flexDirection={'column'} h={'100%'}>
{paths.length > 0 && (
<Box pt={[4, 6]} pl={3}>
<Box pt={[4, 6]} pl={5}>
<FolderPath
paths={paths}
hoverStyle={{ bg: 'myGray.200' }}
forbidLastClick
onClick={(parentId) => {
router.push({
query: {
@@ -140,78 +158,23 @@ const MyApps = () => {
flex={'1 0 0'}
flexDirection={'column'}
h={'100%'}
pr={folderDetail ? [3, 2] : [3, 8]}
pl={3}
pr={folderDetail ? [3, 2] : [3, 6]}
pl={6}
overflowY={'auto'}
overflowX={'hidden'}
>
<Flex pt={paths.length > 0 ? 3 : [4, 6]} alignItems={'center'} gap={3}>
<LightRowTabs
list={[
{
label: t('app:type.All'),
value: 'ALL'
},
{
label: t('app:type.Simple bot'),
value: AppTypeEnum.simple
},
{
label: t('app:type.Workflow bot'),
value: AppTypeEnum.workflow
},
{
label: t('app:type.Plugin'),
value: AppTypeEnum.plugin
}
]}
value={appType}
inlineStyles={{ px: 0.5 }}
gap={5}
display={'flex'}
alignItems={'center'}
fontSize={['sm', 'md']}
flexShrink={0}
onChange={(e) => {
router.push({
query: {
...router.query,
type: e
}
});
}}
/>
{isPc ? (
<Box fontSize={'lg'} color={'myGray.900'} fontWeight={500}>
{appTypeName}
</Box>
) : (
MenuIcon
)}
<Box flex={1} />
{isPc && RenderSearchInput}
{isPc && (
<Flex
alignItems={'center'}
gap={1.5}
border={'1px solid'}
borderColor={'myGray.250'}
h={9}
px={4}
fontSize={'14px'}
fontWeight={'medium'}
bg={'white'}
rounded={'sm'}
cursor={'pointer'}
boxShadow={
'0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
_hover={{
bg: 'primary.50',
color: 'primary.600'
}}
onClick={() => setTemplateModalType('all')}
>
<MyImage src={'/imgs/app/templateFill.svg'} w={'18px'} />
{t('app:template_market')}
</Flex>
)}
{(folderDetail
? folderDetail.permission.hasWritePer && folderDetail?.type !== AppTypeEnum.httpPlugin
: userInfo?.team.permission.hasAppCreatePer) && (
@@ -248,6 +211,12 @@ const MyApps = () => {
label: t('app:type.Http plugin'),
description: t('app:type.Create http plugin tip'),
onClick: onOpenCreateHttpPlugin
},
{
icon: 'core/app/type/mcpToolsFill',
label: t('app:type.MCP tools'),
description: t('app:type.Create mcp tools tip'),
onClick: onOpenCreateMCPTools
}
]
},
@@ -261,20 +230,6 @@ const MyApps = () => {
}
]
},
...(isPc
? []
: [
{
children: [
{
icon: '/imgs/app/templateFill.svg',
label: t('app:template_market'),
description: t('app:template_market_description'),
onClick: () => setTemplateModalType('all')
}
]
}
]),
{
children: [
{
@@ -379,19 +334,10 @@ const MyApps = () => {
/>
)}
{!!createAppType && (
<CreateModal
type={createAppType}
onClose={() => setCreateAppType(undefined)}
onOpenTemplateModal={setTemplateModalType}
/>
<CreateModal type={createAppType} onClose={() => setCreateAppType(undefined)} />
)}
{isOpenCreateHttpPlugin && <HttpEditModal onClose={onCloseCreateHttpPlugin} />}
{!!templateModalType && (
<TemplateMarketModal
onClose={() => setTemplateModalType(undefined)}
defaultType={templateModalType}
/>
)}
{isOpenCreateMCPTools && <MCPToolsEditModal onClose={onCloseCreateMCPTools} />}
{isOpenJsonImportModal && <JsonImportModal onClose={onCloseJsonImportModal} />}
</Flex>
);
@@ -399,9 +345,13 @@ const MyApps = () => {
function ContextRender() {
return (
<AppListContextProvider>
<MyApps />
</AppListContextProvider>
<DashboardContainer>
{({ MenuIcon }) => (
<AppListContextProvider>
<MyApps MenuIcon={MenuIcon} />
</AppListContextProvider>
)}
</DashboardContainer>
);
}

View File

@@ -0,0 +1,179 @@
import { serviceSideProps } from '@/web/common/i18n/utils';
import React, { useState } from 'react';
import DashboardContainer from '@/pageComponents/dashboard/Container';
import {
Box,
Button,
Flex,
HStack,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr
} from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { deleteMcpServer, getMcpServerList } from '@/web/support/mcp/api';
import MyBox from '@fastgpt/web/components/common/MyBox';
import EditMcpModal, { defaultForm, EditMcForm } from '@/pageComponents/dashboard/mcp/EditModal';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import dynamic from 'next/dynamic';
import { McpKeyType } from '@fastgpt/global/support/mcp/type';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
const UsageWay = dynamic(() => import('@/pageComponents/dashboard/mcp/usageWay'), {
ssr: false
});
const McpServer = () => {
const { t } = useTranslation();
const { isPc } = useSystem();
const {
data: mcpServerList = [],
loading: loadingList,
refresh: loadMcpList
} = useRequest2(getMcpServerList, {
manual: false
});
const [editMcp, setEditMcp] = useState<EditMcForm>();
const [usageWay, setUsageWay] = useState<McpKeyType>();
const { openConfirm: openDelConfirm, ConfirmModal: DelConfirmModal } = useConfirm({
type: 'delete',
content: t('dashboard_mcp:delete_mcp_server_confirm_tip')
});
const { runAsync: onDeleteMcpServer } = useRequest2(deleteMcpServer, {
manual: true,
onSuccess: () => {
loadMcpList();
}
});
const isLoading = loadingList;
return (
<>
<DashboardContainer>
{({ MenuIcon }) => (
<MyBox isLoading={isLoading} h={'100%'} p={6}>
{isPc ? (
<Flex alignItems={'flex-end'} justifyContent={'space-between'}>
<Box>
<Box fontSize={'lg'} color={'myGray.900'} fontWeight={500}>
{t('dashboard_mcp:mcp_server')}
</Box>
<Box fontSize={'xs'} color={'myGray.500'}>
{t('dashboard_mcp:mcp_server_description')}
</Box>
</Box>
<Button onClick={() => setEditMcp(defaultForm)}>
{t('dashboard_mcp:create_mcp_server')}
</Button>
</Flex>
) : (
<>
<HStack>
<Box>{MenuIcon}</Box>
<Box fontSize={'lg'} color={'myGray.900'} fontWeight={500}>
{t('dashboard_mcp:mcp_server')}
</Box>
</HStack>
<Box fontSize={'xs'} color={'myGray.500'}>
{t('dashboard_mcp:mcp_server_description')}
</Box>
<Flex mt={2} justifyContent={'flex-end'}>
<Button onClick={() => setEditMcp(defaultForm)}>
{t('dashboard_mcp:create_mcp_server')}
</Button>
</Flex>
</>
)}
{/* table */}
<TableContainer mt={4} bg={'white'} borderRadius={'md'}>
<Table>
<Thead>
<Tr borderBottom={'base'}>
<Th bg={'white'}>{t('dashboard_mcp:mcp_name')}</Th>
<Th bg={'white'}>{t('dashboard_mcp:mcp_apps')}</Th>
<Th bg={'white'}></Th>
</Tr>
</Thead>
<Tbody fontSize={'sm'}>
{mcpServerList.map((mcp) => {
return (
<Tr key={mcp._id} fontWeight={500} fontSize={'sm'} color={'myGray.900'}>
<Td>{mcp.name}</Td>
<Td>{mcp.apps.length}</Td>
<Td>
<HStack>
<Button
mr={4}
variant={'whiteBase'}
size={'sm'}
onClick={() => setUsageWay(mcp)}
>
{t('dashboard_mcp:start_use')}
</Button>
<MyIconButton
icon="edit"
onClick={() =>
setEditMcp({
id: mcp._id,
name: mcp.name,
apps: mcp.apps
})
}
/>
<MyIconButton
icon="delete"
hoverColor={'red.600'}
onClick={() => openDelConfirm(() => onDeleteMcpServer(mcp._id))()}
/>
</HStack>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
{mcpServerList.length === 0 && <EmptyTip />}
</TableContainer>
</MyBox>
)}
</DashboardContainer>
<DelConfirmModal />
{!!usageWay && <UsageWay mcp={usageWay} onClose={() => setUsageWay(undefined)} />}
{!!editMcp && (
<EditMcpModal
editMcp={editMcp}
onClose={() => setEditMcp(undefined)}
onSuccess={() => {
setEditMcp(undefined);
loadMcpList();
}}
/>
)}
</>
);
};
export default McpServer;
export async function getServerSideProps(content: any) {
return {
props: {
...(await serviceSideProps(content, ['dashboard_mcp']))
}
};
}

View File

@@ -0,0 +1,348 @@
import { serviceSideProps } from '@/web/common/i18n/utils';
import DashboardContainer from '@/pageComponents/dashboard/Container';
import { Box, Button, Flex, Grid, HStack } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { ParentIdType } from '@fastgpt/global/common/parentFolder/type';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { AppTemplateSchemaType, TemplateTypeSchemaType } from '@fastgpt/global/core/app/type';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { getTemplateMarketItemDetail } from '@/web/core/app/api/template';
import { postCreateApp } from '@/web/core/app/api';
import { webPushTrack } from '@/web/common/middle/tracks/utils';
import Avatar from '@fastgpt/web/components/common/Avatar';
import AppTypeTag from '@/pageComponents/dashboard/apps/TypeTag';
import dynamic from 'next/dynamic';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import MySelect from '@fastgpt/web/components/common/MySelect';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
const UseGuideModal = dynamic(() => import('@/components/common/Modal/UseGuideModal'), {
ssr: false
});
const TemplateMarket = ({
templateList,
templateTags,
MenuIcon
}: {
templateList: AppTemplateSchemaType[];
templateTags: TemplateTypeSchemaType[];
MenuIcon: JSX.Element;
}) => {
const router = useRouter();
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const { isPc } = useSystem();
const containerRef = useRef<HTMLDivElement>(null);
const {
parentId,
type,
appType = 'all'
} = router.query as { parentId?: ParentIdType; type?: string; appType?: AppTypeEnum | 'all' };
const [searchKey, setSearchKey] = useState('');
const filterTemplateTags = useMemo(() => {
return templateTags
.map((tag) => {
const templates = templateList.filter((template) => template.tags.includes(tag.typeId));
return {
...tag,
templates
};
})
.filter((item) => item.templates.length > 0);
}, [templateList, templateTags]);
const { runAsync: onUseTemplate, loading: isCreating } = useRequest2(
async (template: AppTemplateSchemaType) => {
const templateDetail = await getTemplateMarketItemDetail(template.templateId);
return postCreateApp({
parentId,
avatar: template.avatar,
name: template.name,
type: template.type as AppTypeEnum,
modules: templateDetail.workflow.nodes || [],
edges: templateDetail.workflow.edges || [],
chatConfig: templateDetail.workflow.chatConfig
}).then((res) => {
webPushTrack.useAppTemplate({
id: res,
name: template.name
});
return res;
});
},
{
onSuccess(id: string) {
router.push(`/app/detail?appId=${id}`);
},
successToast: t('common:common.Create Success'),
errorToast: t('common:common.Create Failed')
}
);
const TemplateCard = useCallback(
({ item }: { item: AppTemplateSchemaType }) => {
const { t } = useTranslation();
return (
<MyBox
key={item.templateId}
lineHeight={1.5}
h="100%"
pt={4}
pb={3}
px={4}
border={'base'}
boxShadow={'2'}
bg={'white'}
borderRadius={'10px'}
position={'relative'}
display={'flex'}
flexDirection={'column'}
_hover={{
borderColor: 'primary.300',
boxShadow: '1.5',
'& .buttons': {
display: 'flex'
}
}}
>
<HStack>
<Avatar src={item.avatar} borderRadius={'sm'} w={'1.5rem'} h={'1.5rem'} />
<Box flex={'1 0 0'} color={'myGray.900'} fontWeight={500}>
{item.name}
</Box>
<Box mr={'-1rem'}>
<AppTypeTag type={item.type as AppTypeEnum} />
</Box>
</HStack>
<Box
flex={['1 0 48px', '1 0 56px']}
mt={3}
pr={1}
textAlign={'justify'}
wordBreak={'break-all'}
fontSize={'xs'}
color={'myGray.500'}
>
<Box className={'textEllipsis2'}>{item.intro || t('app:templateMarket.no_intro')}</Box>
</Box>
<Box w={'full'} fontSize={'mini'}>
<Box color={'myGray.500'}>{`by ${item.author || feConfigs.systemTitle}`}</Box>
<Flex
className="buttons"
display={'none'}
justifyContent={'center'}
alignItems={'center'}
position={'absolute'}
borderRadius={'lg'}
w={'full'}
h={'full'}
left={0}
right={0}
bottom={1}
height={'40px'}
bg={'white'}
zIndex={1}
gap={2}
>
{((item.userGuide?.type === 'markdown' && item.userGuide?.content) ||
(item.userGuide?.type === 'link' && item.userGuide?.link)) && (
<UseGuideModal
title={item.name}
iconSrc={item.avatar}
text={item.userGuide?.content}
link={item.userGuide?.link}
>
{({ onClick }) => (
<Button variant={'whiteBase'} h={6} rounded={'sm'} onClick={onClick}>
{t('app:templateMarket.template_guide')}
</Button>
)}
</UseGuideModal>
)}
<Button
variant={'whiteBase'}
h={6}
rounded={'sm'}
onClick={() => onUseTemplate(item)}
>
{t('app:templateMarket.Use')}
</Button>
</Flex>
</Box>
</MyBox>
);
},
[onUseTemplate, feConfigs.systemTitle]
);
// Scroll to the selected template type
useEffect(() => {
if (type) {
const typeElement = document.getElementById(type as string);
if (typeElement) {
typeElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
}, [type]);
return (
<MyBox ref={containerRef} h={'100%'} isLoading={isCreating}>
<Flex flexDirection={'column'} h={'100%'} py={5}>
<Flex mb={4} alignItems={'center'} px={5}>
{isPc ? (
<Box fontSize={'lg'} color={'myGray.900'} fontWeight={500}>
{t('app:template_market')}
</Box>
) : (
MenuIcon
)}
<Box flex={1} />
<Box mr={3}>
<SearchInput
h={'34px'}
bg={'white'}
placeholder={t('app:templateMarket.Search_template')}
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
/>
</Box>
<MySelect
h={'34px'}
bg={'white'}
value={appType}
list={[
{
value: 'all',
label: t('app:type.All')
},
{
value: AppTypeEnum.simple,
label: t('app:type.Simple bot')
},
{
value: AppTypeEnum.workflow,
label: t('app:type.Workflow bot')
},
{
value: AppTypeEnum.plugin,
label: t('app:type.Plugin')
}
]}
onChange={(e) => {
router.push({
query: {
...router.query,
type: '',
appType: e
}
});
}}
/>
</Flex>
<Box flex={'1 0 0'} px={5} overflow={'auto'}>
{searchKey ? (
<>
<Box fontSize={'lg'} color={'myGray.900'} mb={4}>
{t('common:xx_search_result', { key: searchKey })}
</Box>
{(() => {
const templates = templateList.filter((template) =>
`${template.name}${template.intro}`.includes(searchKey)
);
if (templates.length > 0) {
return (
<Grid
gridTemplateColumns={[
'1fr',
'repeat(2,1fr)',
'repeat(3,1fr)',
'repeat(3,1fr)',
'repeat(4,1fr)'
]}
gridGap={4}
alignItems={'stretch'}
pb={5}
>
{templates.map((item) => (
<TemplateCard key={item.templateId} item={item} />
))}
</Grid>
);
}
return <EmptyTip text={t('app:template_market_empty_data')} />;
})()}
</>
) : (
<>
{filterTemplateTags.map((item) => {
return (
<Box key={item.typeId}>
<Box id={item.typeId} color={'myGray.900'} mb={4} fontWeight={500} pt={2}>
{t(item.typeName as any)}
</Box>
<Grid
gridTemplateColumns={[
'1fr',
'repeat(2,1fr)',
'repeat(3,1fr)',
'repeat(3,1fr)',
'repeat(4,1fr)'
]}
gridGap={4}
alignItems={'stretch'}
pb={5}
>
{item.templates.map((item) => (
<TemplateCard key={item.templateId} item={item} />
))}
</Grid>
</Box>
);
})}
</>
)}
</Box>
</Flex>
</MyBox>
);
};
const TemplateMarketContainer = ({ children }: { children: React.ReactNode }) => {
return (
<DashboardContainer>
{({ templateTags, templateList, MenuIcon }) => (
<TemplateMarket
templateTags={templateTags}
templateList={templateList}
MenuIcon={MenuIcon}
/>
)}
</DashboardContainer>
);
};
export default TemplateMarketContainer;
export async function getServerSideProps(content: any) {
return {
props: {
...(await serviceSideProps(content, ['app']))
}
};
}

View File

@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
const index = () => {
const router = useRouter();
useEffect(() => {
router.push('/app/list');
router.push('/dashboard/apps');
}, [router]);
return <Loading></Loading>;
};

View File

@@ -77,7 +77,7 @@ export async function getServerSideProps(content: any) {
props: {
code: content?.query?.code || '',
token: content?.query?.token || '',
callbackUrl: content?.query?.callbackUrl || '/app/list',
callbackUrl: content?.query?.callbackUrl || '/dashboard/apps',
...(await serviceSideProps(content))
}
};

View File

@@ -66,7 +66,9 @@ const Login = ({ ChineseRedirectUrl }: { ChineseRedirectUrl: string }) => {
const decodeLastRoute = decodeURIComponent(lastRoute);
// 检查是否是当前的 route
const navigateTo =
decodeLastRoute && !decodeLastRoute.includes('/login') ? decodeLastRoute : '/app/list';
decodeLastRoute && !decodeLastRoute.includes('/login')
? decodeLastRoute
: '/dashboard/apps';
router.push(navigateTo);
},
[setUserInfo, lastRoute, router]
@@ -129,7 +131,7 @@ const Login = ({ ChineseRedirectUrl }: { ChineseRedirectUrl: string }) => {
useMount(() => {
clearToken();
router.prefetch('/app/list');
router.prefetch('/dashboard/apps');
ChineseRedirectUrl && showRedirect && checkIpInChina();
localCookieVersion !== cookieVersion && onOpenCookiesDrawer();

View File

@@ -26,7 +26,9 @@ const provider = () => {
(res: ResLogin) => {
setUserInfo(res.user);
router.push(loginStore?.lastRoute ? decodeURIComponent(loginStore?.lastRoute) : '/app/list');
router.push(
loginStore?.lastRoute ? decodeURIComponent(loginStore?.lastRoute) : '/dashboard/apps'
);
},
[setUserInfo, router, loginStore?.lastRoute]
);
@@ -95,7 +97,7 @@ const provider = () => {
(async () => {
await clearToken();
router.prefetch('/app/list');
router.prefetch('/dashboard/apps');
if (loginStore && loginStore.provider !== 'sso' && state !== loginStore.state) {
toast({

View File

@@ -1,226 +0,0 @@
import { serviceSideProps } from '@/web/common/i18n/utils';
import { getPluginGroups, getSystemPlugTemplates } from '@/web/core/app/api/plugin';
import { Box, Flex, Grid, useDisclosure } from '@chakra-ui/react';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useMemo, useState } from 'react';
import PluginCard from '@/pageComponents/toolkit/PluginCard';
import { i18nT } from '@fastgpt/web/i18n/utils';
import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import { navbarWidth } from '@/components/Layout';
const Toolkit = () => {
const { t } = useTranslation();
const router = useRouter();
const { isPc } = useSystem();
const { data: plugins = [] } = useRequest2(getSystemPlugTemplates, {
manual: false
});
const { data: pluginGroups = [] } = useRequest2(getPluginGroups, {
manual: false
});
const isOneGroup = pluginGroups.length === 1;
const [search, setSearch] = useState('');
const { isOpen, onOpen, onClose } = useDisclosure();
const { group: selectedGroup = pluginGroups?.[0]?.groupId, type: selectedType = 'all' } =
router.query;
const pluginGroupTypes = useMemo(() => {
const allTypes = [
{
typeId: 'all',
typeName: i18nT('common:common.All')
}
];
const currentTypes =
pluginGroups?.find((group) => group.groupId === selectedGroup)?.groupTypes ?? [];
return [
...allTypes,
...currentTypes.filter((type) =>
plugins.find((plugin) => plugin.templateType === type.typeId)
)
];
}, [pluginGroups, plugins, selectedGroup]);
const currentPlugins = useMemo(() => {
const typeArray = pluginGroupTypes?.map((type) => type.typeId);
return plugins
.filter(
(plugin) =>
(selectedType === 'all' && typeArray?.includes(plugin.templateType)) ||
selectedType === plugin.templateType
)
.filter((plugin) => {
const str = `${plugin.name}${plugin.intro}${plugin.instructions}`;
const regx = new RegExp(search, 'gi');
return regx.test(str);
});
}, [pluginGroupTypes, plugins, selectedType, search]);
return (
<Flex flexDirection={'column'} h={'100%'} overflow={'auto'}>
{/* Mask */}
{!isPc && isOpen && (
<Box
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
bg="blackAlpha.600"
onClick={onClose}
zIndex={99}
/>
)}
{/* Sidebar */}
{(isPc || isOpen) && (
<Box
position={'fixed'}
left={isPc ? navbarWidth : 0}
top={0}
bg={'myGray.25'}
w={['60vw', '200px']}
h={'full'}
borderLeft={'1px solid'}
borderRight={'1px solid'}
borderColor={'myGray.200'}
pt={4}
px={2.5}
pb={2.5}
zIndex={100}
userSelect={'none'}
>
{pluginGroups.map((group) => {
const selected = group.groupId === selectedGroup;
return (
<Box key={group.groupId}>
<Flex
p={2}
mb={0.5}
fontSize={'sm'}
rounded={'md'}
color={'myGray.900'}
{...(!isOneGroup && {
cursor: 'pointer',
_hover: {
bg: 'primary.50'
},
onClick: () => {
router.push({
query: { group: group.groupId, type: 'all' }
});
onClose();
}
})}
>
<Avatar src={group.groupAvatar} w={'1rem'} mr={1.5} color={'primary.600'} />
<Box>{t(group.groupName as any)}</Box>
<Box flex={1} />
{!isOneGroup && (
<MyIcon
color={'myGray.600'}
name={selected ? 'core/chat/chevronDown' : 'core/chat/chevronUp'}
w={'1rem'}
/>
)}
</Flex>
{/* group types */}
{selected &&
pluginGroupTypes.map((type) => {
return (
<Flex
key={type.typeId}
fontSize={'14px'}
fontWeight={500}
rounded={'md'}
py={2}
pl={'30px'}
cursor={'pointer'}
mb={0.5}
_hover={{ bg: 'primary.50' }}
{...(type.typeId === selectedType
? {
bg: 'primary.50',
color: 'primary.600'
}
: {
bg: 'transparent',
color: 'myGray.500'
})}
onClick={() => {
router.push({
query: { group: selectedGroup, type: type.typeId }
});
onClose();
}}
>
{t(type.typeName as any)}
</Flex>
);
})}
</Box>
);
})}
</Box>
)}
<Box ml={[0, '200px']} p={[5, 6]}>
<Flex alignItems={'center'}>
<Flex flex={1} fontSize={'xl'} fontWeight={'medium'} color={'myGray.900'}>
{isPc ? (
<Box>
{t(
pluginGroups?.find((group) => group.groupId === selectedGroup)?.groupName as any
)}
</Box>
) : (
<MyIcon name="menu" w={'20px'} mr={1.5} onClick={onOpen} />
)}
</Flex>
<Box w={['60vw', '260px']}>
<SearchInput
value={search}
bg={'white'}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('common:plugin.Search plugin')}
/>
</Box>
</Flex>
<Grid
gridTemplateColumns={[
'1fr',
'repeat(2,1fr)',
'repeat(2,1fr)',
'repeat(3,1fr)',
'repeat(4,1fr)'
]}
gridGap={4}
alignItems={'stretch'}
py={5}
>
{currentPlugins.map((item) => (
<PluginCard key={item.id} item={item} groups={pluginGroups} />
))}
</Grid>
</Box>
</Flex>
);
};
export default Toolkit;
export async function getServerSideProps(context: any) {
return {
props: {
...(await serviceSideProps(context, ['app', 'user']))
}
};
}