* feat: Add portal management related icons

* feat: Add portal configuration pages and related translations

* feat: Add new gateway configuration components and icons

- Introduced `ConfigButtons` component for save and share actions with new SVG icons.
- Added `CopyrightTable` and `HomeTable` components for managing copyright and home settings.
- Implemented `SectionHeader` for consistent section titles in the gateway configuration.
- Updated `FillRowTabs` to support new tabs for home and copyright configurations.
- Modified translations for gateway-related terms in English, Simplified Chinese, and Traditional Chinese.
- Removed unused gateway tab from `AccountContainer`.

* feat(gate): add API and schema for team gate configurations

- Introduced new TypeScript definitions for gate configuration parameters and data structures.
- Created constants for gate status and tools.
- Implemented MongoDB schema for team gate configurations.
- Added API functions for getting, creating, updating, and deleting team gate configurations and logos.
- Developed ShareGateModal component for sharing portal links and custom domains.
- Updated ConfigButtons component to handle saving configurations and opening the share modal.
- Added new icons for gate functionalities.
- Updated English and Chinese translations for gateway-related texts.

* feat(gate): refactor gate configuration API and remove unused logo handling

* feat(gate): enhance team gate configuration with new error handling and chat features

- Added new error codes to CommonErrEnum for method not allowed, system error, and unauthorized access.
- Updated datasetErr to include corresponding error messages for new error codes.
- Refactored API to support updating team gate configurations and copyright information.
- Introduced ChatInputBox component for chat functionalities, including file and image uploads.
- Enhanced HomeTable and CopyrightTable components to manage settings more effectively.
- Updated translations for new terms in English and Chinese.
- Improved layout and user experience in the gateway configuration pages.

* feat: Refactor gateway configuration and chat components

- Replaced direct API calls with Zustand store for gate configuration management.
- Introduced `useGateStore` for managing gate and copyright configurations.
- Updated `GatewayConfig` component to utilize the new store and remove redundant state management.
- Enhanced chat functionality in `application.tsx` and `index.tsx` to support gate model.
- Created new `application.tsx` for handling chat interactions with the gate application.
- Improved error handling and loading states in chat components.
- Added dynamic imports for better performance and code splitting.

* feat(gate): update GateSideBar to conditionally render recent apps based on chat page state

* fix(HomeTable): comment out unused FormControl for better readability

* feat(gate): enhance copyright configuration and file upload functionality

- Updated ConfigButtons to handle team avatar updates and save copyright configurations.
- Refactored CopyrightTable to integrate file selection for team avatars and improve form handling.
- Added animations and hover effects for better user experience during file uploads.
- Improved toast notifications for success and error handling in configuration processes.

* feat(gate): add gate service availability check and update translations

- Implemented gate service availability check in application and index pages, redirecting users if the service is unavailable.
- Added new translation keys for gate service status in English and Chinese.
- Refactored GateSideBar to improve rendering logic for recent apps based on gate status.

* feat(chat): add route check to ToolMenu for app detail visibility

- Implemented a check to prevent displaying app details when the current route starts with '/chat/gate'.
- Updated menu rendering logic to conditionally show app details based on the new route check.

* feat(constants): add 'gate' type to AppTypeEnum

* refactor: rename "Portal" to "Gate" across the application

- Updated schema to remove the slogan field from GateConfigSchema.
- Modified SVG icon dimensions for gateLight.svg.
- Changed localization keys and values from "Portal" to "Gate" in various JSON files.
- Added support for gate applications in the app creation and management logic.
- Enhanced ChatBox component to handle gate-specific routes and configurations.
- Updated ConfigButtons to manage gate configurations and intros.
- Adjusted ShareGateModal to generate correct gate URLs.
- Expanded emptyTemplates to include gate-specific templates and configurations.
- Refactored chatItemContext to include intro for gate applications.
- Updated useGateStore to initialize gate configurations with intros from existing gate applications.

* fix: add isResponseDetail prop to ChatItemContextProvider

* feat: refactor gate-related API and components for improved functionality

* feat: 添加工具选择和工具选择模态框组件

* refactor: Update GateConfig related types, remove unnecessary constants and enums

* feat: Enhance Gate configuration components and API integration

- Updated ConfigButtons and HomeTable to use string arrays for tools instead of GateTool type.
- Implemented batch plugin loading in HomeTable with error handling.
- Added ToolSelect and ToolSelectModal components for improved tool management.
- Introduced AppCard and ChatTest components for app detail editing.
- Enhanced Edit and EditForm components for better app configuration management.
- Added new API endpoint for batch plugin retrieval.
- Improved overall structure and styling for better user experience.

* fix: Update ChatBoxDataType to make intro optional in chatItemContext.tsx

* fix: Add isResponseDetail prop to ChatItemContextProvider in ChatPage component

* feat: Enhance ToolSelectModal and GatewayConfig with new functionalities

- Updated ToolSelectModal to handle tool selection and configuration, integrating new props for selected tools and chat configuration.
- Implemented loading and error handling for Gate applications in GatewayConfig, including a retry mechanism for fetching apps.
- Added selectedTool parameter to chat completions API to enable tool activation during chat.
- Refactored chat component to support app form context and debug mode for testing.
- Enhanced useGateStore to manage gate applications, including loading and updating functionalities.

* feat: Refactor GateSideBar to enhance recent apps display and add resource selection

* refactor: 移除门户删除确认功能

* feat: 更新 Chat 组件以使用 AppContextProvider 并修正 localAppDetail 的类型

* refactor: Remove the tool menu logic in the GateChatInput component to simplify the code structure

* refactor:
Remove the tool menu logic from the GateChatInput component to simplify the code structure

* feat:
Simplify the ShareGateModal component by removing unused states and logic

* fix: Update chatGray.svg to remove fill attributes for paths, improving SVG structure

* feat: Added new chat icons and updated internationalized text to support new chat features

* feat: Refactor chat components and introduce GateChat functionality

- Updated ChatHistorySlider to remove isGateRoute check for PC view.
- Added new GateChatHistorySlider component for handling chat history in gate context.
- Removed obsolete ChatPage component related to gate chat.
- Modified GateSideBar styles for improved UI consistency.
- Implemented new API endpoint for chat gate functionality.
- Refactored chat gate index page to utilize GateChatHistorySlider and streamline chat initialization.
- Cleaned up unused imports and code related to debugging and legacy chat handling.

* feat: Update GateSideBar styles for improved responsiveness and animation

- Adjusted width and padding for collapsed and expanded states.
- Enhanced transition effects for smoother UI interactions.
- Modified alignment and positioning of navigation items and user profile for better layout consistency.
- Improved accessibility by ensuring elements are centered when collapsed.

* feat: 添加新的聊天图标和更新分享门户组件样式以提升用户体验

* Refactor chat gate components and implement sidebar functionality

- Updated ChatGate component to use ChatItemContextProvider and ChatRecordContextProvider for better context management.
- Introduced FoldButton component for sidebar collapsing functionality.
- Created GateNavBar component to replace GateSideBar for improved navigation.
- Refactored GateSideBar to handle folding state and external triggers.
- Updated application and index pages to integrate new components and manage sidebar state.
- Enhanced useChatGate hook to include appDetail.intro.

* feat: Updated team structure, set default banner image and refactored LogoBox component to support diagonal background

* feat: Enhance GateNavBar with user popover functionality and logout feature

- Added user popover for displaying user information and logout option.
- Implemented mouse enter/leave handlers for popover visibility.
- Updated user profile section to include popover and improved layout.
- Modified index page to include 'account' in server-side props for better context management.

* feat: Add a bottom line statement in the ChatBox component to remind users that the content is generated by third-party AI

* feat: Update placeholder text in ChatBox and GateChatInput components for better user guidance

- Added internationalized placeholder text for user input in both English and Chinese.
- Updated ChatBox and GateChatInput components to utilize the new placeholder text from localization files.

* feat: Add upload icon and enhance ChatBox layout for better user experience

- Introduced a new upload icon in the Icon component for improved visual representation.
- Updated ChatBox layout to enhance responsiveness and user interaction, including adjustments to padding and structure.
- Added hover overlay effect for logo upload areas in the CopyrightTable component to improve user guidance.

* feat: Refactor Chat component to integrate GateSideBar and GateChatHistorySlider for improved layout and functionality

* refactor: Update imports to use 'import type' for type-only imports across multiple files

- Changed standard imports to type imports for better clarity and performance in TypeScript.
- Updated files in the global support, service, and app components to reflect this change.

* feat: Update localization strings and improve toast messages for better user feedback

- Added new success and failure messages for create, delete, save, and update actions in English and Chinese localization files.
- Refactored toast message keys in the ConfigButtons, CopyrightTable, HomeTable, ToolSelect, and other components to use updated localization keys for consistency.
- Enhanced user experience by providing clearer feedback on actions performed within the application.

* feat: Implement tag management functionality with CRUD operations

- Added new Tag schema and controller for managing application tags.
- Implemented API endpoints for creating, updating, deleting, and listing tags.
- Enhanced the App schema to include a reference to tags.
- Updated localization files for new tag-related messages.
- Improved user experience by providing clear feedback on tag operations.

* feat: Enhance ChatWelcome and GateNavBar components with conditional rendering for team avatars

- Updated ChatWelcome and GateNavBar components to conditionally render avatars based on availability.
- Improved layout by using Flex components for better alignment and responsiveness.
- Ensured consistent styling and structure for avatar display across both components.

* fix: Update parameter name in getBatchPlugins API for consistency

- Changed parameter name from 'id' to 'appId' in getChildAppPreviewNode function call for better clarity and consistency with the rest of the codebase.

* feat: Enhance ToolSelectModal with gate plugins integration and improved filtering

- Added useEffect to load plugins from gateStore and set them in state.
- Introduced ExtendedNodeTemplateItemType to include cost-related properties.
- Updated filtering logic for plugins based on search input.
- Refactored RenderList to display plugins with cost information and improved layout.

* refactor: Update ToolSelect and ToolSelectModal components for improved UI and state management

- Replaced Button with Flex component in ToolSelect for better styling and hover effects.
- Adjusted layout and styling in ToolSelect for a more responsive design.
- Removed ExtendedNodeTemplateItemType and reverted to NodeTemplateListItemType in ToolSelectModal for simplified state management.
- Updated RenderList to reflect changes in template type and maintain consistency.

* refactor: Replace Flex with Button for add tool action and enhance loading state UI

* feat: Enhance application tag management and localization support

- Added 'tags' property to AppListItemType for better tag management.
- Updated localization files for English and Chinese to include new tag-related strings.
- Implemented new AppTable component in the gateway for managing applications.
- Adjusted routes and components to support the new app management features.

* feat: Update localization and refactor chat components

- Added new localization strings for "enlarge" in English, Simplified Chinese, and Traditional Chinese.
- Refactored chat components to replace `quoteData` with `datasetCiteData` for improved state management.
- Enhanced `ToolSelect` and related components by removing error handling logic for a cleaner UI.
- Updated `AppTable` component to remove unnecessary props for better clarity.

* feat: Initialize copyright configuration in GateNavBar component

* feat: Add appDetail property to ChatGate component and update related logic

* feat: Update GateNavBar routing logic for chat page refresh and enhance avatar display

* feat: Enhance tag management and app detail handling in Chat component

* feat: 更新聊天组件中的国际化文本和输入逻辑,优化用户体验

* feat: Refactor gate configuration management

- Updated API endpoints for fetching and updating gate configurations.
- Changed `avatar` field to `logo` and added `banner` in gate configuration types.
- Implemented new controller methods for creating, retrieving, updating, and deleting gate configurations.
- Enhanced `ConfigButtons` and `CopyrightTable` components to handle new configuration fields.
- Added new SVG icon for sidebar collapse button.
- Improved internationalization support by adding new translation keys.
- Refactored `HomeTable` to manage gate configuration state and handle updates.
- Updated `ShareGateModal` to accept gate configuration as props.
- Cleaned up unused imports and optimized component structures.

* feat: 加载和管理 Gate 配置及版权信息,优化相关组件逻辑

* feat: 更新国际化文本,优化聊天组件中的配置和状态检查逻辑

* feat: Update template configuration and adjust default open state to improve user experience

* feat: Enhance gate management features and update related components

- Added `featuredApps` and `quickApps` fields to `GateSchemaType` for better app management.
- Implemented new methods for updating and managing featured and quick apps in the `controller` and `featureApp` modules.
- Introduced `AddFeatureAppModal` for selecting and adding featured apps.
- Updated `AppTable` and `HomeTable` components to integrate new app management functionalities.
- Enhanced internationalization support by adding new translation keys for app management features.
- Refactored existing components to improve code clarity and maintainability.

* feat: Enhance chat tool selection and quick app management features

- Added `selectedToolIds` and `onSelectedToolIdsChange` props to `ChatBox` and `GateChatInput` components for better tool management.
- Introduced `GateToolSelect` component for selecting tools with improved UI and functionality.
- Implemented `AddQuickAppModal` for managing quick apps, including selection and drag-and-drop functionality.
- Updated `HomeTable` to integrate quick app management and display selected apps.
- Refactored related components to improve code clarity and maintainability.

* refactor: Remove unused AppContext import in useChatGate.tsx to clean up code

* refactor: Update plugin ID handling and clean up unused imports

- Renamed `splitCombinePluginId` to `splitCombineToolId` for consistency in plugin ID processing.
- Removed unused `checkNode` import from `featureApp/detail.ts` and `quickApp/detail.ts` files to streamline the code.
- Added `ownerTmbId` to the parameters in `rewriteAppWorkflowToDetail` for better context management.

* refactor: Rename storeEdgesRenderEdge to storeEdge2RenderEdge for consistency

- Updated the function name from `storeEdgesRenderEdge` to `storeEdge2RenderEdge` in the Header component to maintain naming consistency.
- Adjusted the mapping of edges to use the new function name for improved clarity in the workflow processing.
This commit is contained in:
Theresa
2025-05-30 10:37:48 +08:00
committed by archer
parent 165b783a95
commit 3b0f0a8108
134 changed files with 15066 additions and 46 deletions

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { useTheme, type BoxProps } from '@chakra-ui/react';
import MyBox from '@fastgpt/web/components/common/MyBox';
const GatePageContainer = ({
children,
isLoading,
insertProps = {},
...props
}: BoxProps & { isLoading?: boolean; insertProps?: BoxProps }) => {
const theme = useTheme();
return (
<MyBox h={'100%'} py={[0, '16px']} pr={[0, '16px']} {...props}>
<MyBox
isLoading={isLoading}
h={'100%'}
overflow={'overlay'}
bg={'myGray.25'}
borderRadius={[0, '12px']}
overflowX={'visible'}
{...insertProps}
>
{children}
</MyBox>
</MyBox>
);
};
export default GatePageContainer;

View File

@@ -45,6 +45,9 @@ const pcUnShowLayoutRoute: Record<string, boolean> = {
'/login': true,
'/login/provider': true,
'/login/fastlogin': true,
'/chat/gate': true,
'/chat/gate/store': true,
'/chat/gate/application': true,
'/chat/share': true,
'/chat/team': true,
'/app/edit': true,
@@ -57,6 +60,9 @@ const phoneUnShowLayoutRoute: Record<string, boolean> = {
'/login': true,
'/login/provider': true,
'/login/fastlogin': true,
'/chat/gate': true,
'/chat/gate/store': true,
'/chat/gate/application': true,
'/chat/share': true,
'/chat/team': true,
'/tools/price': true,

View File

@@ -82,6 +82,7 @@ const Navbar = ({ unread }: { unread: number }) => {
'/account/team',
'/account/usage',
'/account/thirdParty',
'/account/gateway',
'/account/apikey',
'/account/setting',
'/account/inform',

View File

@@ -0,0 +1,466 @@
import React, { useRef, useCallback, useMemo, useState, useEffect, useContext } from 'react';
import { Box, Flex, Textarea, IconButton, useBreakpointValue, Button } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { getWebDefaultLLMModel } from '@/web/common/system/utils';
import { useTranslation } from 'next-i18next';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import type { ChatBoxInputFormType, ChatBoxInputType, SendPromptFnType } from '../type';
import { textareaMinH } from '../constants';
import type { UseFormReturn } from 'react-hook-form';
import { useFieldArray } from 'react-hook-form';
import { ChatBoxContext } from '../Provider';
import { useContextSelector } from 'use-context-selector';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { documentFileType } from '@fastgpt/global/common/file/constants';
import FilePreview from '../../components/FilePreview';
import { useFileUpload } from '../hooks/useFileUpload';
import ComplianceTip from '@/components/common/ComplianceTip/index';
import VoiceInput, { type VoiceInputComponentRef } from './VoiceInput';
import { useRouter } from 'next/router';
import { appWorkflow2Form } from '@fastgpt/global/core/app/utils';
import dynamic from 'next/dynamic';
import { AppContext } from '@/pageComponents/app/detail/context';
import { AppFormContext } from '@/pages/chat/gate/index';
import Icon from '@fastgpt/web/components/common/Icon';
import GateSelect from '@fastgpt/web/components/common/MySelect/GateSelect';
const GateToolSelect = dynamic(
() => import('@/pageComponents/app/detail/Gate/components/GateToolSelect'),
{
ssr: false
}
);
const fileTypeFilter = (file: File) => {
return (
file.type.includes('image') ||
documentFileType.split(',').some((type) => file.name.endsWith(type.trim()))
);
};
type Props = {
onSendMessage: SendPromptFnType;
onStop: () => void;
TextareaDom: React.MutableRefObject<HTMLTextAreaElement | null>;
resetInputVal: (val: ChatBoxInputType) => void;
chatForm: UseFormReturn<ChatBoxInputFormType>;
placeholder?: string;
selectedToolIds?: string[];
onSelectedToolIdsChange?: (toolIds: string[]) => void;
};
const GateChatInput = ({
onSendMessage,
onStop,
TextareaDom,
resetInputVal,
chatForm,
placeholder,
selectedToolIds: externalSelectedToolIds,
onSelectedToolIdsChange
}: Props) => {
const { t } = useTranslation();
const { isPc } = useSystem();
const router = useRouter();
const buttonSize = useBreakpointValue({ base: 'sm', md: 'md' });
const VoiceInputRef = useRef<VoiceInputComponentRef>(null);
// 使用AppFormContext替代本地appForm状态
const { appForm, setAppForm } = useContext(AppFormContext);
const { setValue, watch, control } = chatForm;
const inputValue = watch('input');
const outLinkAuthData = useContextSelector(ChatBoxContext, (v) => v.outLinkAuthData);
const appId = useContextSelector(ChatBoxContext, (v) => v.appId);
const chatId = useContextSelector(ChatBoxContext, (v) => v.chatId);
const isChatting = useContextSelector(ChatBoxContext, (v) => v.isChatting);
const fileSelectConfig = useContextSelector(ChatBoxContext, (v) => v.fileSelectConfig);
// 如果有外部传入的工具选择,使用外部的;否则使用内部状态
const [internalSelectedToolIds, setInternalSelectedToolIds] = useState<string[]>([]);
const selectedToolIds = externalSelectedToolIds ?? internalSelectedToolIds;
const setSelectedToolIds = onSelectedToolIdsChange ?? setInternalSelectedToolIds;
const { appDetail } = useContextSelector(AppContext, (v) => v);
const { llmModelList } = useSystemStore();
const modelList = useMemo(
() => llmModelList.map((item) => ({ label: item.name, value: item.model })),
[llmModelList]
);
const defaultModel = useMemo(() => getWebDefaultLLMModel(llmModelList).model, [llmModelList]);
const [selectedModel, setSelectedModel] = useState(defaultModel);
const showModelSelector = useMemo(() => {
return (
router.pathname.startsWith('/chat/gate') &&
!router.pathname.includes('/chat/gate/application')
);
}, [router.pathname]);
// 是否显示工具选择器
const showTools = useMemo(() => {
return router.pathname === '/chat/gate';
}, [router.pathname]);
// 初始化加载appForm - 从Gate应用获取配置
useEffect(() => {
if (!appId || !showTools) return;
const fetchAppForm = async () => {
try {
// 加载Gate应用列表
// 获取当前应用或第一个可用的Gate应用
const currentApp = appDetail;
if (currentApp && currentApp.modules) {
// 将模块转换为appForm格式
const form = appWorkflow2Form({
nodes: currentApp.modules,
chatConfig: currentApp.chatConfig || {}
});
setAppForm(form);
// 如果选择了模型,设置为默认模型
if (form.aiSettings.model) {
setSelectedModel(form.aiSettings.model);
}
}
} catch (error) {
console.error('加载Gate应用信息失败:', error);
}
};
fetchAppForm();
}, [appId, showTools, appDetail, setAppForm]);
// 当模型选择变化时更新appForm
useEffect(() => {
if (!showTools) return;
setAppForm((prevAppForm) => ({
...prevAppForm,
aiSettings: {
...prevAppForm.aiSettings,
model: selectedModel
}
}));
}, [selectedModel, showTools, setAppForm]);
const fileCtrl = useFieldArray({
control,
name: 'files'
});
const {
File,
onOpenSelectFile,
fileList,
onSelectFile,
uploadFiles,
removeFiles,
replaceFiles,
hasFileUploading
} = useFileUpload({
fileSelectConfig,
fileCtrl,
outLinkAuthData,
appId,
chatId
});
const havInput = !!inputValue || fileList.length > 0;
const canSendMessage = havInput && !hasFileUploading;
// Upload files
useRequest2(uploadFiles, {
manual: false,
errorToast: t('common:upload_file_error'),
refreshDeps: [fileList, outLinkAuthData, chatId]
});
const handleSend = useCallback(
async (val?: string) => {
if (!canSendMessage) return;
const textareaValue = val || TextareaDom.current?.value || '';
onSendMessage({
text: textareaValue.trim(),
files: fileList,
gateModel: showModelSelector ? selectedModel : undefined,
selectedTool: selectedToolIds.length > 0 ? selectedToolIds.join(',') : null // 将工具ID数组转换为逗号分隔的字符串
});
replaceFiles([]);
},
[
TextareaDom,
canSendMessage,
fileList,
onSendMessage,
replaceFiles,
showModelSelector,
selectedModel,
selectedToolIds
]
);
return (
<Box
w="full"
maxW="100%"
minH="132px"
background="var(--White, #FFF)"
border="0.5px solid rgba(0, 0, 0, 0.13)"
boxShadow="0px 5px 16px -4px rgba(19, 51, 107, 0.08)"
borderRadius="20px"
position="relative"
p={4}
pb="56px"
overflow="hidden"
transition="all 0.2s ease"
_hover={{
border: '0.5px solid rgba(0, 0, 0, 0.20)',
boxShadow: '0px 5px 20px -4px rgba(19, 51, 107, 0.13)'
}}
_focus-within={{
border: '0.5px solid rgba(0, 0, 0, 0.20)',
boxShadow: '0px 5px 20px -4px rgba(19, 51, 107, 0.13)'
}}
>
{/* file preview */}
<Box px={[1, 3]}>
<FilePreview fileList={fileList} removeFiles={removeFiles} />
</Box>
{/* voice input and loading container */}
{!inputValue && (
<VoiceInput
ref={VoiceInputRef}
onSendMessage={onSendMessage}
resetInputVal={resetInputVal}
/>
)}
<Textarea
ref={TextareaDom}
value={inputValue}
onChange={(e) => {
const textarea = e.target;
textarea.style.height = textareaMinH;
textarea.style.height = `${textarea.scrollHeight}px`;
setValue('input', textarea.value);
}}
onKeyDown={(e) => {
// enter send.(pc or iframe && enter and unPress shift)
const isEnter = e.keyCode === 13;
if (isEnter && TextareaDom.current && (e.ctrlKey || e.altKey)) {
// Add a new line
const index = TextareaDom.current.selectionStart;
const val = TextareaDom.current.value;
TextareaDom.current.value = `${val.slice(0, index)}\n${val.slice(index)}`;
TextareaDom.current.selectionStart = index + 1;
TextareaDom.current.selectionEnd = index + 1;
TextareaDom.current.style.height = textareaMinH;
TextareaDom.current.style.height = `${TextareaDom.current.scrollHeight}px`;
return;
}
// 全选内容
// @ts-ignore
e.key === 'a' && e.ctrlKey && e.target?.select();
if ((isPc || window !== parent) && e.keyCode === 13 && !e.shiftKey) {
handleSend();
e.preventDefault();
}
}}
onPaste={(e) => {
const clipboardData = e.clipboardData;
if (clipboardData && (fileSelectConfig.canSelectFile || fileSelectConfig.canSelectImg)) {
const items = clipboardData.items;
const files = Array.from(items)
.map((item) => (item.kind === 'file' ? item.getAsFile() : undefined))
.filter((file) => {
return file && fileTypeFilter(file);
}) as File[];
onSelectFile({ files });
if (files.length > 0) {
e.preventDefault();
e.stopPropagation();
}
}
}}
placeholder={placeholder}
variant="unstyled"
resize="none"
minH="60px"
maxH="300px"
fontFamily="PingFang SC"
fontSize="15px"
lineHeight="1.6"
letterSpacing="0.5px"
overflowY="auto"
css={{
'&::-webkit-scrollbar': {
width: '4px'
},
'&::-webkit-scrollbar-track': {
width: '6px',
background: 'transparent'
},
'&::-webkit-scrollbar-thumb': {
background: '#E2E8F0',
borderRadius: '24px'
}
}}
_placeholder={{
color: '#A4A4A4',
fontSize: '15px'
}}
/>
{/* Bottom Toolbar */}
<Flex
position="absolute"
left="0"
right="0"
bottom="3"
px="4"
justify="space-between"
align="center"
w="100%"
maxW="100%"
>
<Flex align="center" gap={2} overflow="hidden" maxW="65%" flexShrink={1} flexWrap="nowrap">
{showModelSelector && (
<GateSelect
value={selectedModel}
list={modelList}
onChange={setSelectedModel}
minW="128px"
maxW="180px"
w="auto"
bg="#F9F9F9"
border="0.5px solid #E0E0E0"
borderRadius="10px"
color="#485264"
h="36px"
fontSize="14px"
/>
)}
{showTools && (
<GateToolSelect
selectedToolIds={selectedToolIds}
onToolsChange={setSelectedToolIds}
buttonSize={buttonSize}
/>
)}
</Flex>
<Flex align="center" gap="2px" flexShrink={0}>
<IconButton
aria-label="Upload file"
icon={<MyIcon name={'support/gate/chat/paperclip'} w={'20px'} h={'20px'} />}
size="auto" // 尝试移除buttonSize变量的影响
variant="ghost"
display="flex"
padding="8px"
alignItems="center"
minW="36px" // 使用minW而不是w
minH="36px" // 使用minH而不是h
w="36px"
h="36px"
boxSize="36px" // 添加boxSize属性更强制性地控制尺寸
onClick={() => onOpenSelectFile()}
flexShrink={0}
_hover={{
background: 'var(--light-general-surface-opacity-005, rgba(17, 24, 36, 0.05))',
'& svg path': {
fill: '#3370FF !important'
}
}}
/>
<IconButton
aria-label="Voice input"
icon={<Icon name={'support/gate/chat/voiceGray'} w={'20px'} h={'20px'} />}
size="auto"
variant="ghost"
display="flex"
padding="8px"
w="36px"
h="36px"
alignItems="center"
onClick={() => VoiceInputRef.current?.onSpeak?.()}
flexShrink={0}
_hover={{
background: 'var(--light-general-surface-opacity-005, rgba(17, 24, 36, 0.05))',
'& svg path': {
fill: '#3370FF !important'
}
}}
/>
<Box w="2px" h="16px" bg="#F0F1F6" mx={1} flexShrink={0} />
{isChatting ? (
<IconButton
aria-label="Stop"
icon={
<MyIcon
animation={'zoomStopIcon 0.4s infinite alternate'}
width={['22px', '25px']}
height={['22px', '25px']}
name={'stop'}
color={'gray.500'}
/>
}
size="auto"
onClick={onStop}
borderRadius="12px"
w="36px"
h="36px"
variant="ghost"
flexShrink={0}
/>
) : (
<IconButton
aria-label="Send"
icon={
<MyIcon
name={'core/chat/sendFill'}
width={['18px', '20px']}
height={['18px', '20px']}
color={'white'}
/>
}
size="auto"
bg={
!canSendMessage
? 'var(--light-general-surface-opacity-01, rgba(17, 24, 36, 0.10))'
: '#3370FF'
}
_hover={{
bg: !canSendMessage
? 'var(--light-general-surface-opacity-01, rgba(17, 24, 36, 0.10))'
: '#2860E1'
}}
borderRadius="12px"
w="36px"
h="36px"
onClick={() => handleSend()}
flexShrink={0}
/>
)}
</Flex>
</Flex>
<File onSelect={(files) => onSelectFile({ files })} />
<ComplianceTip type={'chat'} />
</Box>
);
};
export default React.memo(GateChatInput);

View File

@@ -23,7 +23,12 @@ export interface VoiceInputComponentRef {
}
type VoiceInputProps = {
onSendMessage: (params: { text: string; files?: any[]; autoTTSResponse?: boolean }) => void;
onSendMessage: (params: {
text: string;
files?: any[];
autoTTSResponse?: boolean;
gateModel?: string;
}) => void;
resetInputVal: (val: { text: string }) => void;
};

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { Box, Flex, Text } from '@chakra-ui/react';
import Avatar from '@fastgpt/web/components/common/Avatar';
type Props = {
teamName?: string;
teamAvatar?: string;
slogan?: string;
};
const ChatWelcome = ({ teamName = 'FastGPT', teamAvatar, slogan }: Props) => {
return (
<Flex direction="column" align="center" gap={4} maxW="700px">
<Flex align="center" gap={5}>
{teamAvatar ? (
<Flex
w="60px"
h="60px"
borderRadius="15px"
overflow="hidden"
justifyContent="center"
alignItems="center"
>
<Avatar w="100%" h="100%" src={teamAvatar} borderRadius="15px" />
</Flex>
) : (
<Box
w="60px"
h="60px"
bg="white"
border="1.25px solid #ECECEC"
borderRadius="15px"
overflow="hidden"
>
<Avatar w="100%" h="100%" src={teamAvatar} borderRadius="15px" />
</Box>
)}
<Text fontSize="2xl" fontWeight="bold" color="#111824" fontFamily="Inter">
{teamName}
</Text>
</Flex>
{slogan && (
<Text
fontSize="lg"
color="#707070"
fontFamily="PingFang SC"
textAlign="center"
maxW="600px"
whiteSpace="pre-line"
>
{slogan}
</Text>
)}
</Flex>
);
};
export default React.memo(ChatWelcome);

View File

@@ -14,7 +14,7 @@ import type {
} from '@fastgpt/global/core/chat/type.d';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { Box, Checkbox } from '@chakra-ui/react';
import { Box, Checkbox, Flex, Text } from '@chakra-ui/react';
import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus';
import { chats2GPTMessages } from '@fastgpt/global/core/chat/adapt';
import { useForm } from 'react-hook-form';
@@ -67,6 +67,13 @@ import TimeBox from './components/TimeBox';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import { valueTypeFormat } from '@fastgpt/global/core/workflow/runtime/utils';
import GateChatInput from './Input/GateChatInput';
import ChatWelcome from './components/ChatWelcome';
import { useUserStore } from '@/web/support/user/useUserStore';
import { useRouter } from 'next/router';
import { getTeamGateConfig, getTeamGateConfigCopyRight } from '@/web/support/user/team/gate/api';
import type { getGateConfigCopyRightResponse } from '@fastgpt/global/support/user/team/gate/api';
import type { GateSchemaType } from '@fastgpt/global/support/user/team/gate/type';
const FeedbackModal = dynamic(() => import('./components/FeedbackModal'));
const ReadFeedbackModal = dynamic(() => import('./components/ReadFeedbackModal'));
@@ -89,6 +96,8 @@ type Props = OutLinkChatAuthProps &
showVoiceIcon?: boolean;
showEmptyIntro?: boolean;
active?: boolean; // can use
selectedToolIds?: string[];
onSelectedToolIdsChange?: (toolIds: string[]) => void;
onStartChat?: (e: StartChatFnProps) => Promise<
StreamResponseType & {
@@ -105,7 +114,9 @@ const ChatBox = ({
showEmptyIntro = false,
active = true,
onStartChat,
chatType
chatType,
selectedToolIds,
onSelectedToolIdsChange
}: Props) => {
const ScrollContainerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
@@ -127,6 +138,7 @@ const ChatBox = ({
const [questionGuides, setQuestionGuide] = useState<string[]>([]);
const appAvatar = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.app?.avatar);
const appIntro = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.app?.intro);
const userAvatar = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.userAvatar);
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const ChatBoxRef = useContextSelector(ChatItemContext, (v) => v.ChatBoxRef);
@@ -407,6 +419,10 @@ const ChatBox = ({
pluginController.current?.abort(signal);
});
const router = useRouter();
const inGateRoute = useMemo(() => {
return router.pathname.startsWith('/chat/gate');
}, [router.pathname]);
/**
* user confirm send prompt
*/
@@ -417,7 +433,8 @@ const ChatBox = ({
history = chatRecords,
autoTTSResponse = false,
isInteractivePrompt = false,
hideInUI = false
hideInUI = false,
gateModel = ''
}) => {
variablesForm.handleSubmit(
async ({ variables = {} }) => {
@@ -536,6 +553,7 @@ const ChatBox = ({
});
const { responseText } = await onStartChat({
gateModel,
messages, // 保证最后一条是 Human 的消息
responseChatItemId: responseChatId,
controller: abortSignal,
@@ -1085,6 +1103,34 @@ const ChatBox = ({
welcomeText
]);
const [gateConfig, setGateConfig] = useState<GateSchemaType | undefined>(undefined);
const [copyRightConfig, setCopyRightConfig] = useState<
getGateConfigCopyRightResponse | undefined
>(undefined);
// 加载 gateConfig 和 copyRightConfig
useEffect(() => {
const loadConfig = async () => {
try {
const gateConfig = await getTeamGateConfig();
setGateConfig(gateConfig);
const copyRightConfig = await getTeamGateConfigCopyRight();
setCopyRightConfig(copyRightConfig);
} catch (error) {
console.error('Failed to load gate config:', error);
}
};
loadConfig();
}, []);
const { userInfo } = useUserStore();
const showWelcome = useMemo(() => {
return (
router.pathname.startsWith('/chat/gate') &&
!router.pathname.includes('/chat/gate/application') &&
chatRecords.length === 0
);
}, [router.pathname, chatRecords.length]);
return (
<MyBox
isLoading={isLoading}
@@ -1094,18 +1140,124 @@ const ChatBox = ({
position={'relative'}
>
<Script src={getWebReqUrl('/js/html2pdf.bundle.min.js')} strategy="lazyOnload"></Script>
{/* chat box container */}
{RenderRecords}
{/* message input */}
{onStartChat && chatStarted && active && !isInteractive && (
<ChatInput
onSendMessage={sendPrompt}
onStop={() => chatController.current?.abort('stop')}
TextareaDom={TextareaDom}
resetInputVal={resetInputVal}
chatForm={chatForm}
/>
{chatRecords.length === 0 && showWelcome ? (
<Flex
flex={1}
direction="column"
align="center"
justify="space-between"
h="100%"
w="100%"
position="relative"
maxW="1360px"
mx="auto"
pt={{ base: '100px', sm: '120px', md: '158px' }}
px={{ base: '20px', sm: '30px', md: '40px' }}
pb={{ base: '12px', sm: '12px' }}
gap={{ base: 4, md: 6 }}
>
<Flex direction="column" align="center" justify="center" w="100%" gap="44px">
<Box>
<ChatWelcome
teamName={copyRightConfig?.name || chatBoxData?.app?.name}
teamAvatar={copyRightConfig?.logo}
slogan={appIntro}
/>
</Box>
{/* message input */}
{onStartChat && chatStarted && active && !isInteractive && (
<Box w={{ base: 'calc(100% - 48px)', md: '700px' }} maxH="132px" h="100%" px={0}>
<GateChatInput
onSendMessage={sendPrompt}
onStop={() => chatController.current?.abort('stop')}
TextareaDom={TextareaDom}
resetInputVal={resetInputVal}
chatForm={chatForm}
placeholder={gateConfig?.placeholderText || '你可以问我任何问题'}
selectedToolIds={selectedToolIds}
onSelectedToolIdsChange={onSelectedToolIdsChange}
/>
</Box>
)}
</Flex>
{/* 移动端下的版权信息容器 */}
<Box w="100%" mt="auto">
{/* 在inGateRoute状态下显示底部语句 */}
{inGateRoute && (
<Flex
justify="center"
w="100%"
py={3}
px={4}
fontSize={{ base: '2xs', sm: 'xs' }}
color="gray.500"
>
<Text textAlign="center">{t('common:gate.copyright')}</Text>
</Flex>
)}
</Box>
</Flex>
) : (
<>
{RenderRecords}
{/* 移动端下的输入框和版权信息容器 */}
<Flex direction="column" w="100%" mb={{ base: '12px', sm: 0 }} gap="12px">
{/* message input */}
{onStartChat && chatStarted && active && !isInteractive && (
<Box
m={['0 auto', '10px auto']}
w={'100%'}
maxW={['auto', 'min(800px, 100%)']}
px={['16px', 5]}
display="flex"
justifyContent="center"
alignItems="center"
>
{inGateRoute && (
<GateChatInput
onSendMessage={sendPrompt}
onStop={() => chatController.current?.abort('stop')}
TextareaDom={TextareaDom}
resetInputVal={resetInputVal}
chatForm={chatForm}
placeholder={gateConfig?.placeholderText || t('common:gate.placeholder')}
selectedToolIds={selectedToolIds}
onSelectedToolIdsChange={onSelectedToolIdsChange}
/>
)}
{!inGateRoute && (
<ChatInput
onSendMessage={sendPrompt}
onStop={() => chatController.current?.abort('stop')}
TextareaDom={TextareaDom}
resetInputVal={resetInputVal}
chatForm={chatForm}
/>
)}
</Box>
)}
{/* 在inGateRoute状态下显示底部语句 */}
{inGateRoute && (
<Flex
justify="center"
w="100%"
py={3}
px={4}
fontSize={{ base: '2xs', sm: 'xs' }}
color="gray.500"
>
<Text textAlign="center">{t('common:gate.copyright')}</Text>
</Flex>
)}
</Flex>
</>
)}
{/* user feedback modal */}
{!!feedbackId && chatId && (
<FeedbackModal

View File

@@ -27,6 +27,8 @@ export type ChatBoxInputType = {
files?: UserInputFileItemType[];
isInteractivePrompt?: boolean;
hideInUI?: boolean;
gateModel?: string;
selectedTool?: string | null;
};
export type SendPromptFnType = (

View File

@@ -26,6 +26,8 @@ export type StartChatFnProps = {
controller: AbortController;
variables: Record<string, any>;
generatingMessage: (e: generatingMessageProps) => void;
gateModel?: string;
selectedTool?: string | null;
};
export type onStartChatType = (e: StartChatFnProps) => Promise<

View File

@@ -22,7 +22,8 @@ export enum TabEnum {
'apikey' = 'apikey',
'loginout' = 'loginout',
'team' = 'team',
'model' = 'model'
'model' = 'model',
gateway = 'gateway'
}
const AccountContainer = ({
@@ -60,6 +61,11 @@ const AccountContainer = ({
icon: 'support/usage/usageRecordLight',
label: t('account:usage_records'),
value: TabEnum.usage
},
{
icon: 'support/gate/gateLight',
label: t('account:gateways'),
value: TabEnum.gateway
}
]
: []),

View File

@@ -0,0 +1,273 @@
import React, { useCallback, useState } from 'react';
import { ModalBody, ModalFooter, Button, Text, Flex, Box, IconButton } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import type { SelectAppItemType } from '@fastgpt/global/core/workflow/template/system/abandoned/runApp/type';
import { useTranslation } from 'next-i18next';
import SelectMultipleResource from './SelectMultipleResource';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyIcon from '@fastgpt/web/components/common/Icon';
import DndDrag, { Draggable } from '@fastgpt/web/components/common/DndDrag';
import {
type GetResourceFolderListProps,
type GetResourceListItemResponse
} from '@fastgpt/global/common/parentFolder/type';
import { getMyApps } from '@/web/core/app/api';
import { listFeatureApps, batchUpdateFeaturedApps } from '@/web/support/user/team/gate/featureApp';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
// 扩展的应用类型,包含显示所需的属性
type ExtendedSelectAppItemType = SelectAppItemType & {
name: string;
avatar?: string;
};
const AddFeatureAppModal = ({
isOpen = true,
value,
filterAppIds = [],
onClose,
onSuccess
}: {
isOpen?: boolean;
value?: ExtendedSelectAppItemType[];
filterAppIds?: string[];
onClose: () => void;
onSuccess: (e: ExtendedSelectAppItemType[]) => void;
}) => {
const { t } = useTranslation();
const [selectedApps, setSelectedApps] = useState<ExtendedSelectAppItemType[]>([]);
const [searchKey, setSearchKey] = useState('');
// 使用 listFeatureApps 初始化已选择的应用数组
const { data: featureApps = [], loading: loadingFeatureApps } = useRequest2(
() => listFeatureApps(),
{
manual: false,
onSuccess: (data) => {
const initialSelectedApps = data.map((app) => ({
id: app._id,
name: app.name,
avatar: app.avatar
}));
setSelectedApps(initialSelectedApps);
}
}
);
const getAppList = useCallback(
async ({ parentId }: GetResourceFolderListProps) => {
return getMyApps({
parentId,
searchKey,
type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow]
}).then((res) =>
res
.filter((item) => !filterAppIds.includes(item._id))
.map<GetResourceListItemResponse>((item) => ({
id: item._id,
name: item.name,
avatar: item.avatar,
isFolder: item.type === AppTypeEnum.folder
}))
);
},
[filterAppIds, searchKey]
);
const handleAppSelect = useCallback((appId: string, appData: GetResourceListItemResponse) => {
setSelectedApps((prev) => {
const exists = prev.find((app) => app.id === appId);
if (exists) {
// 如果已存在,则移除
return prev.filter((app) => app.id !== appId);
} else {
// 如果不存在,则添加到末尾
return [...prev, { id: appId, name: appData.name, avatar: appData.avatar }];
}
});
}, []);
const handleAppUnselect = useCallback((appId: string) => {
setSelectedApps((prev) => prev.filter((app) => app.id !== appId));
}, []);
// 处理拖拽排序
const handleDragEnd = useCallback((reorderedList: ExtendedSelectAppItemType[]) => {
setSelectedApps(reorderedList);
}, []);
// 批量更新特色应用
const { runAsync: updateFeaturedApps, loading: isUpdating } = useRequest2(
() => {
const updates = [{ featuredApps: selectedApps.map((app) => app.id) }];
return batchUpdateFeaturedApps(updates);
},
{
manual: true,
onSuccess: () => {
onSuccess(selectedApps);
onClose();
},
onError: (error) => {
console.error('更新特色应用失败:', error);
}
}
);
return (
<MyModal
isOpen={isOpen}
title={t('common:core.module.Select app')}
iconSrc="/imgs/workflow/ai.svg"
onClose={onClose}
position={'relative'}
w={'900px'}
maxW={'90vw'}
>
<ModalBody flex={'1 0 0'} overflow={'hidden'} minH={'500px'} position={'relative'}>
<Flex h="100%" gap={4}>
{/* 左侧应用选择区域 */}
<Flex direction="column" flex={1} h="100%">
{/* 搜索框 */}
<Box mb={4}>
<SearchInput
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder={t('app:search_app')}
/>
</Box>
{/* 应用选择区域 */}
<Box flex={1} overflow="auto">
<SelectMultipleResource
selectedIds={selectedApps.map((app) => app.id)}
onSelect={handleAppSelect}
server={getAppList}
searchKey={searchKey}
/>
</Box>
</Flex>
{/* 右侧已选择应用排序区域 */}
<Box w="300px" h="100%" borderLeft="1px solid" borderColor="gray.200" pl={4}>
<Flex direction="column" h="100%">
<Text fontSize="sm" fontWeight="medium" mb={3}>
{t('common:selected')} {selectedApps.length}
</Text>
{selectedApps.length > 0 ? (
<Box flex={1} overflow="auto">
<DndDrag<ExtendedSelectAppItemType>
onDragEndCb={handleDragEnd}
dataList={selectedApps}
>
{({ provided }) => (
<Flex
flexDirection={'column'}
gap={2}
{...provided.droppableProps}
ref={provided.innerRef}
>
{selectedApps.map((app, index) => (
<Draggable key={app.id} draggableId={String(app.id)} index={index}>
{(provided, snapshot) => (
<Flex
ref={provided.innerRef}
{...provided.draggableProps}
style={{
...provided.draggableProps.style,
opacity: snapshot.isDragging ? 0.8 : 1
}}
alignItems="center"
gap={2}
p={2}
bg="white"
borderRadius="md"
border="1px solid"
borderColor="gray.200"
fontSize="sm"
_hover={{
bg: 'gray.50',
borderColor: 'gray.300'
}}
>
{/* 拖拽图标 */}
<Flex
{...provided.dragHandleProps}
alignItems="center"
justifyContent="center"
w="16px"
h="16px"
cursor="grab"
_active={{ cursor: 'grabbing' }}
>
<MyIcon name="drag" w={'10px'} h={'12px'} color={'gray.500'} />
</Flex>
{/* 应用图标 */}
<Avatar src={app.avatar} w="20px" h="20px" borderRadius="4px" />
{/* 应用名称 */}
<Text flex={1} fontSize="12px" fontWeight="500" noOfLines={1}>
{app.name}
</Text>
{/* 删除按钮 */}
<IconButton
size="xs"
variant="ghost"
icon={<MyIcon name="delete" w="12px" />}
aria-label="remove"
onClick={(e) => {
e.stopPropagation();
handleAppUnselect(app.id);
}}
_hover={{ bg: 'red.50', color: 'red.500' }}
/>
</Flex>
)}
</Draggable>
))}
{provided.placeholder}
</Flex>
)}
</DndDrag>
</Box>
) : (
<Flex
flex={1}
alignItems="center"
justifyContent="center"
color="gray.500"
fontSize="sm"
>
<Text>{t('common:no_selected_apps')}</Text>
</Flex>
)}
</Flex>
</Box>
</Flex>
</ModalBody>
<ModalFooter>
<Button variant={'whiteBase'} onClick={onClose}>
{t('common:Cancel')}
</Button>
<Button
ml={2}
isDisabled={selectedApps.length === 0 || loadingFeatureApps || isUpdating}
isLoading={isUpdating}
onClick={() => {
if (selectedApps.length === 0) return;
updateFeaturedApps();
}}
>
{t('common:Confirm')} ({selectedApps.length})
</Button>
</ModalFooter>
</MyModal>
);
};
export default React.memo(AddFeatureAppModal);

View File

@@ -0,0 +1,270 @@
import React, { useCallback, useState } from 'react';
import { ModalBody, ModalFooter, Button, Text, Flex, Box, IconButton } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import type { SelectAppItemType } from '@fastgpt/global/core/workflow/template/system/abandoned/runApp/type';
import { useTranslation } from 'next-i18next';
import SelectMultipleResource from './SelectMultipleResource';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyIcon from '@fastgpt/web/components/common/Icon';
import DndDrag, { Draggable } from '@fastgpt/web/components/common/DndDrag';
import {
type GetResourceFolderListProps,
type GetResourceListItemResponse
} from '@fastgpt/global/common/parentFolder/type';
import { getMyApps } from '@/web/core/app/api';
import { listQuickApps, batchUpdateQuickApps } from '@/web/support/user/team/gate/quickApp';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
// 扩展的应用类型,包含显示所需的属性
type ExtendedSelectAppItemType = SelectAppItemType & {
name: string;
avatar?: string;
};
const AddQuickAppModal = ({
isOpen = true,
value,
filterAppIds = [],
onClose,
onSuccess
}: {
isOpen?: boolean;
value?: ExtendedSelectAppItemType[];
filterAppIds?: string[];
onClose: () => void;
onSuccess: (e: ExtendedSelectAppItemType[]) => void;
}) => {
const { t } = useTranslation();
const [selectedApps, setSelectedApps] = useState<ExtendedSelectAppItemType[]>([]);
const [searchKey, setSearchKey] = useState('');
// 使用 listQuickApps 初始化已选择的应用数组
const { data: quickApps = [], loading: loadingQuickApps } = useRequest2(() => listQuickApps(), {
manual: false,
onSuccess: (data) => {
const initialSelectedApps = data.map((app) => ({
id: app._id,
name: app.name,
avatar: app.avatar
}));
setSelectedApps(initialSelectedApps);
}
});
const getAppList = useCallback(
async ({ parentId }: GetResourceFolderListProps) => {
return getMyApps({
parentId,
searchKey,
type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow]
}).then((res) =>
res
.filter((item) => !filterAppIds.includes(item._id))
.map<GetResourceListItemResponse>((item) => ({
id: item._id,
name: item.name,
avatar: item.avatar,
isFolder: item.type === AppTypeEnum.folder
}))
);
},
[filterAppIds, searchKey]
);
const handleAppSelect = useCallback((appId: string, appData: GetResourceListItemResponse) => {
setSelectedApps((prev) => {
const exists = prev.find((app) => app.id === appId);
if (exists) {
// 如果已存在,则移除
return prev.filter((app) => app.id !== appId);
} else {
// 如果不存在,则添加到末尾
return [...prev, { id: appId, name: appData.name, avatar: appData.avatar }];
}
});
}, []);
const handleAppUnselect = useCallback((appId: string) => {
setSelectedApps((prev) => prev.filter((app) => app.id !== appId));
}, []);
// 处理拖拽排序
const handleDragEnd = useCallback((reorderedList: ExtendedSelectAppItemType[]) => {
setSelectedApps(reorderedList);
}, []);
// 批量更新快速应用
const { runAsync: updateQuickApps, loading: isUpdating } = useRequest2(
() => {
const updates = [{ quickApps: selectedApps.map((app) => app.id) }];
return batchUpdateQuickApps(updates);
},
{
manual: true,
onSuccess: () => {
onSuccess(selectedApps);
onClose();
},
onError: (error) => {
console.error('更新快速应用失败:', error);
}
}
);
return (
<MyModal
isOpen={isOpen}
title={t('common:core.module.Select app')}
iconSrc="/imgs/workflow/ai.svg"
onClose={onClose}
position={'relative'}
w={'900px'}
maxW={'90vw'}
>
<ModalBody flex={'1 0 0'} overflow={'hidden'} minH={'500px'} position={'relative'}>
<Flex h="100%" gap={4}>
{/* 左侧应用选择区域 */}
<Flex direction="column" flex={1} h="100%">
{/* 搜索框 */}
<Box mb={4}>
<SearchInput
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder={t('app:search_app')}
/>
</Box>
{/* 应用选择区域 */}
<Box flex={1} overflow="auto">
<SelectMultipleResource
selectedIds={selectedApps.map((app) => app.id)}
onSelect={handleAppSelect}
server={getAppList}
searchKey={searchKey}
/>
</Box>
</Flex>
{/* 右侧已选择应用排序区域 */}
<Box w="300px" h="100%" borderLeft="1px solid" borderColor="gray.200" pl={4}>
<Flex direction="column" h="100%">
<Text fontSize="sm" fontWeight="medium" mb={3}>
{t('common:selected')} {selectedApps.length}
</Text>
{selectedApps.length > 0 ? (
<Box flex={1} overflow="auto">
<DndDrag<ExtendedSelectAppItemType>
onDragEndCb={handleDragEnd}
dataList={selectedApps}
>
{({ provided }) => (
<Flex
flexDirection={'column'}
gap={2}
{...provided.droppableProps}
ref={provided.innerRef}
>
{selectedApps.map((app, index) => (
<Draggable key={app.id} draggableId={String(app.id)} index={index}>
{(provided, snapshot) => (
<Flex
ref={provided.innerRef}
{...provided.draggableProps}
style={{
...provided.draggableProps.style,
opacity: snapshot.isDragging ? 0.8 : 1
}}
alignItems="center"
gap={2}
p={2}
bg="white"
borderRadius="md"
border="1px solid"
borderColor="gray.200"
fontSize="sm"
_hover={{
bg: 'gray.50',
borderColor: 'gray.300'
}}
>
{/* 拖拽图标 */}
<Flex
{...provided.dragHandleProps}
alignItems="center"
justifyContent="center"
w="16px"
h="16px"
cursor="grab"
_active={{ cursor: 'grabbing' }}
>
<MyIcon name="drag" w={'10px'} h={'12px'} color={'gray.500'} />
</Flex>
{/* 应用图标 */}
<Avatar src={app.avatar} w="20px" h="20px" borderRadius="4px" />
{/* 应用名称 */}
<Text flex={1} fontSize="12px" fontWeight="500" noOfLines={1}>
{app.name}
</Text>
{/* 删除按钮 */}
<IconButton
size="xs"
variant="ghost"
icon={<MyIcon name="delete" w="12px" />}
aria-label="remove"
onClick={(e) => {
e.stopPropagation();
handleAppUnselect(app.id);
}}
_hover={{ bg: 'red.50', color: 'red.500' }}
/>
</Flex>
)}
</Draggable>
))}
{provided.placeholder}
</Flex>
)}
</DndDrag>
</Box>
) : (
<Flex
flex={1}
alignItems="center"
justifyContent="center"
color="gray.500"
fontSize="sm"
>
<Text>{t('common:no_selected_apps')}</Text>
</Flex>
)}
</Flex>
</Box>
</Flex>
</ModalBody>
<ModalFooter>
<Button variant={'whiteBase'} onClick={onClose}>
{t('common:Cancel')}
</Button>
<Button
ml={2}
isDisabled={selectedApps.length === 0 || loadingQuickApps || isUpdating}
isLoading={isUpdating}
onClick={() => {
if (selectedApps.length === 0) return;
updateQuickApps();
}}
>
{t('common:Confirm')} ({selectedApps.length})
</Button>
</ModalFooter>
</MyModal>
);
};
export default React.memo(AddQuickAppModal);

View File

@@ -0,0 +1,688 @@
import {
Box,
Flex,
HStack,
Text,
IconButton,
Button,
useDisclosure,
Tooltip,
Wrap,
WrapItem,
Checkbox,
Menu,
MenuButton,
MenuList,
MenuItem
} from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import MySelect from '@fastgpt/web/components/common/MySelect';
import MultipleSelect, {
useMultipleSelect
} from '@fastgpt/web/components/common/MySelect/MultipleSelect';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { delAppById } from '@/web/core/app/api';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import type { AppListItemType } from '@fastgpt/global/core/app/type.d';
import { getTeamTags } from '@/web/core/app/api/tags';
import type { TagSchemaType } from '@fastgpt/global/core/app/tags';
import GateAppInfoModal from './GateAppInfoModal';
import TagManageModal from './TagManageModal';
import DndDrag, { Draggable } from '@fastgpt/web/components/common/DndDrag';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import { listFeatureApps, reorderFeatureApps } from '@/web/support/user/team/gate/featureApp';
import AddFeatureAppModal from './AddFeatureAppModal';
// 设置最大可见标签数
const MAX_VISIBLE_TAGS = 2;
// 自定义 hook应用选择逻辑
const useAppSelection = (filteredApps: AppListItemType[]) => {
const [selectedAppIds, setSelectedAppIds] = useState<string[]>([]);
const handleAppSelect = useCallback((appId: string, isSelected: boolean) => {
setSelectedAppIds((prev) =>
isSelected ? [...prev.filter((id) => id !== appId), appId] : prev.filter((id) => id !== appId)
);
}, []);
const handleSelectAll = useCallback(
(isSelected: boolean) => {
setSelectedAppIds(isSelected ? filteredApps.map((app) => app._id) : []);
},
[filteredApps]
);
const isAllSelected = useMemo(
() => filteredApps.length > 0 && filteredApps.every((app) => selectedAppIds.includes(app._id)),
[filteredApps, selectedAppIds]
);
const isIndeterminate = useMemo(() => {
const selectedCount = filteredApps.filter((app) => selectedAppIds.includes(app._id)).length;
return selectedCount > 0 && selectedCount < filteredApps.length;
}, [filteredApps, selectedAppIds]);
return {
selectedAppIds,
handleAppSelect,
handleSelectAll,
isAllSelected,
isIndeterminate
};
};
// 标签组件
const AppTags = ({ tags, tagMap }: { tags?: string[]; tagMap: Map<string, TagSchemaType> }) => {
if (!tags?.length) return null;
const validTags = tags.filter((tagId) => tagMap.get(tagId));
const visibleTags = validTags.slice(0, MAX_VISIBLE_TAGS);
const remainingCount = Math.max(0, validTags.length - MAX_VISIBLE_TAGS);
const TagItem = ({ tagId }: { tagId: string }) => {
const tag = tagMap.get(tagId);
if (!tag) return null;
return (
<Flex
padding="10px 8px"
justifyContent="center"
alignItems="center"
height="22px"
minWidth="32px"
borderRadius="6px"
backgroundColor="#F4F4F5"
>
<Text
fontSize="12px"
fontWeight="500"
lineHeight="16px"
color="#525252"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{tag.name}
</Text>
</Flex>
);
};
return (
<HStack spacing={2} wrap="wrap">
{visibleTags.map((tagId) => (
<TagItem key={tagId} tagId={tagId} />
))}
{remainingCount > 0 && (
<Tooltip
label={
<Wrap spacing={2} maxW="300px" p={2}>
{validTags.slice(MAX_VISIBLE_TAGS).map((tagId) => (
<WrapItem key={tagId}>
<TagItem tagId={tagId} />
</WrapItem>
))}
</Wrap>
}
hasArrow
placement="top"
bg="white"
boxShadow="lg"
>
<Flex
padding="10px 8px"
justifyContent="center"
alignItems="center"
height="22px"
width="31px"
borderRadius="6px"
backgroundColor="#F4F4F5"
>
<Text fontSize="12px" fontWeight="500" color="#525252">
+{remainingCount}
</Text>
</Flex>
</Tooltip>
)}
</HStack>
);
};
// 应用行组件
const AppRow = ({
app,
index,
tagMap,
selectedAppIds,
onAppSelect,
onEdit,
onDelete
}: {
app: AppListItemType;
index: number;
tagMap: Map<string, TagSchemaType>;
selectedAppIds: string[];
onAppSelect: (appId: string, isSelected: boolean) => void;
onEdit: (app: AppListItemType) => void;
onDelete: (appId: string) => void;
}) => {
return (
<Draggable key={app._id} draggableId={String(app._id)} index={index}>
{(provided, snapshot) => (
<MyBox
ref={provided.innerRef}
{...provided.draggableProps}
style={{
...provided.draggableProps.style,
opacity: snapshot.isDragging ? 0.8 : 1
}}
display="flex"
pl={2}
bg="white"
h={12}
w="full"
borderBottom="1px solid var(--Gray-Modern-150, #F0F1F6)"
_hover={{
bg: 'white',
border: '1px solid var(--Gray-Modern-200, #E8EBF0)',
boxShadow:
'0px 4px 4px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)',
borderRadius: '6px',
zIndex: 2
}}
fontSize="mini"
alignItems="center"
>
{/* 名称列 */}
<Box display="flex" w="20%">
<Flex alignItems="center" gap="10px" width="100%" pl="24px">
<Checkbox
isChecked={selectedAppIds.includes(app._id)}
onChange={(e) => {
e.stopPropagation();
onAppSelect(app._id, e.target.checked);
}}
size="sm"
/>
<Flex {...provided.dragHandleProps} cursor="grab">
<MyIcon name="drag" w="10.5px" h="14px" color="#667085" />
</Flex>
<Flex gap="6px" alignItems="center">
<Flex
w="20px"
h="20px"
borderRadius="4px"
overflow="hidden"
justifyContent="center"
alignItems="center"
bg={
app.avatar
? 'transparent'
: 'linear-gradient(200.75deg, #61D2C4 13.74%, #40CAA1 89.76%)'
}
boxShadow="sm"
>
{app.avatar ? (
<Avatar src={app.avatar} alt={app.name} w="100%" h="100%" />
) : (
<Text color="white" fontSize="16px" fontWeight="bold">
{app.name.charAt(0).toUpperCase()}
</Text>
)}
</Flex>
<Text
fontSize="12px"
fontWeight="500"
color="#111824"
maxWidth="60px"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{app.name}
</Text>
</Flex>
</Flex>
</Box>
{/* 介绍列 */}
<Box w="40%" pl={4}>
<Text color="myGray.500" noOfLines={1}>
{app.intro}
</Text>
</Box>
{/* 标签列 */}
<Box w="30%" pl={4}>
<AppTags tags={app.tags} tagMap={tagMap} />
</Box>
{/* 操作列 */}
<Flex w="10%" justifyContent="center">
<HStack spacing={2}>
<IconButton
size="sm"
variant="ghost"
icon={<MyIcon name="edit" w="14px" />}
aria-label="edit"
onClick={(e) => {
e.stopPropagation();
onEdit(app);
}}
/>
<IconButton
size="sm"
variant="ghost"
colorScheme="red"
icon={<MyIcon name="delete" w="14px" />}
aria-label="delete"
onClick={(e) => {
e.stopPropagation();
onDelete(app._id);
}}
/>
</HStack>
</Flex>
</MyBox>
)}
</Draggable>
);
};
const AppTable = () => {
const { t } = useTranslation();
const [editingApp, setEditingApp] = useState<AppListItemType | null>(null);
const [search, setSearch] = useState('');
const [localAppList, setLocalAppList] = useState<AppListItemType[]>([]);
// 使用多选标签的 hook
const {
value: selectedTags,
setValue: setSelectedTags,
isSelectAll,
setIsSelectAll
} = useMultipleSelect<string>([], false);
// 模态框状态
const tagModal = useDisclosure();
const addAppModal = useDisclosure();
// API 请求
const {
data: appList = [],
loading: loadingApps,
refresh: refreshApps
} = useRequest2(() => listFeatureApps(), { manual: false });
const {
data: tagList = [],
loading: loadingTags,
refresh: refreshTags
} = useRequest2(() => getTeamTags(), { manual: false });
const { runAsync: onReorderApps } = useRequest2(
({ appId, toIndex }: { appId: string; toIndex: number }) => reorderFeatureApps(appId, toIndex),
{
onSuccess: refreshApps,
errorToast: t('common:reorder_failed')
}
);
const { openConfirm: openConfirmDel, ConfirmModal: DelConfirmModal } = useConfirm({
type: 'delete'
});
const { runAsync: onDeleteApp } = useRequest2(delAppById, {
onSuccess: refreshApps,
successToast: t('common:delete_success'),
errorToast: t('common:delete_failed')
});
// 计算属性
const loading = loadingApps || loadingTags;
const tagMap = useMemo(() => {
const map = new Map<string, TagSchemaType>();
(tagList as TagSchemaType[]).forEach((tag) => map.set(tag._id, tag));
return map;
}, [tagList]);
const filteredApps = useMemo(() => {
return localAppList.filter((app) => {
const searchMatch =
!search ||
app.name.toLowerCase().includes(search.toLowerCase()) ||
app.intro?.toLowerCase().includes(search.toLowerCase());
// 多选标签筛选逻辑:如果选择了全部或没有选择任何标签,显示所有应用
// 如果选择了特定标签,应用必须包含至少一个选中的标签
const tagMatch =
isSelectAll ||
selectedTags.length === 0 ||
(app.tags && app.tags.some((tag) => selectedTags.includes(tag)));
return searchMatch && tagMatch;
});
}, [localAppList, search, selectedTags, isSelectAll]);
const allTags = useMemo(
() =>
Array.from(new Set(appList.flatMap((app) => app.tags || []))).map((tag) => ({
label: tagMap.get(tag)?.name || tag,
value: tag
})),
[appList, tagMap]
);
// 自定义 hooks
const selection = useAppSelection(filteredApps);
// 副作用
useEffect(() => {
setLocalAppList(appList);
}, [appList]);
// 事件处理
const handleDragEnd = async (list: AppListItemType[]) => {
// 先更新本地状态以提供即时反馈
setLocalAppList(list);
// 找到被移动的应用 - 需要找到移动距离最大的那个应用
let movedApp: AppListItemType | null = null;
let originalIndex = -1;
let newIndex = -1;
let maxDistance = 0;
// 找到位置发生变化的应用中移动距离最大的(这个就是被拖拽的应用)
for (let i = 0; i < list.length; i++) {
const currentApp = list[i];
const origIndex = filteredApps.findIndex((app) => app._id === currentApp._id);
if (origIndex !== -1 && origIndex !== i) {
const distance = Math.abs(origIndex - i);
if (distance > maxDistance) {
maxDistance = distance;
movedApp = currentApp;
originalIndex = origIndex;
newIndex = i;
}
}
}
if (movedApp && originalIndex !== -1 && newIndex !== -1) {
try {
// 计算在完整应用列表中的目标位置
let targetIndex: number;
if (newIndex === 0) {
// 移动到第一位
targetIndex = 0;
} else if (newIndex === list.length - 1) {
// 移动到最后一位,找到最后一个应用在完整列表中的位置
const lastApp = list[newIndex - 1];
const lastAppIndexInFullList = appList.findIndex((app) => app._id === lastApp._id);
targetIndex = lastAppIndexInFullList + 1;
} else {
// 移动到中间位置
if (originalIndex < newIndex) {
// 向下拖拽:目标位置是新位置后面那个应用在完整列表中的位置
const nextApp = list[newIndex + 1];
const nextAppIndexInFullList = appList.findIndex((app) => app._id === nextApp._id);
targetIndex = nextAppIndexInFullList - 1;
} else {
// 向上拖拽:目标位置是新位置前面那个应用在完整列表中的位置+1
const prevApp = list[newIndex - 1];
const prevAppIndexInFullList = appList.findIndex((app) => app._id === prevApp._id);
targetIndex = prevAppIndexInFullList + 1;
}
}
console.log('拖拽信息:', {
appName: movedApp.name,
originalIndex,
newIndex,
targetIndex,
direction: originalIndex < newIndex ? '向下' : '向上'
});
await onReorderApps({
appId: movedApp._id,
toIndex: targetIndex
});
} catch (error) {
console.error('重新排序失败:', error);
// 如果失败,恢复原始状态
setLocalAppList(appList);
}
}
};
const handleTagModalClose = () => {
tagModal.onClose();
refreshTags();
refreshApps();
};
const handleAddAppSuccess = (selectedApps: any) => {
console.log('Selected apps:', selectedApps);
refreshApps();
};
return (
<MyBox flex="1 0 0" isLoading={loading}>
<Flex flexDirection="column" h="100%">
{/* 筛选控件 */}
<Flex
gap={4}
mb={4}
flexDirection={{ base: 'column', md: 'row' }}
alignItems={{ base: 'stretch', md: 'center' }}
>
<Flex flex={1} gap={4}>
<SearchInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('app:search_app')}
flex={1}
/>
<Box w="200px">
<Menu closeOnSelect={false}>
<MenuButton
as={Button}
rightIcon={<MyIcon name={'core/chat/chevronDown'} w={4} color={'myGray.500'} />}
variant={'outline'}
size={'sm'}
fontSize={'sm'}
textAlign={'left'}
w="100%"
justifyContent="space-between"
>
{isSelectAll
? t('common:All')
: selectedTags.length === 0
? t('common:select_tag')
: `已选择: ${selectedTags.length}`}
</MenuButton>
<MenuList maxH="300px" overflowY="auto">
<MenuItem
onClick={(e) => {
e.preventDefault();
if (isSelectAll) {
setSelectedTags([]);
setIsSelectAll(false);
} else {
setSelectedTags(allTags.map((tag) => tag.value));
setIsSelectAll(true);
}
}}
>
<Checkbox
isChecked={isSelectAll}
mr={2}
onChange={(e) => {
e.stopPropagation();
if (isSelectAll) {
setSelectedTags([]);
setIsSelectAll(false);
} else {
setSelectedTags(allTags.map((tag) => tag.value));
setIsSelectAll(true);
}
}}
/>
{t('common:All')}
</MenuItem>
{allTags.map((tag) => (
<MenuItem
key={tag.value}
onClick={(e) => {
e.preventDefault();
if (isSelectAll) {
// 如果当前是全选状态,取消全选并只选择当前项
setSelectedTags([tag.value]);
setIsSelectAll(false);
} else {
// 正常的多选逻辑
if (selectedTags.includes(tag.value)) {
setSelectedTags(selectedTags.filter((t) => t !== tag.value));
} else {
setSelectedTags([...selectedTags, tag.value]);
}
}
}}
>
<Checkbox
isChecked={isSelectAll || selectedTags.includes(tag.value)}
mr={2}
onChange={(e) => {
e.stopPropagation();
if (isSelectAll) {
// 如果当前是全选状态,取消全选并只选择当前项
setSelectedTags([tag.value]);
setIsSelectAll(false);
} else {
// 正常的多选逻辑
if (selectedTags.includes(tag.value)) {
setSelectedTags(selectedTags.filter((t) => t !== tag.value));
} else {
setSelectedTags([...selectedTags, tag.value]);
}
}
}}
/>
{tag.label}
</MenuItem>
))}
</MenuList>
</Menu>
</Box>
</Flex>
<Flex gap={3}>
<Button
colorScheme="blue"
leftIcon={<MyIcon name="common/add2" w="14px" />}
onClick={addAppModal.onOpen}
minW="120px"
>
{t('common:add_app')}
</Button>
<Button
variant="outline"
leftIcon={<MyIcon name="common/settingLight" w="14px" />}
onClick={tagModal.onOpen}
minW="120px"
>
{t('common:tag_manage')}
</Button>
</Flex>
</Flex>
{/* 表头 */}
<Flex
bg="white"
h={8}
mt={5}
pl={8}
rounded="md"
alignItems="center"
fontSize="mini"
fontWeight="medium"
>
<Box w="20%">
<Flex alignItems="center" gap={2}>
<Checkbox
isChecked={selection.isAllSelected}
isIndeterminate={selection.isIndeterminate}
onChange={(e) => selection.handleSelectAll(e.target.checked)}
size="sm"
/>
{t('common:Name')}
</Flex>
</Box>
<Box w="40%">{t('common:Intro')}</Box>
<Box w="30%">{t('common:Tags')}</Box>
<Box w="10%">{t('common:Action')}</Box>
</Flex>
{/* 应用列表 */}
<Box overflow="auto" mt={4} maxH="calc(100vh - 200px)">
{filteredApps.length > 0 ? (
<DndDrag<AppListItemType> onDragEndCb={handleDragEnd} dataList={filteredApps}>
{({ provided }) => (
<Flex flexDirection="column" {...provided.droppableProps} ref={provided.innerRef}>
{filteredApps.map((app, index) => (
<AppRow
key={app._id}
app={app}
index={index}
tagMap={tagMap}
selectedAppIds={selection.selectedAppIds}
onAppSelect={selection.handleAppSelect}
onEdit={setEditingApp}
onDelete={(appId) => openConfirmDel(() => onDeleteApp(appId))()}
/>
))}
{provided.placeholder}
</Flex>
)}
</DndDrag>
) : (
<EmptyTip
text={loading ? t('common:Loading') : t('common:no_matching_apps_found')}
py={2}
/>
)}
</Box>
{/* 模态框 */}
{editingApp && (
<GateAppInfoModal
app={editingApp}
onClose={() => setEditingApp(null)}
onUpdateSuccess={refreshApps}
/>
)}
{tagModal.isOpen && <TagManageModal onClose={handleTagModalClose} />}
{addAppModal.isOpen && (
<AddFeatureAppModal
isOpen={addAppModal.isOpen}
onClose={addAppModal.onClose}
onSuccess={handleAddAppSuccess}
/>
)}
<DelConfirmModal />
</Flex>
</MyBox>
);
};
export default AppTable;

View File

@@ -0,0 +1,170 @@
import React from 'react';
import { Button, Flex, useDisclosure } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useToast } from '@fastgpt/web/hooks/useToast';
import ShareGateModal from './ShareModol';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { getMyAppsGate, postCreateApp, putAppById } from '@/web/core/app/api';
import { useUserStore } from '@/web/support/user/useUserStore';
import { emptyTemplates } from '@/web/core/app/templates';
import { saveGateConfig } from './HomeTable';
import type { GateSchemaType } from '@fastgpt/global/support/user/team/gate/type';
import type { putUpdateGateConfigCopyRightData } from '@fastgpt/global/support/user/team/gate/api';
import { saveCopyRightConfig } from './CopyrightTable';
import type { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import { form2AppWorkflow } from '@/web/core/app/utils';
type Props = {
tab: 'home' | 'copyright' | 'app' | 'logs';
appForm?: AppSimpleEditFormType;
gateConfig?: GateSchemaType;
copyRightConfig?: putUpdateGateConfigCopyRightData;
};
const ConfigButtons = ({ tab, appForm, gateConfig, copyRightConfig }: Props) => {
const { t } = useTranslation();
const { toast } = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
// 保存配置
const { runAsync: saveHomeConfig, loading: savingHome } = useRequest2(
async () => {
if (!!gateConfig) {
await saveGateConfig(gateConfig);
toast({
title: t('common:save_success'),
status: 'success'
});
}
},
{
manual: true,
onError: (err) => {
toast({
title: t('common:save_failed'),
status: 'error',
description: err?.message
});
}
}
);
console.log('buttons appForm', appForm);
const { nodes, edges } = appForm
? form2AppWorkflow(appForm, t)
: {
nodes: emptyTemplates[AppTypeEnum.gate].nodes,
edges: emptyTemplates[AppTypeEnum.gate].edges
};
// 保存版权配置
const { runAsync: saveCopyrightConfig, loading: savingCopyright } = useRequest2(
async () => {
// 保存其他版权配置
if (!!copyRightConfig) {
await saveCopyRightConfig(copyRightConfig);
toast({
title: t('common:save_success'),
status: 'success'
});
}
},
{
manual: true,
onError: (err) => {
toast({
title: t('common:save_failed'),
status: 'error',
description: err?.message
});
}
}
);
const checkAndCreateGateApp = async () => {
try {
// 获取应用列表
const apps = await getMyAppsGate();
const gateApp = apps.find((app) => app.type === AppTypeEnum.gate);
const currentTeamAvatar = copyRightConfig?.logo;
const currentSlogan = gateConfig?.slogan;
console.log('gateApp', gateApp, currentTeamAvatar, currentSlogan, nodes, edges);
if (gateApp) {
if (
gateApp.avatar !== currentTeamAvatar ||
gateApp.intro !== currentSlogan ||
nodes !== emptyTemplates[AppTypeEnum.gate].nodes ||
edges !== emptyTemplates[AppTypeEnum.gate].edges
) {
await putAppById(gateApp._id, {
avatar: currentTeamAvatar,
intro: currentSlogan,
name: gateConfig?.name,
nodes,
edges
});
toast({
title: t('common:update_success'),
status: 'success'
});
}
} else {
await postCreateApp({
avatar: gateConfig?.logo,
name: gateConfig?.name,
intro: gateConfig?.slogan,
type: AppTypeEnum.gate,
modules: emptyTemplates[AppTypeEnum.gate].nodes,
edges: emptyTemplates[AppTypeEnum.gate].edges,
chatConfig: emptyTemplates[AppTypeEnum.gate].chatConfig
});
toast({
title: t('common:create_success'),
status: 'success'
});
}
} catch (error) {
toast({
title: t('common:error.Create failed'),
status: 'error'
});
}
};
const handleSave = async () => {
if (tab === 'home') {
await saveHomeConfig();
await checkAndCreateGateApp();
} else if (tab === 'copyright') {
await saveCopyrightConfig();
await checkAndCreateGateApp();
}
};
return (
<Flex>
<Button
variant="primaryOutline"
mr={2}
leftIcon={<MyIcon name="support/gate/home/savePrimary" />}
onClick={handleSave}
isLoading={tab === 'home' ? savingHome : savingCopyright}
>
{t('account:gateway.save_config')}
</Button>
<Button
variant={'primary'}
mr={2}
leftIcon={<MyIcon name="support/gate/home/shareLight" />}
onClick={onOpen}
>
{t('account:gateway.share')}
</Button>
{/* 分享门户弹窗 */}
<ShareGateModal gateConfig={gateConfig} isOpen={isOpen} onClose={onClose} />
</Flex>
);
};
export default ConfigButtons;

View File

@@ -0,0 +1,398 @@
import React from 'react';
import { Box, Flex, Text, Input, useBreakpointValue, Image } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
import { useForm } from 'react-hook-form';
import MyIcon from '@fastgpt/web/components/common/Icon';
import type { putUpdateGateConfigCopyRightData } from '@fastgpt/global/support/user/team/gate/api';
import { updateTeamGateConfigCopyRight } from '@/web/support/user/team/gate/api';
type Props = {
gateName: string;
gateLogo: string;
gateBanner: string;
onNameChange?: (name: string) => void;
onLogoChange?: (logo: string) => void;
onBannerChange?: (banner: string) => void;
};
export const saveCopyRightConfig = async (data: putUpdateGateConfigCopyRightData) => {
try {
await updateTeamGateConfigCopyRight(data);
} catch (e) {
console.error('Error saving copyright config:', e);
}
};
// 斜线背景样式
const stripedBackgroundStyle = {
backgroundImage:
'linear-gradient(135deg, #f0f0f0 25%, transparent 25%, transparent 50%, #f0f0f0 50%, #f0f0f0 75%, transparent 75%, transparent)',
backgroundSize: '5px 5px',
padding: '12px',
borderRadius: '16px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)',
border: '1px dashed #e0e0e0'
};
// 添加悬浮遮罩样式
const uploadOverlayStyle = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
border: '1px dashed var(--Royal-Blue-200, #C5D7FF)',
background: 'rgba(255, 255, 255, 0.5)',
backdropFilter: 'blur(2px)',
zIndex: 10,
opacity: 0,
transition: 'opacity 0.3s ease',
_groupHover: {
opacity: 1
}
};
const CopyrightTable = ({
gateName,
gateLogo,
gateBanner,
onNameChange,
onLogoChange,
onBannerChange
}: Props) => {
const { t } = useTranslation();
// 使用useForm管理表单数据
const { setValue, watch } = useForm({
defaultValues: {
name: gateName,
logo: gateLogo,
banner: gateBanner
}
});
// 从表单中获取logo和banner值
const logo = watch('logo');
const banner = watch('banner');
const handleGateNameChange = (name: string) => {
setValue('name', name);
onNameChange?.(name);
};
const handleGateLogoChange = (logo: string) => {
setValue('logo', logo);
onLogoChange?.(logo);
};
const handleGateBannerChange = (banner: string) => {
setValue('banner', banner);
onBannerChange?.(banner);
};
// 添加文件选择器 - 分别为左右两侧Logo创建选择器
const {
File: LogoFile,
onOpen: onOpenLogoFile,
onSelectImage: onSelectLogoImage
} = useSelectFile({
fileType: '.jpg,.png,.svg',
multiple: false
});
const {
File: BannerFile,
onOpen: onOpenBannerFile,
onSelectImage: onSelectBannerImage
} = useSelectFile({
fileType: '.jpg,.png,.svg',
multiple: false
});
// 响应式尺寸 - 根据设计比例调整
const logoBoxSize = useBreakpointValue({ md: '60px' });
const logoBorderRadius = useBreakpointValue({ base: '5.8px', md: '15px' });
const titleFontSize = useBreakpointValue({ base: '18px', md: '28px' });
const dividerHeight = useBreakpointValue({ base: '70px', md: '84px' });
// 左侧带文本的Logo稍大一些
const logoBoxSizeWithText = useBreakpointValue({ base: '28px', md: '60px' });
return (
<Box flex={'1 0 0'} overflow={'hidden'} display="flex" justifyContent="center">
<Box w="100%" maxW={{ base: '100%', md: '640px' }} py={{ base: 4, md: 6 }}>
<Flex flexDirection={'column'} gap={{ base: 4, md: 6 }}>
{/* 基础设置区域 */}
<Flex flexDirection="column" gap={{ base: 3, md: 4 }}>
<Flex alignItems="center" gap={3}>
<Box w="4px" h="16px" bg="#3370FF" borderRadius="6px" />
<Text fontSize={{ base: '14px', md: '16px' }} fontWeight={500}>
{t('common:base_config')}
</Text>
</Flex>
<Flex direction="column" gap={2}>
<Text fontSize="14px" color="#485264" fontWeight={500}>
{t('account_gate:gate_name')}
</Text>
<Input
value={watch('name')}
onChange={(e) => handleGateNameChange(e.target.value)}
bg="#FBFBFC"
border="1px solid #E8EBF0"
borderRadius="8px"
height={{ base: '36px', md: '40px' }}
/>
</Flex>
</Flex>
{/* Logo 设置区域 */}
<Flex flexDirection="column" gap={{ base: 3, md: 4 }}>
<Text fontSize="14px" color="#485264" fontWeight={500}>
{t('account_gate:gate_logo')}
</Text>
<Flex
gap={{ base: 4, md: 8 }}
alignItems="center"
justifyContent="flex-start"
flexDirection={{ base: 'column', md: 'row' }}
>
{/* 左侧 Banner 显示 - 带文字 */}
<Flex direction="column" gap={2} alignItems="center">
<Box
sx={stripedBackgroundStyle}
onClick={onOpenBannerFile}
cursor="pointer"
role="group"
position="relative"
width="100%"
padding="20px"
>
<Flex gap={{ base: 3, md: 5 }} alignItems="center" width="100%">
{banner ? (
<Box
width="100%"
height={logoBoxSizeWithText}
justifyContent="center"
alignItems="center"
position="relative"
overflow="hidden"
boxSizing="border-box"
transition="all 0.3s ease"
display="flex"
>
<Image
src={banner}
alt="Team Banner"
width="100%"
height="100%"
objectFit="contain"
objectPosition="center"
style={
{
imageRendering: 'crisp-edges'
} as React.CSSProperties
}
/>
</Box>
) : (
<Box
width="100%"
height={logoBoxSizeWithText}
bg="white"
border="0.483px solid #ECECEC"
display="flex"
justifyContent="center"
alignItems="center"
position="relative"
overflow="hidden"
boxSizing="border-box"
transition="all 0.3s ease"
>
<Flex
width="40px"
height="40px"
justifyContent="center"
alignItems="center"
flexShrink="0"
aspectRatio="1/1"
>
<Image
src={banner}
alt="Team Banner"
width="100%"
height="100%"
objectFit="contain"
fallbackSrc="/icon/logo.svg"
style={
{
imageRendering: 'crisp-edges'
} as React.CSSProperties
}
/>
</Flex>
</Box>
)}
</Flex>
{/* 悬浮遮罩 - 4:1 */}
<Box
sx={{
...uploadOverlayStyle,
width: '100%',
height: '100%'
}}
borderRadius="16px"
>
<Flex direction="column" alignItems="center" justifyContent="center">
<MyIcon
name="support/gate/home/upload"
width="24px"
height="24px"
color="blue.500"
/>
</Flex>
</Box>
</Box>
<Text fontSize="12px" color="#667085" alignSelf="flex-start">
{t('account_gate:suggestion_ratio_4_1')}
</Text>
</Flex>
<Box display={{ base: 'none', md: 'block' }} w="1px" h={dividerHeight} bg="#F0F1F6" />
{/* 右侧 Logo 显示 - 仅Logo */}
<Flex direction="column" gap={2} alignItems="center">
<Box
sx={stripedBackgroundStyle}
onClick={onOpenLogoFile}
cursor="pointer"
role="group"
position="relative"
>
{logo ? (
<Flex
width={logoBoxSize}
height={logoBoxSize}
justifyContent="center"
alignItems="center"
position="relative"
overflow="hidden"
borderRadius={logoBorderRadius}
boxSizing="border-box"
transition="all 0.3s ease"
>
<Image
src={logo}
alt="Team Logo"
width="100%"
height="100%"
objectFit="contain"
style={
{
imageRendering: 'crisp-edges'
} as React.CSSProperties
}
/>
</Flex>
) : (
<Box
width={logoBoxSize}
height={logoBoxSize}
bg="white"
border="0.483px solid #ECECEC"
borderRadius={logoBorderRadius}
display="flex"
justifyContent="center"
alignItems="center"
position="relative"
overflow="hidden"
boxSizing="border-box"
transition="all 0.3s ease"
>
<Flex
width="40px"
height="40px"
justifyContent="center"
alignItems="center"
flexShrink="0"
aspectRatio="1/1"
>
<Image
src={logo}
alt="Team Logo"
width="100%"
height="100%"
objectFit="contain"
fallbackSrc="/icon/logo.svg"
/>
</Flex>
</Box>
)}
{/* 悬浮遮罩 - 1:1 */}
<Box
sx={{
...uploadOverlayStyle,
width: '100%',
height: '100%'
}}
borderRadius="16px"
>
<Flex direction="column" alignItems="center" justifyContent="center">
<MyIcon
name="support/gate/home/upload"
width="24px"
height="24px"
color="blue.500"
/>
</Flex>
</Box>
</Box>
<Text fontSize="12px" color="#667085">
{t('account_gate:suggestion_ratio_1_1')}
</Text>
</Flex>
</Flex>
</Flex>
</Flex>
</Box>
{/* 文件选择器组件 */}
<LogoFile
onSelect={(e: File[]) =>
onSelectLogoImage(e, {
maxH: 300,
maxW: 300,
callback: (e: string) => {
setValue('logo', e);
handleGateLogoChange(e);
}
})
}
/>
<BannerFile
onSelect={(e: File[]) =>
onSelectBannerImage(e, {
maxH: 300,
maxW: 300,
callback: (e: string) => {
setValue('banner', e);
handleGateBannerChange(e);
}
})
}
/>
</Box>
);
};
export default CopyrightTable;

View File

@@ -0,0 +1,356 @@
import {
Box,
Button,
Flex,
FormControl,
Input,
ModalBody,
ModalFooter,
Textarea,
HStack,
Text,
Tag as ChakraTag,
TagCloseButton,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
useDisclosure
} from '@chakra-ui/react';
import type { AppListItemType } from '@fastgpt/global/core/app/type.d';
import type { TagSchemaType } from '@fastgpt/global/core/app/tags';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useTranslation } from 'next-i18next';
import React, { useCallback, useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
import { putAppById } from '@/web/core/app/api';
import {
getTeamTags,
addTagToApp,
removeTagFromApp,
batchAddTagsToApp,
batchRemoveTagsFromApp
} from '@/web/core/app/api/tags';
import MyIcon from '@fastgpt/web/components/common/Icon';
interface AppInfoModalProps {
app: AppListItemType;
onClose: () => void;
onUpdateSuccess?: () => void;
}
const AppInfoModal = ({ app, onClose, onUpdateSuccess }: AppInfoModalProps) => {
const { t } = useTranslation();
const { toast } = useToast();
const { isOpen, onOpen, onClose: onClosePopover } = useDisclosure();
const [appTags, setAppTags] = useState<string[]>(app.tags || []);
const [availableTags, setAvailableTags] = useState<TagSchemaType[]>([]);
const [initialTags, setInitialTags] = useState<string[]>(app.tags || []);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const {
File,
onOpen: onOpenSelectFile,
onSelectImage
} = useSelectFile({
fileType: '.jpg,.png',
multiple: false
});
const {
register,
setValue,
watch,
formState: { errors },
handleSubmit
} = useForm({
defaultValues: {
name: app.name,
avatar: app.avatar,
intro: app.intro
}
});
const avatar = watch('avatar');
// 获取所有标签
const { data: tags = [], loading: loadingTags } = useRequest2(
async () => {
const result = await getTeamTags();
return result as TagSchemaType[];
},
{
manual: false,
refreshDeps: [refreshTrigger],
onSuccess: (data) => {
setAvailableTags(data);
}
}
);
useEffect(() => {
// 如果应用的标签有变化,通知父组件刷新
if (app.tags && initialTags && JSON.stringify(app.tags) !== JSON.stringify(initialTags)) {
setInitialTags([...app.tags]);
if (onUpdateSuccess) onUpdateSuccess();
}
}, [app.tags, initialTags, onUpdateSuccess]);
// 添加标签到应用
const { runAsync: addTag, loading: addTagLoading } = useRequest2(
async (tagId: string) => {
if (!appTags.includes(tagId)) {
setAppTags([...appTags, tagId]);
}
return tagId;
},
{
onSuccess: (tagId) => {
onClosePopover();
}
}
);
// 从应用移除标签
const { runAsync: removeTag, loading: removeTagLoading } = useRequest2(async (tagId: string) => {
setAppTags(appTags.filter((id) => id !== tagId));
return tagId;
});
// 保存所有标签更改
const saveTagChanges = useCallback(async () => {
const tagsToAdd = appTags.filter((tagId) => !initialTags.includes(tagId));
const tagsToRemove = initialTags.filter((tagId) => !appTags.includes(tagId));
let hasChanges = false;
if (tagsToAdd.length > 0) {
await batchAddTagsToApp(app._id, tagsToAdd);
hasChanges = true;
}
if (tagsToRemove.length > 0) {
await batchRemoveTagsFromApp(app._id, tagsToRemove);
hasChanges = true;
}
setInitialTags([...appTags]);
return hasChanges;
}, [appTags, initialTags, app._id]);
// submit config
const { runAsync: saveSubmitSuccess, loading: btnLoading } = useRequest2(
async (data: { name: string; avatar: string; intro: string }) => {
// 使用正确的 API 函数 putAppById
await putAppById(app._id, {
name: data.name,
avatar: data.avatar,
intro: data.intro
});
// 保存标签变更
const tagsChanged = await saveTagChanges();
return tagsChanged; // 返回标签是否有变更
},
{
onSuccess(tagsChanged) {
toast({
title: t('common:update_success'),
status: 'success'
});
if (onUpdateSuccess) onUpdateSuccess();
onClose();
},
errorToast: t('common:update_failed')
}
);
const saveSubmitError = useCallback(() => {
const deepSearch = (obj: any): string => {
if (!obj) return t('common:submit_failed');
if (!!obj.message) {
return obj.message;
}
return deepSearch(Object.values(obj)[0]);
};
toast({
title: deepSearch(errors),
status: 'error',
duration: 4000,
isClosable: true
});
}, [errors, t, toast]);
const saveUpdateModel = useCallback(
() => handleSubmit((data) => saveSubmitSuccess(data), saveSubmitError)(),
[handleSubmit, saveSubmitError, saveSubmitSuccess]
);
// 获取标签样式
const getTagStyle = (color: string) => {
// 处理自定义颜色 (#XXXXXX)
if (color.startsWith('#')) {
return {
bg: `${color}15`, // 15 表示透明度
color: color
};
}
// 预设颜色
const colorMap: Record<string, { bg: string; color: string }> = {
blue: { bg: 'blue.50', color: 'blue.600' },
green: { bg: 'green.50', color: 'green.600' },
red: { bg: 'red.50', color: 'red.600' },
yellow: { bg: 'yellow.50', color: 'yellow.600' },
purple: { bg: 'purple.50', color: 'purple.600' },
teal: { bg: 'teal.50', color: 'teal.600' }
};
return colorMap[color] || colorMap.blue;
};
// 获取当前选中的标签
const getSelectedTags = useCallback(() => {
return tags.filter((tag) => appTags.includes(tag._id));
}, [tags, appTags]);
// 获取未选中的标签
const getUnselectedTags = useCallback(() => {
return tags.filter((tag) => !appTags.includes(tag._id));
}, [tags, appTags]);
return (
<MyModal
isOpen={true}
onClose={onClose}
iconSrc="/imgs/workflow/ai.svg"
title={t('common:core.app.setting')}
>
<ModalBody>
<Box fontSize={'sm'}>{t('common:core.app.Name and avatar')}</Box>
<Flex mt={2} alignItems={'center'}>
<Avatar
src={avatar}
w={['26px', '34px']}
h={['26px', '34px']}
cursor={'pointer'}
borderRadius={'md'}
mr={4}
title={t('common:set_avatar')}
onClick={() => onOpenSelectFile()}
/>
<FormControl>
<Input
bg={'myWhite.600'}
placeholder={t('common:core.app.Set a name for your app')}
{...register('name', {
required: true
})}
></Input>
</FormControl>
</Flex>
<Box mt={4} mb={1} fontSize={'sm'}>
{t('common:core.app.App intro')}
</Box>
<Textarea
rows={4}
maxLength={500}
placeholder={t('common:core.app.Make a brief introduction of your app')}
bg={'myWhite.600'}
{...register('intro')}
/>
{/* 标签管理部分 */}
<Box mt={4} mb={2} fontSize={'sm'}>
</Box>
<Flex direction="column" gap={2}>
<Flex wrap="wrap" gap={2} mb={2} minH="30px">
{getSelectedTags().map((tag) => (
<ChakraTag
key={tag._id}
size="md"
variant="subtle"
{...getTagStyle(tag.color)}
px={3}
py={1}
borderRadius="full"
>
{tag.name}
<TagCloseButton onClick={() => removeTag(tag._id)} isDisabled={removeTagLoading} />
</ChakraTag>
))}
<Popover isOpen={isOpen} onClose={onClosePopover} placement="bottom-start">
<PopoverTrigger>
<Button
size="sm"
variant="outline"
leftIcon={<MyIcon name="common/addLight" w="12px" />}
onClick={onOpen}
isLoading={loadingTags}
fontWeight="normal"
h="30px"
>
</Button>
</PopoverTrigger>
<PopoverContent w="200px">
<PopoverBody p={2}>
{getUnselectedTags().length === 0 ? (
<Text fontSize="sm" color="gray.500" textAlign="center" p={2}>
</Text>
) : (
<Flex direction="column" gap={1}>
{getUnselectedTags().map((tag) => (
<ChakraTag
key={tag._id}
size="md"
variant="subtle"
{...getTagStyle(tag.color)}
px={3}
py={1.5}
borderRadius="full"
cursor="pointer"
onClick={() => addTag(tag._id)}
_hover={{ opacity: 0.8 }}
>
{tag.name}
</ChakraTag>
))}
</Flex>
)}
</PopoverBody>
</PopoverContent>
</Popover>
</Flex>
</Flex>
</ModalBody>
<ModalFooter>
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
{t('common:Close')}
</Button>
<Button isLoading={btnLoading} onClick={saveUpdateModel}>
{t('common:Save')}
</Button>
</ModalFooter>
<File
onSelect={(e) =>
onSelectImage(e, {
maxH: 300,
maxW: 300,
callback: (e) => setValue('avatar', e)
})
}
/>
</MyModal>
);
};
export default React.memo(AppInfoModal);

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { Box, Flex, Text, Avatar, Heading, Button } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import type { AppListItemType } from '@fastgpt/global/core/app/type';
import { useRouter } from 'next/router';
type Props = {
gateApps: AppListItemType[];
};
const GateAppsList = ({ gateApps }: Props) => {
const { t } = useTranslation();
const router = useRouter();
const handleGateClick = (appId: string) => {
router.push(`/app/detail?appId=${appId}`);
};
return (
<Box w="220px" h="100%" bg="#FBFBFC" borderRight="1px solid #E8EBF0" p={5} overflowY="auto">
<Flex justifyContent="space-between" alignItems="center" mb={4}>
<Heading size="sm">{t('account_gate:gate_list')}</Heading>
</Flex>
{gateApps.length === 0 ? (
<Flex direction="column" justify="center" align="center" h="180px" gap={4}>
<Text color="gray.500" fontSize="sm" textAlign="center">
{t('account_gate:no_gate_available')}
</Text>
</Flex>
) : (
<Flex direction="column" gap={3}>
{gateApps.map((gate) => (
<Flex
key={gate._id}
align="center"
p={3}
borderRadius="md"
cursor="pointer"
transition="all 0.2s ease"
bg="white"
border="1px solid"
borderColor="gray.100"
boxShadow="0 2px 8px rgba(0,0,0,0.06)"
_hover={{
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
borderColor: 'primary.300'
}}
onClick={() => handleGateClick(gate._id)}
>
<Avatar src={gate.avatar} size="sm" mr={3} borderRadius="md" />
<Box>
<Text fontSize="sm" fontWeight="medium" className="textEllipsis">
{gate.name}
</Text>
{gate.intro && (
<Text fontSize="xs" color="gray.500" className="textEllipsis">
{gate.intro}
</Text>
)}
</Box>
</Flex>
))}
</Flex>
)}
</Box>
);
};
export default GateAppsList;

View File

@@ -0,0 +1,607 @@
import React, { useRef, useEffect, useCallback, useState } from 'react';
import {
Box,
Flex,
Text,
Radio,
RadioGroup,
Stack,
Input,
FormControl,
FormLabel,
Link
} from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import ToolSelect from './ToolSelect';
import type { putUpdateGateConfigData } from '@fastgpt/global/support/user/team/gate/api';
import { updateTeamGateConfig } from '@/web/support/user/team/gate/api';
import { appWorkflow2Form, getDefaultAppForm } from '@fastgpt/global/core/app/utils';
import type { SimpleAppSnapshotType } from '@/pageComponents/app/detail/SimpleApp/useSnapshots';
import { getAppConfigByDiff } from '@/web/core/app/diff';
import { v1Workflow2V2 } from '@/web/core/workflow/adapt';
import { useMount } from 'ahooks';
import type { AppDetailType, AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import { useSimpleAppSnapshots } from '@/pageComponents/app/detail/Gate/useSnapshots';
import { Dropdown } from 'react-day-picker';
import MyIcon from '@fastgpt/web/components/common/Icon';
import AddQuickAppModal from './AddQuickAppModal';
import { listQuickApps } from '@/web/support/user/team/gate/quickApp';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import Avatar from '@fastgpt/web/components/common/Avatar';
import type { AppListItemType } from '@fastgpt/global/core/app/type';
export const saveGateConfig = async (data: putUpdateGateConfigData) => {
try {
await updateTeamGateConfig(data);
} catch (error) {
console.error('Failed to save gate config:', error);
}
};
type Props = {
appDetail: AppDetailType;
tools: string[];
slogan: string;
placeholderText: string;
onStatusChange?: (status: boolean) => void;
onSloganChange?: (slogan: string) => void;
onPlaceholderChange?: (text: string) => void;
onToolsChange?: (tools: string[]) => void;
onAppFormChange?: (appForm: AppSimpleEditFormType) => void;
};
const HomeTable = ({
appDetail,
slogan,
placeholderText,
onStatusChange,
onSloganChange,
onPlaceholderChange,
onAppFormChange
}: Props) => {
const { t } = useTranslation();
// 批量获取插件信息
const [appForm, setAppForm] = useState(getDefaultAppForm());
// 快捷应用modal状态
const [isQuickAppModalOpen, setIsQuickAppModalOpen] = useState(false);
// 获取快捷应用数据
const {
data: quickApps = [],
loading: loadingQuickApps,
refresh: refreshQuickApps
} = useRequest2(() => listQuickApps(), {
manual: false
});
const { forbiddenSaveSnapshot, past, setPast, saveSnapshot } = useSimpleAppSnapshots(
appDetail._id
);
useMount(() => {
if (appDetail.version !== 'v2') {
const form = appWorkflow2Form({
nodes: v1Workflow2V2((appDetail.modules || []) as any)?.nodes,
chatConfig: appDetail.chatConfig
});
return updateAppForm(form);
}
// 读取旧的存储记录
const pastSnapshot = (() => {
try {
const pastSnapshot = localStorage.getItem(`${appDetail._id}-past`);
return pastSnapshot ? (JSON.parse(pastSnapshot) as SimpleAppSnapshotType[]) : [];
} catch (error) {
return [];
}
})();
const defaultState = pastSnapshot?.[pastSnapshot.length - 1]?.state;
if (pastSnapshot?.[0]?.diff && defaultState) {
setPast(
pastSnapshot
.map((item) => {
if (!item.state && !item.diff) return;
if (!item.diff) {
return {
title: t('app:initial_form'),
isSaved: true,
appForm: defaultState
};
}
const currentState = getAppConfigByDiff(defaultState, item.diff);
return {
title: item.title,
isSaved: item.isSaved,
appForm: currentState
};
})
.filter(Boolean) as SimpleAppSnapshotType[]
);
const pastState = getAppConfigByDiff(defaultState, pastSnapshot[0].diff);
localStorage.removeItem(`${appDetail._id}-past`);
return updateAppForm(pastState);
}
// 无旧的记录,正常初始化
if (past.length === 0) {
const appForm = appWorkflow2Form({
nodes: appDetail.modules,
chatConfig: appDetail.chatConfig
});
saveSnapshot({
appForm,
title: t('app:initial_form'),
isSaved: true
});
updateAppForm(appForm);
} else {
updateAppForm(past[0].appForm);
}
});
// 通用样式变量
const spacing = {
xs: '4px',
sm: '8px',
md: '12px',
lg: '16px',
xl: '20px'
};
const formStyles = {
fontSize: '14px',
lineHeight: '20px',
fontWeight: '500',
letterSpacing: '0.1px'
};
const inputStyles = {
padding: '10px 12px',
height: '40px',
fontSize: '14px',
lineHeight: '20px',
letterSpacing: '0.25px'
};
// 响应式工具布局
const handleStatusChange = (val: string) => {
onStatusChange?.(val === 'enabled');
};
const handleSloganChange = (val: string) => {
onSloganChange?.(val);
};
const handlePlaceholderChange = (val: string) => {
onPlaceholderChange?.(val);
};
// 快捷应用相关处理函数
const handleOpenQuickAppModal = () => {
setIsQuickAppModalOpen(true);
};
const handleCloseQuickAppModal = () => {
setIsQuickAppModalOpen(false);
};
const handleQuickAppSuccess = (selectedApps: any[]) => {
// 刷新快捷应用列表
refreshQuickApps();
console.log('快捷应用更新成功:', selectedApps);
setIsQuickAppModalOpen(false);
};
// 修改 setAppForm使其同时调用父组件的回调
const updateAppForm = useCallback(
(newAppForm: AppSimpleEditFormType) => {
setAppForm(newAppForm);
onAppFormChange?.(newAppForm);
},
[onAppFormChange]
);
// 渲染快捷应用项
const renderQuickAppItem = (app: AppListItemType, index: number) => {
const gradients = [
'linear-gradient(200.75deg, #67BFFF 13.74%, #5BA6FF 89.76%)', // 蓝色渐变
'linear-gradient(200.75deg, #7895FE 13.74%, #7177FF 89.76%)', // 紫色渐变
'linear-gradient(200.75deg, #67BFFF 13.74%, #5BA6FF 89.76%)', // 蓝色渐变
'linear-gradient(200.75deg, #67BFFF 13.74%, #5BA6FF 89.76%)' // 蓝色渐变
];
const gradient = gradients[index % gradients.length];
return (
<React.Fragment key={app._id}>
{/* 应用项 */}
<Flex
flexDirection="column"
alignItems="flex-start"
padding="4px 0px"
gap="10px"
w="80px"
h="28px"
borderRadius="6px"
>
<Flex alignItems="center" gap="4px" w="80px" h="20px">
<Box
w="20px"
h="20px"
background={gradient}
borderRadius="6px"
position="relative"
overflow="hidden"
>
{app.avatar ? (
<Avatar src={app.avatar} alt={app.name} w="100%" h="100%" borderRadius="6px" />
) : (
<MyIcon
name="core/app/type/simple"
position="absolute"
left="15%"
right="15%"
top="15%"
bottom="15%"
color="white"
/>
)}
</Box>
<Text
w="56px"
h="16px"
fontFamily="PingFang SC"
fontWeight={400}
fontSize="12px"
lineHeight="16px"
letterSpacing="0.004em"
color="#111824"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{app.name}
</Text>
</Flex>
</Flex>
{/* 分隔线 - 除了最后一个应用之外都显示 */}
{index < Math.min(quickApps.length - 1, 3) && (
<Box w="11.46px" h="0px" border="1px solid #DFE2EA" transform="rotate(90deg)" />
)}
</React.Fragment>
);
};
return (
<Box flex="1 0 0" overflow="auto" px={spacing.sm}>
<Flex
flexDirection="column"
alignItems="center"
gap={spacing.xl}
maxW="640px"
mx="auto"
pb={6}
pt={{ base: 4, md: 6 }}
>
{/* 状态选择 */}
<FormControl display="flex" flexDirection="column" gap={spacing.sm} w="full">
<FormLabel
fontWeight={formStyles.fontWeight}
fontSize={formStyles.fontSize}
lineHeight={formStyles.lineHeight}
letterSpacing={formStyles.letterSpacing}
color="myGray.700"
mb="0"
>
{t('account_gate:status')}
</FormLabel>
<RadioGroup value={status ? 'enabled' : 'disabled'} onChange={handleStatusChange}>
<Stack direction={{ base: 'column', sm: 'row' }} spacing={spacing.md}>
<Flex
alignItems="center"
p={`${spacing.sm} ${spacing.lg} ${spacing.sm} ${spacing.md}`}
borderWidth="1px"
borderColor={status ? 'primary.500' : 'myGray.200'}
borderRadius="7px"
bg={status ? 'blue.50' : 'white'}
transition="all 0.2s ease-in-out"
_hover={{
bg: status ? 'blue.100' : 'myGray.50',
borderColor: status ? 'primary.600' : 'myGray.300',
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.05)',
transform: 'translateY(-1px)'
}}
>
<Radio value="enabled" colorScheme="blue">
<Text
fontSize={formStyles.fontSize}
lineHeight={formStyles.lineHeight}
fontWeight={formStyles.fontWeight}
letterSpacing={formStyles.letterSpacing}
>
{t('account_gate:enabled')}
</Text>
</Radio>
</Flex>
<Flex
alignItems="center"
p={`${spacing.sm} ${spacing.lg} ${spacing.sm} ${spacing.md}`}
borderWidth="1px"
borderColor={!status ? 'primary.500' : 'myGray.200'}
borderRadius="7px"
bg={!status ? 'blue.50' : 'white'}
transition="all 0.2s ease-in-out"
_hover={{
bg: !status ? 'blue.100' : 'myGray.50',
borderColor: !status ? 'primary.600' : 'myGray.300',
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.05)',
transform: 'translateY(-1px)'
}}
>
<Radio value="disabled" colorScheme="blue">
<Text
fontSize={formStyles.fontSize}
lineHeight={formStyles.lineHeight}
fontWeight={formStyles.fontWeight}
letterSpacing={formStyles.letterSpacing}
>
{t('account_gate:disabled')}
</Text>
</Radio>
</Flex>
</Stack>
</RadioGroup>
</FormControl>
{/* 快捷应用 */}
<FormControl
display={'flex'}
flexDirection={'column'}
justifyContent={'center'}
alignItems={'flex-start'}
gap={'8px'}
>
{/* 标题行 */}
<Flex alignItems={'center'} gap={'4px'}>
<Text
color={'var(--Gray-Modern-600, #485264)'}
fontFamily={'PingFang SC'}
fontSize={'14px'}
fontWeight={500}
lineHeight={'20px'}
letterSpacing={'0.1px'}
>
{t('account_gate:quick_app')}
</Text>
<MyIcon name="common/help" w="16px" h="16px" color="#667085" />
</Flex>
{/* 下拉框区域 */}
<Flex alignItems="center" gap="8px" w="640px" h="40px">
{/* 应用容器 */}
<Box
position="relative"
w="600px"
h="40px"
bg="#FBFBFC"
border="1px solid #E8EBF0"
borderRadius="8px"
>
{/* 应用列表 */}
<Flex
position="absolute"
alignItems="center"
gap="8px"
w="560px"
h="28px"
left="12px"
top="calc(50% - 14px)"
>
{quickApps.length > 0 ? (
quickApps.slice(0, 4).map((app, index) => renderQuickAppItem(app, index))
) : (
<Text fontSize="12px" color="#667085" fontFamily="PingFang SC">
{loadingQuickApps ? '加载中...' : '暂无快捷应用'}
</Text>
)}
</Flex>
</Box>
{/* 设置按钮 */}
<Flex
alignItems="center"
justifyContent="center"
padding="7px"
gap="6px"
w="32px"
h="32px"
borderRadius="6px"
cursor="pointer"
onClick={handleOpenQuickAppModal}
_hover={{
bg: 'myGray.100'
}}
>
<MyIcon name="common/settingLight" w="18px" h="18px" color="#667085" />
</Flex>
</Flex>
</FormControl>
{/* 可用工具选择 */}
<FormControl display="flex" flexDirection="column" gap={spacing.sm} w="full">
<ToolSelect
appForm={appForm}
setAppForm={updateAppForm} // 使用 updateAppForm 替代 setAppForm
/>
</FormControl>
{/* slogan设置 */}
<FormControl display="flex" flexDirection="column" gap={spacing.sm} w="full">
<Flex alignItems="center" gap={spacing.xs}>
<Text
fontWeight={formStyles.fontWeight}
fontSize={formStyles.fontSize}
lineHeight={formStyles.lineHeight}
letterSpacing={formStyles.letterSpacing}
color="myGray.700"
>
{t('account_gate:slogan')}
</Text>
<Link
color="primary.500"
fontSize={formStyles.fontSize}
fontWeight={formStyles.fontWeight}
textDecoration="underline"
>
{t('account_gate:example')}
</Link>
</Flex>
<Input
value={slogan}
onChange={(e) => handleSloganChange(e.target.value)}
bg="myGray.50"
borderWidth="1px"
borderColor="myGray.200"
borderRadius="8px"
p={inputStyles.padding}
h={inputStyles.height}
fontSize={inputStyles.fontSize}
lineHeight={inputStyles.lineHeight}
letterSpacing={inputStyles.letterSpacing}
color="gray.900"
/>
</FormControl>
{/* 对话提示文字 */}
<FormControl display="flex" flexDirection="column" gap={spacing.sm} w="full">
<Flex alignItems="center" gap={spacing.xs}>
<Text
fontWeight={formStyles.fontWeight}
fontSize={formStyles.fontSize}
lineHeight={formStyles.lineHeight}
letterSpacing={formStyles.letterSpacing}
color="myGray.700"
>
{t('account_gate:dialog_prompt_text')}
</Text>
<Link
color="primary.500"
fontSize={formStyles.fontSize}
fontWeight={formStyles.fontWeight}
textDecoration="underline"
>
{t('account_gate:example')}
</Link>
</Flex>
<Input
value={placeholderText}
onChange={(e) => handlePlaceholderChange(e.target.value)}
bg="myGray.50"
borderWidth="1px"
borderColor="myGray.200"
borderRadius="8px"
p={inputStyles.padding}
h={inputStyles.height}
fontSize={inputStyles.fontSize}
lineHeight={inputStyles.lineHeight}
letterSpacing={inputStyles.letterSpacing}
color="gray.900"
/>
</FormControl>
{/* 可用工具 */}
{/* <FormControl display="flex" flexDirection="column" gap={spacing.sm} w="full">
<Flex gap={spacing.xs}>
<FormLabel
fontWeight={formStyles.fontWeight}
fontSize={formStyles.fontSize}
lineHeight={formStyles.lineHeight}
letterSpacing={formStyles.letterSpacing}
color="myGray.700"
mb="0"
>
{t('account_gate:available_tools')}
</FormLabel>
<QuestionTip />
</Flex>
<CheckboxGroup colorScheme="blue" value={tools} onChange={handleToolsChange}>
<Wrap spacing={toolsSpacing}>
{[
{ value: 'webSearch', label: t('account_gate:web_search') },
{ value: 'deepThinking', label: t('account_gate:deep_thinking') },
{ value: 'fileUpload', label: t('account_gate:file_upload') },
{ value: 'imageUpload', label: t('account_gate:image_upload') },
{ value: 'voiceInput', label: t('account_gate:voice_input') }
].map((item) => (
<WrapItem key={item.value}>
<Flex
p={`${spacing.sm} ${spacing.lg} ${spacing.sm} ${spacing.md}`}
borderWidth="1px"
borderColor={
tools.includes(item.value as GateTool) ? 'primary.500' : 'myGray.200'
}
borderRadius="7px"
bg={tools.includes(item.value as GateTool) ? 'blue.50' : 'white'}
transition="all 0.2s ease-in-out"
_hover={{
bg: tools.includes(item.value as GateTool) ? 'blue.100' : 'myGray.50',
borderColor: tools.includes(item.value as GateTool)
? 'primary.600'
: 'myGray.300',
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.05)',
transform: 'translateY(-1px)'
}}
>
<Checkbox
value={item.value}
colorScheme="blue"
isChecked={tools.includes(item.value as GateTool)}
>
<Text
fontSize={formStyles.fontSize}
lineHeight={formStyles.lineHeight}
fontWeight={formStyles.fontWeight}
letterSpacing={formStyles.letterSpacing}
>
{item.label}
</Text>
</Checkbox>
</Flex>
</WrapItem>
))}
</Wrap>
</CheckboxGroup>
</FormControl> */}
</Flex>
{/* 快捷应用配置Modal */}
{isQuickAppModalOpen && (
<AddQuickAppModal
isOpen={isQuickAppModalOpen}
onClose={handleCloseQuickAppModal}
onSuccess={handleQuickAppSuccess}
/>
)}
</Box>
);
};
export default HomeTable;
// 导出常量供其他组件使用
export const spacing = {
xs: '4px',
sm: '8px',
md: '12px',
lg: '16px',
xl: '20px'
};
export const formStyles = {
fontSize: '14px',
lineHeight: '20px',
fontWeight: '500',
letterSpacing: '0.1px'
};

View File

@@ -0,0 +1,186 @@
import React, { useMemo, useState, useEffect } from 'react';
import { Box, type BoxProps, Flex, Checkbox } from '@chakra-ui/react';
import {
type GetResourceFolderListProps,
type GetResourceListItemResponse,
type ParentIdType
} from '@fastgpt/global/common/parentFolder/type';
import MyIcon from '@fastgpt/web/components/common/Icon';
import Loading from '@fastgpt/web/components/common/MyLoading';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useMemoizedFn } from 'ahooks';
import { FolderImgUrl } from '@fastgpt/global/common/file/image/constants';
import { useTranslation } from 'next-i18next';
type ResourceItemType = GetResourceListItemResponse & {
open: boolean;
children?: ResourceItemType[];
};
const rootId = 'root';
const SelectMultipleResource = ({
server,
selectedIds = [],
onSelect,
maxH = ['80vh', '600px'],
searchKey = ''
}: {
server: (e: GetResourceFolderListProps) => Promise<GetResourceListItemResponse[]>;
selectedIds?: string[];
onSelect: (id: string, appData: GetResourceListItemResponse) => any;
maxH?: BoxProps['maxH'];
searchKey?: string;
}) => {
const { t } = useTranslation();
const [dataList, setDataList] = useState<ResourceItemType[]>([]);
const [requestingIdList, setRequestingIdList] = useState<ParentIdType[]>([]);
const concatRoot = useMemo(() => {
const root: ResourceItemType = {
id: rootId,
open: true,
avatar: FolderImgUrl,
name: t('common:root_folder'),
isFolder: true,
children: dataList
};
return [root];
}, [dataList, t]);
const { runAsync: requestServer } = useRequest2((e: GetResourceFolderListProps) => {
if (requestingIdList.includes(e.parentId)) return Promise.reject(null);
setRequestingIdList((state) => [...state, e.parentId]);
return server(e).finally(() =>
setRequestingIdList((state) => state.filter((id) => id !== e.parentId))
);
}, {});
const { loading, refresh } = useRequest2(() => requestServer({ parentId: null }), {
manual: false,
onSuccess: (data) => {
setDataList(
data.map((item) => ({
...item,
open: false
}))
);
}
});
// 当搜索关键词变化时,重新加载数据
useEffect(() => {
refresh();
}, [searchKey, refresh]);
const Render = useMemoizedFn(
({ list, index = 0 }: { list: ResourceItemType[]; index?: number }) => {
return (
<>
{list.map((item) => (
<Box key={item.id} _notLast={{ mb: 0.5 }} userSelect={'none'}>
<Flex
alignItems={'center'}
cursor={'pointer'}
py={1}
pl={index === 0 ? '0.5rem' : `${1.75 * (index - 1) + 0.5}rem`}
pr={2}
borderRadius={'md'}
_hover={{
bg: 'myGray.100'
}}
onClick={async () => {
if (item.id === rootId) return;
// folder => open(request children) or close
if (item.isFolder) {
if (!item.children) {
const data = await requestServer({ parentId: item.id });
item.children = data.map((childItem) => ({
...childItem,
open: false
}));
}
item.open = !item.open;
setDataList([...dataList]);
}
}}
>
{/* Checkbox for non-folder items */}
{!item.isFolder && item.id !== rootId && (
<Checkbox
isChecked={selectedIds.includes(item.id)}
onChange={(e) => {
e.stopPropagation();
onSelect(item.id, item);
}}
mr={2}
size="sm"
/>
)}
{index !== 0 && (
<Flex
alignItems={'center'}
justifyContent={'center'}
visibility={item.isFolder ? 'visible' : 'hidden'}
w={'1.25rem'}
h={'1.25rem'}
cursor={'pointer'}
borderRadius={'xs'}
_hover={{
bg: 'rgba(31, 35, 41, 0.08)'
}}
>
<MyIcon
name={
requestingIdList.includes(item.id)
? 'common/loading'
: 'common/rightArrowFill'
}
w={'14px'}
color={'myGray.500'}
transform={item.open ? 'rotate(90deg)' : 'none'}
/>
</Flex>
)}
<Avatar
ml={index !== 0 ? '0.5rem' : 0}
src={item.avatar}
w={'1.25rem'}
borderRadius={'sm'}
/>
<Box
fontSize={['md', 'sm']}
ml={2}
className="textEllipsis"
color={selectedIds.includes(item.id) ? 'primary.600' : 'inherit'}
fontWeight={selectedIds.includes(item.id) ? 'medium' : 'normal'}
>
{item.name}
</Box>
</Flex>
{item.children && item.open && (
<Box mt={0.5}>
<Render list={item.children} index={index + 1} />
</Box>
)}
</Box>
))}
</>
);
}
);
return loading ? (
<Loading fixed={false} />
) : (
<Box maxH={maxH} h={'100%'} overflow={'auto'}>
<Render list={concatRoot} />
</Box>
);
};
export default SelectMultipleResource;

View File

@@ -0,0 +1,169 @@
import React, { useMemo, useState } from 'react';
import { Box, type BoxProps, Flex } from '@chakra-ui/react';
import {
type GetResourceFolderListProps,
type GetResourceListItemResponse,
type ParentIdType
} from '@fastgpt/global/common/parentFolder/type';
import MyIcon from '@fastgpt/web/components/common/Icon';
import Loading from '@fastgpt/web/components/common/MyLoading';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useMemoizedFn } from 'ahooks';
import { FolderImgUrl } from '@fastgpt/global/common/file/image/constants';
import { useTranslation } from 'next-i18next';
type ResourceItemType = GetResourceListItemResponse & {
open: boolean;
children?: ResourceItemType[];
};
const rootId = 'root';
const SelectOneResource = ({
server,
value,
onSelect,
maxH = ['80vh', '600px']
}: {
server: (e: GetResourceFolderListProps) => Promise<GetResourceListItemResponse[]>;
value?: ParentIdType;
onSelect: (e?: string) => any;
maxH?: BoxProps['maxH'];
}) => {
const { t } = useTranslation();
const [dataList, setDataList] = useState<ResourceItemType[]>([]);
const [requestingIdList, setRequestingIdList] = useState<ParentIdType[]>([]);
const concatRoot = useMemo(() => {
const root: ResourceItemType = {
id: rootId,
open: true,
avatar: FolderImgUrl,
name: t('common:root_folder'),
isFolder: true,
children: dataList
};
return [root];
}, [dataList, t]);
const { runAsync: requestServer } = useRequest2((e: GetResourceFolderListProps) => {
if (requestingIdList.includes(e.parentId)) return Promise.reject(null);
setRequestingIdList((state) => [...state, e.parentId]);
return server(e).finally(() =>
setRequestingIdList((state) => state.filter((id) => id !== e.parentId))
);
}, {});
const { loading } = useRequest2(() => requestServer({ parentId: null }), {
manual: false,
onSuccess: (data) => {
setDataList(
data.map((item) => ({
...item,
open: false
}))
);
}
});
const Render = useMemoizedFn(
({ list, index = 0 }: { list: ResourceItemType[]; index?: number }) => {
return (
<>
{list.map((item) => (
<Box key={item.id} _notLast={{ mb: 0.5 }} userSelect={'none'}>
<Flex
alignItems={'center'}
cursor={'pointer'}
py={1}
pl={index === 0 ? '0.5rem' : `${1.75 * (index - 1) + 0.5}rem`}
pr={2}
borderRadius={'md'}
_hover={{
bg: 'myGray.100'
}}
{...(item.id === value
? {
bg: 'primary.50 !important',
onClick: () => onSelect(undefined)
}
: {
onClick: async () => {
if (item.id === rootId) return;
// folder => open(request children) or close
if (item.isFolder) {
if (!item.children) {
const data = await requestServer({ parentId: item.id });
item.children = data.map((item) => ({
...item,
open: false
}));
}
item.open = !item.open;
setDataList([...dataList]);
} else {
onSelect(item.id);
}
}
})}
>
{index !== 0 && (
<Flex
alignItems={'center'}
justifyContent={'center'}
visibility={item.isFolder ? 'visible' : 'hidden'}
w={'1.25rem'}
h={'1.25rem'}
cursor={'pointer'}
borderRadius={'xs'}
_hover={{
bg: 'rgba(31, 35, 41, 0.08)'
}}
>
<MyIcon
name={
requestingIdList.includes(item.id)
? 'common/loading'
: 'common/rightArrowFill'
}
w={'14px'}
color={'myGray.500'}
transform={item.open ? 'rotate(90deg)' : 'none'}
/>
</Flex>
)}
<Avatar
ml={index !== 0 ? '0.5rem' : 0}
src={item.avatar}
w={'1.25rem'}
borderRadius={'sm'}
/>
<Box fontSize={['md', 'sm']} ml={2} className="textEllipsis">
{item.name}
</Box>
</Flex>
{item.children && item.open && (
<Box mt={0.5}>
<Render list={item.children} index={index + 1} />
</Box>
)}
</Box>
))}
</>
);
}
);
return loading ? (
<Loading fixed={false} />
) : (
<Box maxH={maxH} h={'100%'} overflow={'auto'}>
<Render list={concatRoot} />
</Box>
);
};
export default SelectOneResource;

View File

@@ -0,0 +1,196 @@
import React, { useState } from 'react';
import { Box, Flex, Text, IconButton, Input } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { CopyIcon } from '@chakra-ui/icons';
import type { GateSchemaType } from '@fastgpt/global/support/user/team/gate/type';
// 分享门户组件
const ShareGateModal = ({
isOpen,
onClose,
gateConfig
}: {
isOpen: boolean;
onClose: () => void;
gateConfig: GateSchemaType | undefined;
}) => {
const { copyData } = useCopyData();
// 门户链接和自定义域名
const [defaultGateUrl] = useState(`${window.location.origin}/chat/gate`);
// 复制链接
const handleCopyLink = (link: string) => {
copyData(link, '链接已复制');
};
// 保存配置
const handleSave = () => {
// 保存自定义域名的逻辑
onClose();
};
// 获取门户状态
const isGateEnabled = gateConfig?.status || false;
return (
<MyModal isOpen={isOpen} onClose={onClose} maxW="500px">
<Box
position="relative"
width="500px"
maxHeight="80vh"
gap={'20px'}
bg="#FFFFFF"
boxShadow="0px 32px 64px -12px rgba(19, 51, 107, 0.2), 0px 0px 1px rgba(19, 51, 107, 0.2)"
borderRadius="10px"
overflowY="auto"
>
{/* 弹窗头部 */}
<Flex
boxSizing="border-box"
w="500px"
h="48px"
bg="#FBFBFC"
borderBottom="1px solid #F4F4F7"
justifyContent="space-between"
alignItems="center"
px="20px"
borderTopLeftRadius="10px"
borderTopRightRadius="10px"
overflow="hidden"
>
<Flex alignItems="center" gap="10px">
<MyIcon name="support/gate/home/sharePrimary" color="#3370FF" />
<Text
fontFamily="PingFang SC"
fontWeight="500"
fontSize="16px"
lineHeight="24px"
letterSpacing="0.15px"
color="#24282C"
>
</Text>
</Flex>
</Flex>
{/* 弹窗内容 */}
<Flex
direction="column"
alignItems="flex-start"
padding="24px 36px"
gap="24px"
w="100%"
h="100%"
>
{/* 上部内容区 */}
<Flex direction="column" gap="20px" w="428px">
{/* 提示信息 */}
<Flex
bg="#F0F4FF"
borderRadius="6px"
p="6px 12px"
alignItems="center"
w="100%"
h="44px"
>
<Text
fontFamily="PingFang SC"
fontWeight="500"
fontSize="12px"
lineHeight="16px"
letterSpacing="0.5px"
color="#3370FF"
>
</Text>
</Flex>
{/* 门户状态 */}
<Flex alignItems="center" gap="12px">
<Text
fontFamily="PingFang SC"
fontWeight="500"
fontSize="14px"
lineHeight="20px"
letterSpacing="0.1px"
color="#111824"
>
:
</Text>
<Flex
bg={isGateEnabled ? '#EDFBF3' : '#FFF0F0'}
borderRadius="6px"
p="4px 8px"
alignItems="center"
gap="4px"
>
<Box
w="6px"
h="6px"
borderRadius="50%"
bg={isGateEnabled ? '#039855' : '#D92D20'}
></Box>
<Text
fontFamily="PingFang SC"
fontWeight="500"
fontSize="12px"
lineHeight="16px"
letterSpacing="0.5px"
color={isGateEnabled ? '#039855' : '#D92D20'}
>
{isGateEnabled ? '已启用' : '已禁用'}
</Text>
</Flex>
</Flex>
{/* 默认地址 */}
<Flex direction="column" alignItems="flex-start" gap="8px" w="100%">
<Text
fontFamily="PingFang SC"
fontWeight="500"
fontSize="14px"
lineHeight="20px"
letterSpacing="0.1px"
color="#24282C"
>
</Text>
<Flex w="100%" alignItems="center" gap="8px">
<Input
value={defaultGateUrl}
readOnly
h="32px"
bg="#FFFFFF"
border="1px solid #3370FF"
boxShadow="0px 0px 0px 2.4px rgba(51, 112, 255, 0.15)"
borderRadius="6px"
fontSize="12px"
color="#111824"
pl="12px"
flex="1"
/>
<IconButton
aria-label="复制链接"
icon={<CopyIcon />}
size="sm"
variant="ghost"
colorScheme="gray"
onClick={() => handleCopyLink(defaultGateUrl)}
h="32px"
w="32px"
minW="32px"
/>
</Flex>
</Flex>
</Flex>
</Flex>
</Box>
</MyModal>
);
};
export default ShareGateModal;

View File

@@ -0,0 +1,779 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Box,
Button,
Flex,
Input,
ModalBody,
ModalFooter,
useToast,
HStack,
IconButton,
Container,
Divider,
Text,
Checkbox
} from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import {
getTeamTags,
createTag,
updateTag,
deleteTag,
batchAddTagsToApp,
batchRemoveTagsFromApp,
batchAddAppsToTag
} from '@/web/core/app/api/tags';
import type { TagWithCountType } from '@fastgpt/global/core/app/tags';
import MyIcon from '@fastgpt/web/components/common/Icon';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import SelectMultipleResource from './SelectMultipleResource';
import {
type GetResourceFolderListProps,
type GetResourceListItemResponse
} from '@fastgpt/global/common/parentFolder/type';
import { getMyApps } from '@/web/core/app/api';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import type { AppListItemType } from '@fastgpt/global/core/app/type.d';
interface TagManageModalProps {
onClose: () => void;
onTagsUpdated?: () => void;
}
type ViewMode = 'tagList' | 'appSelection';
const TagManageModal = ({ onClose, onTagsUpdated }: TagManageModalProps) => {
const { t } = useTranslation();
const toast = useToast();
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [editingTag, setEditingTag] = useState<{
_id?: string;
name: string;
}>({ name: '' });
const [isEditing, setIsEditing] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('tagList');
const [selectedTagForAddApps, setSelectedTagForAddApps] = useState<TagWithCountType | null>(null);
const [searchKey, setSearchKey] = useState('');
const [selectedAppIds, setSelectedAppIds] = useState<string[]>([]);
const [allApps, setAllApps] = useState<AppListItemType[]>([]);
const [initialAppsWithTag, setInitialAppsWithTag] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
// 获取标签列表
const { data: tags = [], loading: loadingTags } = useRequest2(
async () => {
const result = await getTeamTags(true);
return result as TagWithCountType[];
},
{
manual: false,
refreshDeps: [refreshTrigger],
onSuccess: (data) => {
console.log('getTeamTags success', data);
}
}
);
// 创建标签
const { runAsync: createTagMutate, loading: createLoading } = useRequest2(
(data: { name: string }) => createTag(data),
{
onSuccess: () => {
toast({
title: '标签创建成功',
status: 'success',
duration: 3000,
isClosable: true
});
setRefreshTrigger((prev) => prev + 1);
setIsCreating(false);
setEditingTag({ name: '' });
onTagsUpdated?.();
}
}
);
// 更新标签
const { runAsync: updateTagMutate, loading: updateLoading } = useRequest2(
(data: { tagId: string; name: string }) => updateTag(data),
{
onSuccess: () => {
toast({
title: '标签更新成功',
status: 'success',
duration: 3000,
isClosable: true
});
setRefreshTrigger((prev) => prev + 1);
setIsEditing(false);
setEditingTag({ name: '' });
onTagsUpdated?.();
}
}
);
// 删除标签
const { runAsync: deleteTagMutate, loading: deleteLoading } = useRequest2(
(tagId: string) => deleteTag(tagId),
{
onSuccess: () => {
toast({
title: '标签删除成功',
status: 'success',
duration: 3000,
isClosable: true
});
setRefreshTrigger((prev) => prev + 1);
onTagsUpdated?.();
}
}
);
// 当创建或编辑模式激活时,聚焦输入框
useEffect(() => {
if ((isCreating || isEditing) && inputRef.current) {
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
}, [isCreating, isEditing]);
// 处理创建标签
const handleCreateTag = () => {
if (!editingTag.name.trim()) {
toast({
title: '标签名称不能为空',
status: 'error',
duration: 3000,
isClosable: true
});
return;
}
createTagMutate({
name: editingTag.name
});
};
// 处理更新标签
const handleUpdateTag = () => {
if (!editingTag.name.trim()) {
toast({
title: '标签名称不能为空',
status: 'error',
duration: 3000,
isClosable: true
});
return;
}
if (!editingTag._id) return;
updateTagMutate({
tagId: editingTag._id,
name: editingTag.name
});
};
// 处理删除标签
const handleDeleteTag = (tagId: string) => {
deleteTagMutate(tagId);
};
// 开始编辑标签
const startEditTag = (tag: TagWithCountType) => {
if (isEditing && editingTag._id === tag._id) {
cancelEdit();
return;
}
setEditingTag({
_id: tag._id,
name: tag.name
});
setIsEditing(true);
setIsCreating(false);
};
// 开始创建新标签
const startCreateTag = () => {
setEditingTag({ name: '' });
setIsCreating(true);
setIsEditing(false);
};
// 取消编辑或创建
const cancelEdit = () => {
setIsEditing(false);
setIsCreating(false);
setEditingTag({ name: '' });
};
// 获取应用列表的函数
const getAppList = useCallback(
async ({ parentId }: GetResourceFolderListProps) => {
const apps = await getMyApps({
parentId,
searchKey,
type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow, AppTypeEnum.plugin]
});
// 保存所有应用数据,用于后续判断哪些应用已经有当前标签
setAllApps(apps);
// 如果是第一次加载parentId 为 null且有选中的标签保存初始有标签的应用
if (parentId === null && selectedTagForAddApps && initialAppsWithTag.length === 0) {
const appsWithCurrentTag = apps
.filter((app) => app.tags?.includes(selectedTagForAddApps._id))
.map((app) => app._id);
setInitialAppsWithTag(appsWithCurrentTag);
}
return apps.map<GetResourceListItemResponse>((item) => ({
id: item._id,
name: item.name,
avatar: item.avatar,
isFolder: item.type === AppTypeEnum.folder
}));
},
[searchKey, selectedTagForAddApps, initialAppsWithTag.length]
);
// 处理应用选择
const handleAppSelect = useCallback(
(appId: string, appData: GetResourceListItemResponse) => {
if (!selectedTagForAddApps) return;
// 获取当前目录中有标签的应用
const currentAppsWithTag = allApps
.filter((app) => app.tags?.includes(selectedTagForAddApps._id))
.map((app) => app._id);
// 判断这个应用是否初始就被选中(包括初始有标签的 + 当前目录中有标签的)
const allInitialSelected = [...new Set([...initialAppsWithTag, ...currentAppsWithTag])];
const isInitiallySelected = allInitialSelected.includes(appId);
const isCurrentlyInSelectedIds = selectedAppIds.includes(appId);
setSelectedAppIds((prev) => {
if (isInitiallySelected) {
// 如果是初始就选中的应用
if (isCurrentlyInSelectedIds) {
// 当前在 selectedAppIds 中,移除它(表示取消选择)
return prev.filter((id) => id !== appId);
} else {
// 当前不在 selectedAppIds 中,添加它(表示取消选择)
return [...prev, appId];
}
} else {
// 如果是初始没有选中的应用
if (isCurrentlyInSelectedIds) {
// 当前已选中,取消选择
return prev.filter((id) => id !== appId);
} else {
// 当前未选中,添加选择
return [...prev, appId];
}
}
});
},
[selectedTagForAddApps, initialAppsWithTag, allApps, selectedAppIds]
);
// 获取当前选中的应用ID列表包括已有标签的应用
const getSelectedIds = useCallback(() => {
if (!selectedTagForAddApps) return [];
// 获取当前目录中有标签的应用
const currentAppsWithTag = allApps
.filter((app) => app.tags?.includes(selectedTagForAddApps._id))
.map((app) => app._id);
// 合并:初始有标签的应用 + 当前目录中有标签的应用 + 用户手动选中的应用
// 然后减去用户手动取消选择的应用
const allInitialSelected = [...new Set([...initialAppsWithTag, ...currentAppsWithTag])];
// 计算最终选中的应用:
// 1. 从所有初始选中的应用开始
// 2. 加上用户新选中的应用
// 3. 减去用户取消选择的应用
const finalSelected = new Set(allInitialSelected);
// 处理用户的选择变更
selectedAppIds.forEach((appId) => {
if (allInitialSelected.includes(appId)) {
// 如果这个应用初始是选中的,现在在 selectedAppIds 中表示用户取消了选择
finalSelected.delete(appId);
} else {
// 如果这个应用初始不是选中的,现在在 selectedAppIds 中表示用户新选择了它
finalSelected.add(appId);
}
});
return Array.from(finalSelected);
}, [selectedTagForAddApps, initialAppsWithTag, allApps, selectedAppIds]);
// 批量更新应用标签
const { runAsync: updateAppTags, loading: isUpdating } = useRequest2(
async () => {
if (!selectedTagForAddApps) return;
// 直接使用 getSelectedIds 获取最终应该拥有该标签的应用列表
const finalSelectedIds = getSelectedIds();
// 使用新的批量添加应用到标签的 API 进行全量更新
// 传入最终选中的所有应用 ID
await batchAddAppsToTag(selectedTagForAddApps._id, finalSelectedIds);
},
{
manual: true,
onSuccess: () => {
toast({
title: '标签应用更新成功',
status: 'success',
duration: 3000,
isClosable: true
});
setRefreshTrigger((prev) => prev + 1);
onTagsUpdated?.();
// 返回标签列表视图
setViewMode('tagList');
setSelectedTagForAddApps(null);
setSelectedAppIds([]);
setSearchKey('');
setInitialAppsWithTag([]); // 清理初始应用列表
},
onError: (error) => {
console.error('更新标签应用失败:', error);
toast({
title: '更新标签应用失败',
status: 'error',
duration: 3000,
isClosable: true
});
}
}
);
// 开始添加应用到标签
const startAddAppsToTag = (tag: TagWithCountType) => {
setSelectedTagForAddApps(tag);
setViewMode('appSelection');
setSelectedAppIds([]);
setSearchKey('');
setInitialAppsWithTag([]); // 重置初始应用列表,将在 getAppList 中重新设置
};
// 返回标签列表
const backToTagList = () => {
setViewMode('tagList');
setSelectedTagForAddApps(null);
setSelectedAppIds([]);
setSearchKey('');
setInitialAppsWithTag([]); // 清理初始应用列表
};
const isLoading = loadingTags || createLoading || updateLoading || deleteLoading;
return (
<MyModal
isOpen
onClose={onClose}
iconSrc="/imgs/modal/tag.svg"
title={viewMode === 'tagList' ? '分类管理' : `为标签"${selectedTagForAddApps?.name}"添加应用`}
w="580px"
maxW="100%"
isLoading={isLoading || isUpdating}
>
<ModalBody px={9} py={6}>
<Container maxW="100%" p={0}>
{viewMode === 'tagList' ? (
<>
{/* 标签列表视图 */}
{/* 头部区域 */}
<Flex direction="column" gap={4} w="100%" h="40px" mb={4}>
<Flex justifyContent="space-between" alignItems="center" w="100%" h="32px">
<Flex alignItems="center" gap={2}>
<MyIcon name="common/list" w="20px" h="20px" color="#111824" />
<Box
fontSize="16px"
fontWeight="500"
lineHeight="24px"
letterSpacing="0.15px"
color="#111824"
>
{tags.length}
</Box>
</Flex>
<Button
leftIcon={<MyIcon name="common/addLight" w="16px" h="16px" color="#485264" />}
onClick={startCreateTag}
size="sm"
variant="outline"
bg="white"
border="1px solid #DFE2EA"
boxShadow="0px 1px 2px rgba(19, 51, 107, 0.05), 0px 0px 1px rgba(19, 51, 107, 0.08)"
borderRadius="6px"
h="32px"
px="14px"
fontSize="12px"
fontWeight="500"
lineHeight="16px"
letterSpacing="0.5px"
color="#485264"
isDisabled={isCreating}
_hover={{
bg: 'gray.50'
}}
>
</Button>
</Flex>
<Divider borderColor="#E8EBF0" />
</Flex>
{/* 标签列表区域 */}
<Flex direction="column" gap={2} w="100%" maxH="304px" overflowY="auto">
{/* 创建新标签表单 */}
{isCreating && (
<Flex
alignItems="center"
p="4px 8px"
gap={2}
w="100%"
h="36px"
borderRadius="4px"
bg="transparent"
>
<Flex alignItems="center" gap={2} w="195px" h="28px">
<Box
position="relative"
w="168px"
h="28px"
bg="white"
border="1px solid #3370FF"
boxShadow="0px 0px 0px 2.4px rgba(51, 112, 255, 0.15)"
borderRadius="4px"
>
<Input
ref={inputRef}
value={editingTag.name}
onChange={(e) => setEditingTag({ ...editingTag, name: e.target.value })}
placeholder="新建分类"
maxLength={20}
bg="transparent"
border="none"
h="100%"
w="100%"
px="8px"
fontSize="12px"
fontWeight="400"
lineHeight="16px"
letterSpacing="0.004em"
color="#111824"
_focus={{ boxShadow: 'none' }}
_placeholder={{ color: '#667085' }}
/>
</Box>
<Box
fontSize="14px"
fontWeight="400"
lineHeight="20px"
letterSpacing="0.25px"
color="#667085"
>
(0)
</Box>
</Flex>
</Flex>
)}
{/* 标签列表 */}
{(tags as TagWithCountType[]).map((tag, index) => (
<React.Fragment key={tag._id}>
{isEditing && editingTag._id === tag._id ? (
// 编辑模式
<Flex
alignItems="center"
p="4px 8px"
gap={2}
w="100%"
h="36px"
borderRadius="4px"
bg="transparent"
>
<Flex alignItems="center" gap={2} w="195px" h="28px">
<Box
position="relative"
w="168px"
h="28px"
bg="white"
border="1px solid #3370FF"
boxShadow="0px 0px 0px 2.4px rgba(51, 112, 255, 0.15)"
borderRadius="4px"
>
<Input
ref={inputRef}
value={editingTag.name}
onChange={(e) =>
setEditingTag({ ...editingTag, name: e.target.value })
}
maxLength={20}
bg="transparent"
border="none"
h="100%"
w="100%"
px="8px"
fontSize="12px"
fontWeight="400"
lineHeight="16px"
letterSpacing="0.004em"
color="#111824"
_focus={{ boxShadow: 'none' }}
_placeholder={{ color: '#667085' }}
/>
</Box>
<Box
fontSize="14px"
fontWeight="400"
lineHeight="20px"
letterSpacing="0.25px"
color="#667085"
>
({tag.count || 0})
</Box>
</Flex>
</Flex>
) : (
// 普通显示模式
<Flex
alignItems="center"
p="4px 8px"
gap={2}
w="100%"
h="36px"
borderRadius="4px"
bg="transparent"
_hover={{ bg: '#F9F9F9' }}
>
<Flex alignItems="center" gap={2} flex={1}>
<Flex
justifyContent="center"
alignItems="center"
p="10px 8px"
h="28px"
bg="#F4F4F5"
borderRadius="6px"
minW="fit-content"
>
<Box
fontSize="12px"
fontWeight="500"
lineHeight="16px"
color="#525252"
whiteSpace="nowrap"
>
{tag.name}
</Box>
</Flex>
<Box fontSize="14px" color="#667085">
({tag.count || 0})
</Box>
</Flex>
<Flex alignItems="center" gap={2}>
<IconButton
aria-label="添加"
icon={
<MyIcon name="common/addLight" w="16px" h="16px" color="#485264" />
}
size="sm"
variant="ghost"
w="24px"
h="24px"
borderRadius="6px"
onClick={() => startAddAppsToTag(tag)}
isDisabled={isCreating}
/>
<IconButton
aria-label="编辑"
icon={<MyIcon name="edit" w="16px" h="16px" color="#485264" />}
size="sm"
variant="ghost"
w="24px"
h="24px"
borderRadius="6px"
onClick={() => startEditTag(tag)}
isDisabled={isCreating}
/>
<IconButton
aria-label="删除"
icon={<MyIcon name="delete" w="16px" h="16px" color="#485264" />}
size="sm"
variant="ghost"
w="24px"
h="24px"
borderRadius="6px"
onClick={() => handleDeleteTag(tag._id)}
isDisabled={isCreating}
/>
</Flex>
</Flex>
)}
{index < tags.length - 1 && <Divider borderColor="#E8EBF0" />}
</React.Fragment>
))}
{tags.length === 0 && !loadingTags && (
<Flex
justifyContent="center"
alignItems="center"
h="100px"
color="gray.500"
fontSize="14px"
>
</Flex>
)}
</Flex>
</>
) : (
<>
{/* 应用选择视图 */}
<Flex direction="column" h="500px" gap={4}>
{/* 头部区域 */}
<Flex direction="column" gap={4} w="100%" h="46px">
<Flex justifyContent="space-between" alignItems="center" w="100%" h="38px">
<Flex alignItems="center" gap={3}>
<IconButton
aria-label="返回"
icon={
<MyIcon name="common/leftArrowLight" w="18px" h="18px" color="#485264" />
}
size="sm"
variant="ghost"
w="32px"
h="32px"
borderRadius="6px"
onClick={backToTagList}
_hover={{
bg: 'rgba(31, 35, 41, 0.08)'
}}
/>
<Flex alignItems="center" gap={2}>
<Flex
justifyContent="center"
alignItems="center"
px="8px"
py="6px"
h="28px"
bg="#F4F4F5"
borderRadius="6px"
minW="fit-content"
>
<Box
fontSize="12px"
fontWeight="500"
lineHeight="16px"
color="#525252"
whiteSpace="nowrap"
>
{selectedTagForAddApps?.name}
</Box>
</Flex>
<Box
fontSize="14px"
fontWeight="400"
lineHeight="20px"
letterSpacing="0.25px"
color="#667085"
>
({selectedTagForAddApps?.count || 0})
</Box>
</Flex>
</Flex>
<Flex alignItems="center" gap={2}>
<Box w="200px" h="32px">
<SearchInput
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder="搜索"
bg="#F7F8FA"
border="1px solid #E8EBF0"
borderRadius="6px"
h="32px"
fontSize="12px"
/>
</Box>
<Button
leftIcon={<MyIcon name="save" w="16px" h="16px" color="#FFFFFF" />}
onClick={() => updateAppTags()}
size="sm"
bg="#3370FF"
color="white"
boxShadow="0px 1px 2px rgba(19, 51, 107, 0.05), 0px 0px 1px rgba(19, 51, 107, 0.08)"
borderRadius="6px"
h="32px"
px="14px"
fontSize="12px"
fontWeight="500"
lineHeight="16px"
letterSpacing="0.5px"
isLoading={isUpdating}
_hover={{
bg: '#2C5CE6'
}}
>
</Button>
</Flex>
</Flex>
<Divider borderColor="#E8EBF0" />
</Flex>
{/* 应用选择区域 */}
<Box flex={1} overflow="auto" w="100%" maxH="400px">
<SelectMultipleResource
selectedIds={getSelectedIds()}
onSelect={handleAppSelect}
server={getAppList}
searchKey={searchKey}
maxH="400px"
/>
</Box>
</Flex>
</>
)}
</Container>
</ModalBody>
<ModalFooter borderTopWidth="1px" py={4}>
<Flex gap={3}>
{(isCreating || isEditing) && viewMode === 'tagList' && (
<>
<Button variant="outline" size="sm" onClick={cancelEdit}>
</Button>
<Button
colorScheme="blue"
size="sm"
onClick={isCreating ? handleCreateTag : handleUpdateTag}
>
{isCreating ? '创建' : '保存'}
</Button>
</>
)}
{!isCreating && !isEditing && viewMode === 'tagList' && (
<Button onClick={onClose}></Button>
)}
{viewMode === 'appSelection' && <Button onClick={backToTagList}></Button>}
</Flex>
</ModalFooter>
</MyModal>
);
};
export default TagManageModal;

View File

@@ -0,0 +1,328 @@
import { Box, Button, Flex, Grid, useDisclosure, Text } from '@chakra-ui/react';
import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useTranslation } from 'next-i18next';
import { SmallAddIcon } from '@chakra-ui/icons';
import type { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { theme } from '@fastgpt/web/styles/theme';
import {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { keyframes } from '@emotion/react';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import { getWebLLMModel } from '@/web/common/system/utils';
import ToolSelectModal, {
childAppSystemKey
} from '@/pageComponents/app/detail/Gate/components/ToolSelectModal';
import ConfigToolModal from '@/pageComponents/app/detail/Gate/components/ConfigToolModal';
// 定义粉碎动画关键帧
const shatterKeyframes = keyframes`
0% {
opacity: 1;
transform: scale(1);
filter: blur(0);
}
50% {
opacity: 0.5;
transform: scale(0.7) rotate(5deg) translateY(10px);
filter: blur(2px);
}
100% {
opacity: 0;
transform: scale(0.2) rotate(-5deg) translateY(15px);
filter: blur(4px);
}
`;
// 定义淡入动画关键帧
const fadeInKeyframes = keyframes`
0% {
opacity: 0;
}
100% {
opacity: 1;
}
`;
// 样式常量
const spacing = {
xs: 2
};
const formStyles = {
fontWeight: 500,
fontSize: '14px',
lineHeight: '20px',
letterSpacing: '0.1px'
};
const ToolSelect = ({
appForm,
setAppForm
}: {
appForm: AppSimpleEditFormType;
setAppForm: (newAppForm: AppSimpleEditFormType) => void;
}) => {
const { t } = useTranslation();
const [configTool, setConfigTool] = useState<
AppSimpleEditFormType['selectedTools'][number] | null
>(null);
// 添加删除状态管理
const [deletingToolIds, setDeletingToolIds] = useState<Set<string>>(new Set());
const {
isOpen: isOpenToolsSelect,
onOpen: onOpenToolsSelect,
onClose: onCloseToolsSelect
} = useDisclosure();
const selectedModel = getWebLLMModel(appForm.aiSettings.model);
// 使用 useCallback 缓存删除函数
const handleDeleteTool = useCallback(
(toolId: string) => {
// 先设置删除标记,触发动画
setDeletingToolIds((prev) => new Set([...prev, toolId]));
// 设置延时,等待动画完成后再从数组中移除
setTimeout(() => {
const newAppForm = {
...appForm,
selectedTools: appForm.selectedTools.filter((tool) => tool.id !== toolId)
};
setAppForm(newAppForm);
// 清除删除标记
setDeletingToolIds((prev) => {
const newSet = new Set(prev);
newSet.delete(toolId);
return newSet;
});
}, 150); // 动画持续时间缩短到150ms
},
[appForm, setAppForm]
);
return (
<>
{/* 标题区域 */}
<Flex alignItems="center" justifyContent="space-between" width="100%">
<Flex alignItems="center" gap={spacing.xs}>
<Text
ml={2}
fontWeight={formStyles.fontWeight}
fontSize={formStyles.fontSize}
lineHeight={formStyles.lineHeight}
letterSpacing={formStyles.letterSpacing}
color="myGray.700"
>
{t('common:core.app.Tool call')}
</Text>
<QuestionTip ml={1} label={t('app:plugin_dispatch_tip')} />
</Flex>
{/* 已有工具时显示新增按钮 */}
{appForm.selectedTools.length > 0 && (
<Button
size="sm"
colorScheme="primary"
variant="outline"
leftIcon={<SmallAddIcon />}
onClick={onOpenToolsSelect}
_hover={{ bg: 'blue.50' }}
>
{t('common:Add')}
</Button>
)}
</Flex>
{/* 工具容器 */}
{appForm.selectedTools.length > 0 ? (
<Box mt={2}>
<Grid gridTemplateColumns={'repeat(3, minmax(0, 1fr))'} gridGap={[2, 4]}>
{appForm.selectedTools.map((item) => {
const isDeleting = deletingToolIds.has(item.id);
return (
<MyTooltip key={item.id} label={item.intro}>
<Flex
overflow={'hidden'}
display={'flex'}
height={'40px'}
padding={'8px 12px'}
flexDirection={'row'}
justifyContent={'flex-start'}
alignItems={'center'}
flex={'1 0 0'}
borderRadius={'6px'}
border={'0.5px solid var(--Gray-Modern-200, #E8EBF0)'}
background={'#FFF'}
boxShadow={
'0px 4px 4px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
_hover={{
transform: 'translateY(-2px)',
borderRadius: '6px',
border: '0.5px solid var(--Gray-Modern-200, #E8EBF0)',
background: '#FFF',
boxShadow:
'0px 4px 4px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}}
cursor={'pointer'}
transition="all 0.2s ease"
position="relative"
role="group"
animation={isDeleting ? `${shatterKeyframes} 0.15s ease forwards` : undefined}
onClick={() => {
if (
item.inputs
.filter((input) => !childAppSystemKey.includes(input.key))
.every(
(input) =>
input.toolDescription ||
input.renderTypeList.includes(FlowNodeInputTypeEnum.selectLLMModel) ||
input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect)
) ||
item.flowNodeType === FlowNodeTypeEnum.tool ||
item.flowNodeType === FlowNodeTypeEnum.toolSet
) {
return;
}
setConfigTool(item);
}}
>
<Flex alignItems="center" width="100%">
<Avatar src={item.avatar} borderRadius={'6px'} w={'20px'} h={'20px'} />
<Box
ml={'6px'}
className={'textEllipsis'}
fontSize={'sm'}
fontWeight="medium"
color={'myGray.900'}
flex="1"
>
{item.name}
</Box>
<Flex
className="delete"
alignItems="center"
justifyContent="center"
ml={2}
w="22px"
h="22px"
borderRadius="sm"
cursor="pointer"
transition="all 0.2s"
_hover={{
background: 'rgba(17, 24, 36, 0.05)',
color: 'red.600'
}}
onClick={(e) => {
e.stopPropagation();
handleDeleteTool(item.id);
}}
opacity="0"
_groupHover={{
opacity: 1,
animation: `${fadeInKeyframes} 0.2s ease`
}}
>
<MyIcon
className="delete"
name={'delete' as any}
w={'16px'}
h={'16px'}
color={'inherit'}
/>
</Flex>
</Flex>
</Flex>
</MyTooltip>
);
})}
</Grid>
</Box>
) : (
<Box
mt={2}
display="flex"
width="100%"
height="80px"
justifyContent="center"
alignItems="center"
borderRadius="4px"
border="1px dashed var(--Gray-Modern-250, #DFE2EA)"
cursor="pointer"
onClick={onOpenToolsSelect}
_hover={{
borderColor: 'primary.300',
bg: 'gray.100',
'.hoverContent': { color: 'primary.500' }
}}
transition="all 0.2s"
position="relative"
>
<Flex
className="hoverContent"
alignItems="center"
justifyContent="center"
flexDirection="row"
gap={'6px'}
color="gray.500"
>
<SmallAddIcon boxSize={5} />
<Box fontSize="sm" fontWeight="medium">
{t('common:Choose')}
</Box>
</Flex>
</Box>
)}
{isOpenToolsSelect && (
<ToolSelectModal
selectedTools={appForm.selectedTools}
chatConfig={appForm.chatConfig}
selectedModel={selectedModel}
onAddTool={(e) => {
const newAppForm = {
...appForm,
selectedTools: [...appForm.selectedTools, e]
};
setAppForm(newAppForm);
}}
onRemoveTool={(e) => {
const newAppForm = {
...appForm,
selectedTools: appForm.selectedTools.filter((item) => item.pluginId !== e.id)
};
setAppForm(newAppForm);
}}
onClose={onCloseToolsSelect}
/>
)}
{configTool && (
<ConfigToolModal
configTool={configTool}
onCloseConfigTool={() => setConfigTool(null)}
onAddTool={(e) => {
const newAppForm = {
...appForm,
selectedTools: appForm.selectedTools.map((item) =>
item.pluginId === configTool.pluginId ? e : item
)
};
setAppForm(newAppForm);
}}
/>
)}
</>
);
};
export default React.memo(ToolSelect);

View File

@@ -0,0 +1,535 @@
import React, { useCallback, useMemo, useState, useEffect } from 'react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useTranslation } from 'next-i18next';
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Button,
css,
Flex,
Grid
} from '@chakra-ui/react';
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import type {
NodeTemplateListItemType,
NodeTemplateListType
} from '@fastgpt/global/core/workflow/type/node.d';
import MyIcon from '@fastgpt/web/components/common/Icon';
import {
getPluginGroups,
getPreviewPluginNode,
getSystemPlugTemplates,
getSystemPluginPaths
} from '@/web/core/app/api/plugin';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { getTeamPlugTemplates } from '@/web/core/app/api/plugin';
import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type';
import { getAppFolderPath } from '@/web/core/app/api/app';
import FolderPath from '@/components/common/folder/Path';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import CostTooltip from '@/components/core/app/plugin/CostTooltip';
import { useContextSelector } from 'use-context-selector';
import { AppContext } from '@/pageComponents/app/detail/context';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import { useMemoizedFn } from 'ahooks';
import MyAvatar from '@fastgpt/web/components/common/Avatar';
import type { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
type Props = {
selectedPluginIds: string[];
onSelectPlugins: (plugins: NodeTemplateListItemType[]) => void;
onCancel: () => void;
};
enum TemplateTypeEnum {
'systemPlugin' = 'systemPlugin',
'teamPlugin' = 'teamPlugin'
}
const ToolSelectModal = ({ selectedPluginIds, onSelectPlugins, onCancel }: Props) => {
const { t } = useTranslation();
const { appDetail } = useContextSelector(AppContext, (v) => v);
const [tempSelectedIds, setTempSelectedIds] = useState<string[]>([...selectedPluginIds]);
const [templateType, setTemplateType] = useState(TemplateTypeEnum.systemPlugin);
const [parentId, setParentId] = useState<ParentIdType>('');
const [searchKey, setSearchKey] = useState('');
// 监听 ESC 键关闭弹窗
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel();
}
};
// 添加事件监听
document.addEventListener('keydown', handleKeyDown);
// 组件卸载时清除事件监听
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [onCancel]);
const {
data: templates = [],
runAsync: loadTemplates,
loading: isLoading
} = useRequest2(
async ({
type = templateType,
parentId = '',
searchVal = searchKey
}: {
type?: TemplateTypeEnum;
parentId?: ParentIdType;
searchVal?: string;
}) => {
if (type === TemplateTypeEnum.systemPlugin) {
return getSystemPlugTemplates({ parentId, searchKey: searchVal });
} else if (type === TemplateTypeEnum.teamPlugin) {
return getTeamPlugTemplates({
parentId,
searchKey: searchVal
}).then((res) => res.filter((app) => app.id !== appDetail._id));
}
},
{
onSuccess(_, [{ type = templateType, parentId = '' }]) {
setTemplateType(type);
setParentId(parentId);
},
refreshDeps: [templateType, searchKey, parentId],
errorToast: t('common:core.module.templates.Load plugin error')
}
);
const { data: paths = [] } = useRequest2(
() => {
if (templateType === TemplateTypeEnum.teamPlugin)
return getAppFolderPath({ sourceId: parentId, type: 'current' });
return getSystemPluginPaths({ sourceId: parentId, type: 'current' });
},
{
manual: false,
refreshDeps: [parentId]
}
);
const onUpdateParentId = useCallback(
(parentId: ParentIdType) => {
loadTemplates({
parentId
});
},
[loadTemplates]
);
useRequest2(() => loadTemplates({ searchVal: searchKey }), {
manual: false,
throttleWait: 300,
refreshDeps: [searchKey]
});
// 处理确认选择,获取完整的插件信息
const handleConfirm = async () => {
console.log('即将保存的插件选择:', tempSelectedIds);
try {
// 从当前已加载的模板中直接获取信息,避免额外的 API 调用
const selectedPlugins = templates
.filter((template) => tempSelectedIds.includes(template.id))
.map((template) => ({
id: template.id,
name: template.name,
avatar: template.avatar,
intro: template.intro || '',
isFolder: template.isFolder || false,
flowNodeType: template.flowNodeType,
templateType: template.templateType
}));
// 对于不在当前模板中的插件(可能来自其他路径或之前选择),需要获取信息
const missingIds = tempSelectedIds.filter(
(id) => !selectedPlugins.some((plugin) => plugin.id === id)
);
if (missingIds.length > 0) {
// 批量获取缺失的插件信息,而不是一个个调用 API
const promises = missingIds.map((pluginId) =>
getPreviewPluginNode({ appId: pluginId })
.then((template) => ({
id: pluginId,
name: template.name,
avatar: template.avatar,
intro: template.intro || '',
isFolder: false,
flowNodeType: template.flowNodeType,
templateType: template.templateType
}))
.catch((error) => {
console.error('获取插件信息失败:', pluginId, error);
return null;
})
);
// 明确指定类型,排除 null 值
const additionalPlugins = (await Promise.all(promises)).filter(
(
item
): item is {
id: string;
name: string;
avatar: string | undefined;
intro: string;
isFolder: boolean;
flowNodeType: FlowNodeTypeEnum;
templateType: string;
} => Boolean(item)
);
selectedPlugins.push(...additionalPlugins);
}
console.log('处理后的插件列表:', selectedPlugins);
onSelectPlugins(selectedPlugins);
} catch (error) {
console.error('处理插件选择时出错:', error);
} finally {
onCancel();
}
};
return (
<MyModal
isOpen
title={t('common:core.app.Tool call')}
iconSrc="core/app/toolCall"
onClose={onCancel}
maxW={['90vw', '700px']}
w={'700px'}
h={['90vh', '80vh']}
>
{/* Header: row and search */}
<Box px={[3, 6]} pt={4} display={'flex'} justifyContent={'space-between'} w={'full'}>
<FillRowTabs
list={[
{
icon: 'phoneTabbar/tool',
label: t('common:navbar.Toolkit'),
value: TemplateTypeEnum.systemPlugin
},
{
icon: 'core/modules/teamPlugin',
label: t('common:core.module.template.Team app'),
value: TemplateTypeEnum.teamPlugin
}
]}
py={'5px'}
px={'15px'}
value={templateType}
onChange={(e) =>
loadTemplates({
type: e as TemplateTypeEnum,
parentId: null
})
}
/>
<Box w={300}>
<SearchInput
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder={
templateType === TemplateTypeEnum.systemPlugin
? t('common:plugin.Search plugin')
: t('app:search_app')
}
/>
</Box>
</Box>
{/* route components */}
{!searchKey && parentId && (
<Flex mt={2} px={[3, 6]}>
<FolderPath paths={paths} FirstPathDom={null} onClick={onUpdateParentId} />
</Flex>
)}
<MyBox isLoading={isLoading} mt={2} px={[3, 6]} pb={3} flex={'1 0 0'} overflowY={'auto'}>
<RenderList
templates={templates}
type={templateType}
setParentId={onUpdateParentId}
selectedIds={tempSelectedIds}
toggleSelection={(id) => {
setTempSelectedIds((prev) => {
return prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id];
});
}}
/>
</MyBox>
{/* Footer buttons - 改用内部内容替代 footer 属性 */}
<Flex
px={[3, 6]}
py={4}
justify="flex-end"
w="full"
gap={3}
borderTop="1px solid"
borderColor="gray.100"
>
<Button variant="outline" onClick={onCancel}>
{t('common:Cancel')}
</Button>
<Button colorScheme="blue" onClick={handleConfirm}>
{t('common:Confirm')}
</Button>
</Flex>
</MyModal>
);
};
export default React.memo(ToolSelectModal);
const RenderList = React.memo(function RenderList({
templates,
type,
setParentId,
selectedIds,
toggleSelection
}: {
templates: NodeTemplateListItemType[];
type: TemplateTypeEnum;
setParentId: (parentId: ParentIdType) => any;
selectedIds: string[];
toggleSelection: (id: string) => void;
}) {
const { t } = useTranslation();
const { data: pluginGroups = [] } = useRequest2(getPluginGroups, {
manual: false
});
const formatTemplatesArray = useMemo(() => {
const data = (() => {
if (type === TemplateTypeEnum.systemPlugin) {
return pluginGroups.map((group) => {
const copy: NodeTemplateListType = group.groupTypes.map((type) => ({
list: [],
type: type.typeId,
label: type.typeName
}));
templates.forEach((item) => {
const index = copy.findIndex((template) => template.type === item.templateType);
if (index === -1) return;
copy[index].list.push(item);
});
return {
label: group.groupName,
list: copy.filter((item) => item.list.length > 0)
};
});
}
return [
{
list: [
{
list: templates,
type: '',
label: ''
}
],
label: ''
}
];
})();
return data.filter(({ list }) => list.length > 0);
}, [pluginGroups, templates, type]);
const gridStyle = useMemo(() => {
if (type === TemplateTypeEnum.teamPlugin) {
return {
gridTemplateColumns: ['1fr', '1fr'],
py: 2,
avatarSize: '2rem'
};
}
return {
gridTemplateColumns: ['1fr', '1fr 1fr'],
py: 3,
avatarSize: '1.75rem'
};
}, [type]);
const PluginListRender = useMemoizedFn(({ list = [] }: { list: NodeTemplateListType }) => {
return (
<>
{list.map((item, i) => {
return (
<Box
key={item.type}
css={css({
span: {
display: 'block'
}
})}
>
<Flex>
<Box fontSize={'sm'} my={2} fontWeight={'500'} flex={1} color={'myGray.900'}>
{t(item.label as any)}
</Box>
</Flex>
<Grid gridTemplateColumns={gridStyle.gridTemplateColumns} rowGap={2} columnGap={3}>
{item.list.map((template) => {
const selected = selectedIds.includes(template.id);
// 判断是否是嵌套插件
const isNestedPlugin = template.isFolder;
return (
<MyTooltip
key={template.id}
placement={'right'}
label={
<Box py={2}>
<Flex alignItems={'center'}>
<MyAvatar
src={template.avatar}
w={'1.75rem'}
objectFit={'contain'}
borderRadius={'sm'}
/>
<Box fontWeight={'bold'} ml={3} color={'myGray.900'}>
{t(template.name as any)}
</Box>
</Flex>
<Box mt={2} color={'myGray.500'} maxH={'100px'} overflow={'hidden'}>
{t(template.intro as any) || t('common:core.workflow.Not intro')}
</Box>
{type === TemplateTypeEnum.systemPlugin && (
<CostTooltip
cost={template.currentCost}
hasTokenFee={template.hasTokenFee}
/>
)}
</Box>
}
>
<Flex
alignItems={'center'}
py={gridStyle.py}
px={3}
_hover={{ bg: 'myWhite.600' }}
borderRadius={'sm'}
whiteSpace={'nowrap'}
overflow={'hidden'}
textOverflow={'ellipsis'}
>
<MyAvatar
src={template.avatar}
w={gridStyle.avatarSize}
objectFit={'contain'}
borderRadius={'sm'}
flexShrink={0}
/>
<Box
color={'myGray.900'}
fontWeight={'500'}
fontSize={'sm'}
flex={'1 0 0'}
ml={3}
className="textEllipsis"
>
{t(template.name as any)}
</Box>
{selected ? (
<Button
size={'sm'}
variant={'grayDanger'}
leftIcon={<MyIcon name={'delete'} w={'16px'} mr={-1} />}
onClick={() => toggleSelection(template.id)}
px={2}
fontSize={'mini'}
>
{t('common:Remove')}
</Button>
) : isNestedPlugin ? (
<Button
size={'sm'}
variant={'whiteBase'}
leftIcon={<MyIcon name={'common/arrowRight'} w={'16px'} mr={-1.5} />}
onClick={() => setParentId(template.id)}
px={2}
fontSize={'mini'}
>
{t('common:Open')}
</Button>
) : (
<Button
size={'sm'}
variant={'primaryOutline'}
leftIcon={<MyIcon name={'common/addLight'} w={'16px'} mr={-1.5} />}
onClick={() => toggleSelection(template.id)}
px={2}
fontSize={'mini'}
>
{t('common:Add')}
</Button>
)}
</Flex>
</MyTooltip>
);
})}
</Grid>
</Box>
);
})}
</>
);
});
return templates.length === 0 ? (
<EmptyTip text={t('app:module.No Modules')} />
) : (
<Box flex={'1 0 0'} overflow={'overlay'}>
<Accordion defaultIndex={[0]} allowMultiple reduceMotion>
{formatTemplatesArray.length > 1 ? (
<>
{formatTemplatesArray.map(({ list, label }, index) => (
<AccordionItem key={index} border={'none'}>
<AccordionButton
fontSize={'sm'}
fontWeight={'500'}
color={'myGray.900'}
justifyContent={'space-between'}
alignItems={'center'}
borderRadius={'md'}
px={3}
>
{t(label as any)}
<AccordionIcon />
</AccordionButton>
<AccordionPanel py={0}>
<PluginListRender list={list} />
</AccordionPanel>
</AccordionItem>
))}
</>
) : (
<PluginListRender list={formatTemplatesArray?.[0]?.list} />
)}
</Accordion>
</Box>
);
});

View File

@@ -0,0 +1,295 @@
import React, { useMemo, useState } from 'react';
import {
Flex,
Box,
TableContainer,
Table,
Thead,
Tr,
Th,
Td,
Tbody,
HStack,
Button
} from '@chakra-ui/react';
import UserBox from '@fastgpt/web/components/common/UserBox';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useTranslation } from 'next-i18next';
import { getAppChatLogs } from '@/web/core/app/api';
import dayjs from 'dayjs';
import { ChatSourceEnum, ChatSourceMap } from '@fastgpt/global/core/chat/constants';
import { addDays } from 'date-fns';
import { usePagination } from '@fastgpt/web/hooks/usePagination';
import DateRangePicker, {
type DateRangeType
} from '@fastgpt/web/components/common/DateRangePicker';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import { cardStyles } from '@/pageComponents/app/detail/constants';
import dynamic from 'next/dynamic';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import MultipleSelect, {
useMultipleSelect
} from '@fastgpt/web/components/common/MySelect/MultipleSelect';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import PopoverConfirm from '@fastgpt/web/components/common/MyPopover/PopoverConfirm';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { downloadFetch } from '@/web/common/system/utils';
const DetailLogsModal = dynamic(() => import('@/pageComponents/app/detail/Logs/DetailLogsModal'));
// 修改将组件改为接收gateAppId作为属性
type LogsProps = {
gateAppId: string;
};
const Logs = ({ gateAppId }: LogsProps) => {
const { t } = useTranslation();
const [dateRange, setDateRange] = useState<DateRangeType>({
from: addDays(new Date(), -7),
to: new Date()
});
console.log('gateAppId', gateAppId);
const [detailLogsId, setDetailLogsId] = useState<string>();
const [logTitle, setLogTitle] = useState<string>();
// 不再需要获取gateAppId的useEffect
const {
value: chatSources,
setValue: setChatSources,
isSelectAll: isSelectAllSource,
setIsSelectAll: setIsSelectAllSource
} = useMultipleSelect<ChatSourceEnum>(Object.values(ChatSourceEnum), true);
const sourceList = useMemo(
() =>
Object.entries(ChatSourceMap).map(([key, value]) => ({
label: t(value.name as any),
value: key as ChatSourceEnum
})),
[t]
);
const {
data: logs,
isLoading,
Pagination,
getData,
pageNum,
total
} = usePagination(getAppChatLogs, {
pageSize: 20,
params: {
appId: gateAppId, // 现在gateAppId始终是字符串类型
dateStart: dateRange.from || new Date(),
dateEnd: addDays(dateRange.to || new Date(), 1),
sources: isSelectAllSource ? undefined : chatSources,
logTitle
},
refreshDeps: [gateAppId, chatSources, logTitle]
});
const { runAsync: exportLogs } = useRequest2(
async () => {
if (!gateAppId) return; // 即使gateAppId是空字符串此检查仍然有效
await downloadFetch({
url: '/api/core/app/exportChatLogs',
filename: 'chat_logs.csv',
body: {
// 修复使用gateAppId替代未定义的appId
appId: gateAppId,
dateStart: dateRange.from || new Date(),
dateEnd: addDays(dateRange.to || new Date(), 1),
sources: isSelectAllSource ? undefined : chatSources,
logTitle,
title: t('app:logs_export_title'),
sourcesMap: Object.fromEntries(
Object.entries(ChatSourceMap).map(([key, config]) => [
key,
{
label: t(config.name as any)
}
])
)
}
});
},
{
refreshDeps: [gateAppId, chatSources, logTitle]
}
);
return (
<Flex flexDirection={'column'} h={'100%'} flex={'1 0 0'}>
<Flex flexDir={['column', 'row']} alignItems={['flex-start', 'center']} gap={3}>
<Flex alignItems={'center'} gap={2}>
<Box fontSize={'mini'} fontWeight={'medium'} color={'myGray.900'}>
{t('app:logs_source')}
</Box>
<Box>
<MultipleSelect<ChatSourceEnum>
list={sourceList}
value={chatSources}
onSelect={setChatSources}
isSelectAll={isSelectAllSource}
setIsSelectAll={setIsSelectAllSource}
itemWrap={false}
height={'32px'}
bg={'myGray.50'}
w={'160px'}
/>
</Box>
</Flex>
<Flex alignItems={'center'} gap={2}>
<Box fontSize={'mini'} fontWeight={'medium'} color={'myGray.900'}>
{t('common:user.Time')}
</Box>
<DateRangePicker
defaultDate={dateRange}
position="bottom"
onChange={setDateRange}
onSuccess={() => getData(1)}
/>
</Flex>
<Flex alignItems={'center'} gap={2}>
<Box fontSize={'mini'} fontWeight={'medium'} color={'myGray.900'} whiteSpace={'nowrap'}>
{t('app:logs_title')}
</Box>
<SearchInput
placeholder={t('app:logs_title')}
w={'240px'}
value={logTitle}
onChange={(e) => setLogTitle(e.target.value)}
/>
</Flex>
<Box flex={'1'} />
<PopoverConfirm
Trigger={<Button size={'md'}>{t('common:Export')}</Button>}
showCancel
content={t('app:logs_export_confirm_tip', { total })}
onConfirm={exportLogs}
/>
</Flex>
<TableContainer mt={[2, 4]} flex={'1 0 0'} h={0} overflowY={'auto'}>
<Table variant={'simple'} fontSize={'sm'}>
<Thead>
<Tr>
<Th>{t('common:core.app.logs.Source And Time')}</Th>
<Th>{t('app:logs_chat_user')}</Th>
<Th>{t('app:logs_title')}</Th>
<Th>{t('app:logs_message_total')}</Th>
<Th>{t('app:feedback_count')}</Th>
<Th>{t('common:core.app.feedback.Custom feedback')}</Th>
<Th>
<Flex gap={1} alignItems={'center'}>
{t('app:mark_count')}
<QuestionTip label={t('common:core.chat.Mark Description')} />
</Flex>
</Th>
</Tr>
</Thead>
<Tbody fontSize={'xs'}>
{logs.map((item) => (
<Tr
key={item._id}
_hover={{ bg: 'myWhite.600' }}
cursor={'pointer'}
title={t('common:core.view_chat_detail')}
onClick={() => setDetailLogsId(item.id)}
>
<Td>
{/* @ts-ignore */}
<Box>{item.sourceName || t(ChatSourceMap[item.source]?.name) || item.source}</Box>
<Box color={'myGray.500'}>{dayjs(item.time).format('YYYY/MM/DD HH:mm')}</Box>
</Td>
<Td>
<Box>
{!!item.outLinkUid ? (
item.outLinkUid
) : (
<UserBox sourceMember={item.sourceMember} />
)}
</Box>
</Td>
<Td className="textEllipsis" maxW={'250px'}>
{item.customTitle || item.title}
</Td>
<Td>{item.messageCount}</Td>
<Td w={'100px'}>
{!!item?.userGoodFeedbackCount && (
<Flex
mb={item?.userGoodFeedbackCount ? 1 : 0}
bg={'green.100'}
color={'green.600'}
px={3}
py={1}
alignItems={'center'}
justifyContent={'center'}
borderRadius={'md'}
fontWeight={'bold'}
>
<MyIcon
mr={1}
name={'core/chat/feedback/goodLight'}
color={'green.600'}
w={'14px'}
/>
{item.userGoodFeedbackCount}
</Flex>
)}
{!!item?.userBadFeedbackCount && (
<Flex
bg={'#FFF2EC'}
color={'#C96330'}
px={3}
py={1}
alignItems={'center'}
justifyContent={'center'}
borderRadius={'md'}
fontWeight={'bold'}
>
<MyIcon
mr={1}
name={'core/chat/feedback/badLight'}
color={'#C96330'}
w={'14px'}
/>
{item.userBadFeedbackCount}
</Flex>
)}
{!item?.userGoodFeedbackCount && !item?.userBadFeedbackCount && <>-</>}
</Td>
<Td>{item.customFeedbacksCount || '-'}</Td>
<Td>{item.markCount}</Td>
</Tr>
))}
</Tbody>
</Table>
{logs.length === 0 && !isLoading && <EmptyTip text={t('app:logs_empty')}></EmptyTip>}
</TableContainer>
<HStack w={'100%'} mt={3} justifyContent={'center'}>
<Pagination />
</HStack>
{!!detailLogsId && (
<DetailLogsModal
appId={gateAppId} // 现在已经是字符串类型
chatId={detailLogsId}
onClose={() => {
setDetailLogsId(undefined);
getData(pageNum);
}}
/>
)}
</Flex>
);
};
export default React.memo(Logs);

View File

@@ -19,5 +19,11 @@ export const appTypeMap = {
avatar: 'core/app/type/pluginFill',
title: i18nT('app:type.Create plugin bot'),
emptyCreateText: i18nT('app:create_empty_plugin')
},
[AppTypeEnum.gate]: {
icon: 'support/gate/gateLight',
avatar: 'support/gate/gateLight',
title: i18nT('app:type.Create gate'),
emptyCreateText: i18nT('app:create_empty_gate')
}
};

View File

@@ -0,0 +1,213 @@
import React, { useState } from 'react';
import {
Box,
Flex,
Button,
IconButton,
HStack,
ModalBody,
Checkbox,
ModalFooter
} from '@chakra-ui/react';
import { useRouter } from 'next/router';
import type { AppSchema, AppSimpleEditFormType } from '@fastgpt/global/core/app/type.d';
import { useTranslation } from 'next-i18next';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyIcon from '@fastgpt/web/components/common/Icon';
import TagsEditModal from '../TagsEditModal';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { AppContext } from '@/pageComponents/app/detail/context';
import { useContextSelector } from 'use-context-selector';
import MyMenu from '@fastgpt/web/components/common/MyMenu';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { postTransition2Workflow } from '@/web/core/app/api/app';
import { form2AppWorkflow } from '@/web/core/app/utils';
import type { SimpleAppSnapshotType } from './useSnapshots';
import ExportConfigPopover from '@/pageComponents/app/detail/ExportConfigPopover';
const AppCard = ({
appForm,
setPast
}: {
appForm: AppSimpleEditFormType;
setPast: (value: React.SetStateAction<SimpleAppSnapshotType[]>) => void;
}) => {
const router = useRouter();
const { t } = useTranslation();
const onSaveApp = useContextSelector(AppContext, (v) => v.onSaveApp);
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
const onOpenInfoEdit = useContextSelector(AppContext, (v) => v.onOpenInfoEdit);
const onDelApp = useContextSelector(AppContext, (v) => v.onDelApp);
const appId = appDetail._id;
const { feConfigs } = useSystemStore();
const [TeamTagsSet, setTeamTagsSet] = useState<AppSchema>();
// transition to workflow
const [transitionCreateNew, setTransitionCreateNew] = useState<boolean>();
const { runAsync: onTransition, loading: transiting } = useRequest2(
async () => {
const { nodes, edges } = form2AppWorkflow(appForm, t);
await onSaveApp({
nodes,
edges,
chatConfig: appForm.chatConfig,
isPublish: false,
versionName: t('app:transition_to_workflow')
});
return postTransition2Workflow({ appId, createNew: transitionCreateNew });
},
{
onSuccess: ({ id }) => {
if (id) {
router.replace({
query: {
appId: id
}
});
} else {
setPast([]);
router.reload();
}
},
successToast: t('common:Success')
}
);
return (
<>
{/* basic info */}
<Box px={[4, 6]} py={4} position={'relative'}>
<Flex alignItems={'center'}>
<Avatar src={appDetail.avatar} borderRadius={'md'} w={'28px'} />
<Box ml={3} fontWeight={'bold'} fontSize={'md'} flex={'1 0 0'} color={'myGray.900'}>
{appDetail.name}
</Box>
</Flex>
<Box
flex={1}
mt={3}
mb={4}
className={'textEllipsis3'}
wordBreak={'break-all'}
color={'myGray.600'}
fontSize={'xs'}
minH={'46px'}
>
{appDetail.intro || t('common:core.app.tip.Add a intro to app')}
</Box>
<HStack alignItems={'center'}>
<Button
size={['sm', 'md']}
variant={'whitePrimary'}
leftIcon={<MyIcon name={'core/chat/chatLight'} w={'16px'} />}
onClick={() => router.push(`/chat?appId=${appId}`)}
>
{t('common:core.Chat')}
</Button>
{appDetail.permission.hasManagePer && (
<Button
size={['sm', 'md']}
variant={'whitePrimary'}
leftIcon={<MyIcon name={'common/settingLight'} w={'16px'} />}
onClick={onOpenInfoEdit}
>
{t('common:Setting')}
</Button>
)}
{appDetail.permission.isOwner && (
<MyMenu
size={'xs'}
Button={
<IconButton
variant={'whitePrimary'}
size={['smSquare', 'mdSquare']}
icon={<MyIcon name={'more'} w={'1rem'} />}
aria-label={''}
/>
}
menuList={[
{
children: [
{
label: (
<Flex>
<ExportConfigPopover
appName={appDetail.name}
appForm={appForm}
chatConfig={appDetail.chatConfig}
/>
</Flex>
)
},
{
icon: 'core/app/type/workflow',
label: t('app:transition_to_workflow'),
onClick: () => setTransitionCreateNew(true)
},
...(appDetail.permission.hasWritePer && feConfigs?.show_team_chat
? [
{
icon: 'core/chat/fileSelect',
label: t('common:team_tags_set'),
onClick: () => setTeamTagsSet(appDetail)
}
]
: [])
]
},
{
children: [
{
icon: 'delete',
type: 'danger',
label: t('common:Delete'),
onClick: onDelApp
}
]
}
]}
/>
)}
<Box flex={1} />
{/* {isPc && ( */}
{/* <MyTag */}
{/* type="borderFill" */}
{/* colorSchema="gray" */}
{/* onClick={() => (appDetail.permission.hasManagePer ? onOpenInfoEdit() : undefined)} */}
{/* > */}
{/* <PermissionIconText defaultPermission={appDetail.defaultPermission} /> */}
{/* </MyTag> */}
{/* )} */}
</HStack>
</Box>
{TeamTagsSet && <TagsEditModal onClose={() => setTeamTagsSet(undefined)} />}
{transitionCreateNew !== undefined && (
<MyModal isOpen title={t('app:transition_to_workflow')} iconSrc="core/app/type/workflow">
<ModalBody>
<Box mb={3}>{t('app:transition_to_workflow_create_new_tip')}</Box>
<HStack cursor={'pointer'} onClick={() => setTransitionCreateNew((state) => !state)}>
<Checkbox
isChecked={transitionCreateNew}
icon={<MyIcon name={'common/check'} w={'12px'} />}
/>
<Box>{t('app:transition_to_workflow_create_new_placeholder')}</Box>
</HStack>
</ModalBody>
<ModalFooter>
<Button variant={'whiteBase'} onClick={() => setTransitionCreateNew(undefined)} mr={3}>
{t('common:Close')}
</Button>
<Button variant={'dangerFill'} isLoading={transiting} onClick={() => onTransition()}>
{t('common:Confirm')}
</Button>
</ModalFooter>
</MyModal>
)}
</>
);
};
export default React.memo(AppCard);

View File

@@ -0,0 +1,104 @@
import { Box, Flex } from '@chakra-ui/react';
import React, { useEffect, useMemo, useState } from 'react';
import { useSafeState } from 'ahooks';
import type { AppDetailType, AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import { useContextSelector } from 'use-context-selector';
import { useChatGate } from '../useChatGate';
import ChatItemContextProvider, { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import ChatRecordContextProvider from '@/web/core/chat/context/chatRecordContext';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { cardStyles } from '../constants';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
type Props = {
appForm: AppSimpleEditFormType;
setRenderEdit: React.Dispatch<React.SetStateAction<boolean>>;
appDetail: AppDetailType; // 添加 appDetail prop
};
const ChatGate = ({ appForm, setRenderEdit, appDetail }: Props) => {
console.log('appDetai', appDetail);
console.log('appform', appForm);
const datasetCiteData = useContextSelector(ChatItemContext, (v) => v.datasetCiteData);
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
// 添加 selectedToolIds 状态管理
const [selectedToolIds, setSelectedToolIds] = useState<string[]>([]);
const [workflowData] = useSafeState({
nodes: appDetail.modules || [],
edges: appDetail.edges || []
});
useEffect(() => {
setRenderEdit(!datasetCiteData);
}, [datasetCiteData, setRenderEdit]);
const { ChatContainer, restartChat, loading } = useChatGate({
...workflowData,
chatConfig: appForm.chatConfig,
isReady: true,
appDetail,
selectedToolIds, // 传递 selectedToolIds
onSelectedToolIdsChange: setSelectedToolIds // 传递更新函数
});
return (
<Flex h={'full'} gap={2}>
<MyBox
flex={'1 0 0'}
w={0}
display={'flex'}
position={'relative'}
flexDirection={'column'}
h={'full'}
bg={'white'}
boxShadow={'3'}
>
<Box flex={1}>
<ChatContainer />
</Box>
</MyBox>
{datasetCiteData && (
<Box flex={'1 0 0'} w={0} maxW={'560px'} {...cardStyles} boxShadow={'3'}>
<ChatQuoteList
rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata}
onClose={() => setCiteModalData(undefined)}
/>
</Box>
)}
</Flex>
);
};
const Render = ({ appForm, setRenderEdit, appDetail }: Props) => {
const { chatId } = useChatStore();
const chatRecordProviderParams = useMemo(
() => ({
chatId: chatId,
appId: appDetail._id
}),
[appDetail._id, chatId]
);
return (
<ChatItemContextProvider
showRouteToAppDetail={true}
showRouteToDatasetDetail={true}
isShowReadRawSource={true}
isResponseDetail={true}
// isShowFullText={true}
showNodeStatus
>
<ChatRecordContextProvider params={chatRecordProviderParams}>
<ChatGate appForm={appForm} setRenderEdit={setRenderEdit} appDetail={appDetail} />
</ChatRecordContextProvider>
</ChatItemContextProvider>
);
};
export default React.memo(Render);

View File

@@ -0,0 +1,133 @@
import { Box, Flex, IconButton } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import React, { useEffect, useMemo } from 'react';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSafeState } from 'ahooks';
import type { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import { form2AppWorkflow } from '@/web/core/app/utils';
import { useContextSelector } from 'use-context-selector';
import { AppContext } from '../context';
import { useChatTest } from '../useChatTest';
import ChatItemContextProvider, { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import ChatRecordContextProvider from '@/web/core/chat/context/chatRecordContext';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { cardStyles } from '../constants';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
import VariablePopover from '@/components/core/chat/ChatContainer/ChatBox/components/VariablePopover';
type Props = {
appForm: AppSimpleEditFormType;
setRenderEdit: React.Dispatch<React.SetStateAction<boolean>>;
};
const ChatTest = ({ appForm, setRenderEdit }: Props) => {
const { t } = useTranslation();
const { appDetail } = useContextSelector(AppContext, (v) => v);
const datasetCiteData = useContextSelector(ChatItemContext, (v) => v.datasetCiteData);
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
// form2AppWorkflow dependent allDatasets
const isVariableVisible = useContextSelector(ChatItemContext, (v) => v.isVariableVisible);
const [workflowData, setWorkflowData] = useSafeState({
nodes: appDetail.modules || [],
edges: appDetail.edges || []
});
useEffect(() => {
const { nodes, edges } = form2AppWorkflow(appForm, t);
setWorkflowData({ nodes, edges });
}, [appForm, setWorkflowData, t]);
useEffect(() => {
setRenderEdit(!datasetCiteData);
}, [datasetCiteData, setRenderEdit]);
const { ChatContainer, restartChat, loading } = useChatTest({
...workflowData,
chatConfig: appForm.chatConfig,
isReady: true
});
return (
<Flex h={'full'} gap={2}>
<MyBox
flex={'1 0 0'}
w={0}
display={'flex'}
position={'relative'}
flexDirection={'column'}
h={'full'}
py={4}
{...cardStyles}
boxShadow={'3'}
>
<Flex px={[2, 5]} pb={2}>
<Box fontSize={['md', 'lg']} fontWeight={'bold'} color={'myGray.900'} mr={3}>
{t('app:chat_debug')}
</Box>
{!isVariableVisible && <VariablePopover showExternalVariables />}
<Box flex={1} />
<MyTooltip label={t('common:core.chat.Restart')}>
<IconButton
className="chat"
size={'smSquare'}
icon={<MyIcon name={'common/clearLight'} w={'14px'} />}
variant={'whiteDanger'}
borderRadius={'md'}
aria-label={'delete'}
onClick={(e) => {
e.stopPropagation();
restartChat();
}}
/>
</MyTooltip>
</Flex>
<Box flex={1}>
<ChatContainer />
</Box>
</MyBox>
{datasetCiteData && (
<Box flex={'1 0 0'} w={0} maxW={'560px'} {...cardStyles} boxShadow={'3'}>
<ChatQuoteList
rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata}
onClose={() => setCiteModalData(undefined)}
/>
</Box>
)}
</Flex>
);
};
const Render = ({ appForm, setRenderEdit }: Props) => {
const { chatId } = useChatStore();
const { appDetail } = useContextSelector(AppContext, (v) => v);
const chatRecordProviderParams = useMemo(
() => ({
chatId: chatId,
appId: appDetail._id
}),
[appDetail._id, chatId]
);
return (
<ChatItemContextProvider
showRouteToAppDetail={true}
showRouteToDatasetDetail={true}
isShowReadRawSource={true}
isResponseDetail={true}
// isShowFullText={true}
showNodeStatus
>
<ChatRecordContextProvider params={chatRecordProviderParams}>
<ChatTest appForm={appForm} setRenderEdit={setRenderEdit} />
</ChatRecordContextProvider>
</ChatItemContextProvider>
);
};
export default React.memo(Render);

View File

@@ -0,0 +1,62 @@
import React, { useState } from 'react';
import { Box } from '@chakra-ui/react';
import ChatTest from './ChatTest';
import AppCard from './AppCard';
import EditForm from './EditForm';
import type { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import { cardStyles } from '../constants';
import styles from './styles.module.scss';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import type { SimpleAppSnapshotType } from './useSnapshots';
const Edit = ({
appForm,
setAppForm,
setPast
}: {
appForm: AppSimpleEditFormType;
setAppForm: React.Dispatch<React.SetStateAction<AppSimpleEditFormType>>;
setPast: (value: React.SetStateAction<SimpleAppSnapshotType[]>) => void;
}) => {
const { isPc } = useSystem();
const [renderEdit, setRenderEdit] = useState(true);
return (
<Box
display={['block', 'flex']}
flex={'1 0 0'}
h={0}
mt={[4, 0]}
gap={1}
borderRadius={'lg'}
overflowY={['auto', 'unset']}
>
{renderEdit && (
<Box
className={styles.EditAppBox}
pr={[0, 1]}
overflowY={'auto'}
minW={['auto', '580px']}
flex={'1'}
>
<Box {...cardStyles} boxShadow={'2'}>
<AppCard appForm={appForm} setPast={setPast} />
</Box>
<Box mt={4} {...cardStyles} boxShadow={'3.5'}>
<EditForm appForm={appForm} setAppForm={setAppForm} />
</Box>
</Box>
)}
{isPc && (
<Box flex={'2 0 0'} w={0} mb={3}>
<ChatTest appForm={appForm} setRenderEdit={setRenderEdit} />
</Box>
)}
</Box>
);
};
export default React.memo(Edit);

View File

@@ -0,0 +1,404 @@
import React, { useEffect, useMemo, useTransition } from 'react';
import type { BoxProps } from '@chakra-ui/react';
import { Box, Flex, Grid, useTheme, useDisclosure, Button, HStack } from '@chakra-ui/react';
import type { AppSimpleEditFormType } from '@fastgpt/global/core/app/type.d';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import dynamic from 'next/dynamic';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyIcon from '@fastgpt/web/components/common/Icon';
import VariableEdit from '@/components/core/app/VariableEdit';
import PromptEditor from '@fastgpt/web/components/common/Textarea/PromptEditor';
import { formatEditorVariablePickerIcon } from '@fastgpt/global/core/workflow/utils';
import SearchParamsTip from '@/components/core/dataset/SearchParamsTip';
import SettingLLMModel from '@/components/core/ai/SettingLLMModel';
import { TTSTypeEnum } from '@/web/core/app/constants';
import { workflowSystemVariables } from '@/web/core/app/utils';
import { useContextSelector } from 'use-context-selector';
import { AppContext } from '@/pageComponents/app/detail/context';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import VariableTip from '@/components/common/Textarea/MyTextarea/VariableTip';
import { getWebLLMModel } from '@/web/common/system/utils';
import ToolSelect from './components/ToolSelect';
const DatasetSelectModal = dynamic(() => import('@/components/core/app/DatasetSelectModal'));
const DatasetParamsModal = dynamic(() => import('@/components/core/app/DatasetParamsModal'));
const TTSSelect = dynamic(() => import('@/components/core/app/TTSSelect'));
const QGConfig = dynamic(() => import('@/components/core/app/QGConfig'));
const WhisperConfig = dynamic(() => import('@/components/core/app/WhisperConfig'));
const InputGuideConfig = dynamic(() => import('@/components/core/app/InputGuideConfig'));
const WelcomeTextConfig = dynamic(() => import('@/components/core/app/WelcomeTextConfig'));
const FileSelectConfig = dynamic(() => import('@/components/core/app/FileSelect'));
const BoxStyles: BoxProps = {
px: [4, 6],
py: '16px',
borderBottomWidth: '1px',
borderBottomColor: 'borderColor.low'
};
const LabelStyles: BoxProps = {
w: ['60px', '100px'],
whiteSpace: 'nowrap',
flexShrink: 0,
fontSize: 'sm',
color: 'myGray.900'
};
const EditForm = ({
appForm,
setAppForm
}: {
appForm: AppSimpleEditFormType;
setAppForm: React.Dispatch<React.SetStateAction<AppSimpleEditFormType>>;
}) => {
const theme = useTheme();
const router = useRouter();
const { t } = useTranslation();
const { appDetail } = useContextSelector(AppContext, (v) => v);
const selectDatasets = useMemo(() => appForm?.dataset?.datasets, [appForm]);
const [, startTst] = useTransition();
const {
isOpen: isOpenDatasetSelect,
onOpen: onOpenKbSelect,
onClose: onCloseKbSelect
} = useDisclosure();
const {
isOpen: isOpenDatasetParams,
onOpen: onOpenDatasetParams,
onClose: onCloseDatasetParams
} = useDisclosure();
const formatVariables = useMemo(
() =>
formatEditorVariablePickerIcon([
...workflowSystemVariables.filter(
(variable) =>
!['appId', 'chatId', 'responseChatItemId', 'histories'].includes(variable.key)
),
...(appForm.chatConfig.variables || [])
]).map((item) => ({
...item,
label: t(item.label as any),
parent: {
id: 'VARIABLE_NODE_ID',
label: t('common:core.module.Variable'),
avatar: 'core/workflow/template/variable'
}
})),
[appForm.chatConfig.variables, t]
);
const selectedModel = getWebLLMModel(appForm.aiSettings.model);
const tokenLimit = useMemo(() => {
return selectedModel?.quoteMaxToken || 3000;
}, [selectedModel?.quoteMaxToken]);
// Force close image select when model not support vision
useEffect(() => {
if (!selectedModel.vision) {
setAppForm((state) => ({
...state,
chatConfig: {
...state.chatConfig,
...(state.chatConfig.fileSelectConfig
? {
fileSelectConfig: {
...state.chatConfig.fileSelectConfig,
canSelectImg: false
}
}
: {})
}
}));
}
}, [selectedModel, setAppForm]);
return (
<>
<Box>
{/* ai */}
<Box {...BoxStyles}>
<Flex alignItems={'center'}>
<MyIcon name={'core/app/simpleMode/ai'} w={'20px'} />
<FormLabel ml={2} flex={1}>
{t('app:ai_settings')}
</FormLabel>
</Flex>
<Flex alignItems={'center'} mt={5}>
<Box {...LabelStyles}>{t('common:core.ai.Model')}</Box>
<Box flex={'1 0 0'}>
<SettingLLMModel
bg="myGray.50"
llmModelType={'all'}
defaultData={{
model: appForm.aiSettings.model,
temperature: appForm.aiSettings.temperature,
maxToken: appForm.aiSettings.maxToken,
maxHistories: appForm.aiSettings.maxHistories,
aiChatReasoning: appForm.aiSettings.aiChatReasoning ?? true,
aiChatTopP: appForm.aiSettings.aiChatTopP,
aiChatStopSign: appForm.aiSettings.aiChatStopSign,
aiChatResponseFormat: appForm.aiSettings.aiChatResponseFormat,
aiChatJsonSchema: appForm.aiSettings.aiChatJsonSchema
}}
onChange={({ maxHistories = 6, ...data }) => {
setAppForm((state) => ({
...state,
aiSettings: {
...state.aiSettings,
...data,
maxHistories
}
}));
}}
/>
</Box>
</Flex>
<Box mt={4}>
<HStack {...LabelStyles} w={'100%'}>
<Box>{t('common:core.ai.Prompt')}</Box>
<QuestionTip label={t('common:core.app.tip.systemPromptTip')} />
<Box flex={1} />
<VariableTip color={'myGray.500'} />
</HStack>
<Box mt={1}>
<PromptEditor
minH={150}
value={appForm.aiSettings.systemPrompt}
bg={'myGray.50'}
onChange={(text) => {
startTst(() => {
setAppForm((state) => ({
...state,
aiSettings: {
...state.aiSettings,
systemPrompt: text
}
}));
});
}}
variableLabels={formatVariables}
variables={formatVariables}
placeholder={t('common:core.app.tip.systemPromptTip')}
title={t('common:core.ai.Prompt')}
/>
</Box>
</Box>
</Box>
{/* dataset */}
<Box {...BoxStyles}>
<Flex alignItems={'center'}>
<Flex alignItems={'center'} flex={1}>
<MyIcon name={'core/app/simpleMode/dataset'} w={'20px'} />
<FormLabel ml={2}>{t('common:core.dataset.Choose Dataset')}</FormLabel>
</Flex>
<Button
variant={'transparentBase'}
leftIcon={<MyIcon name="common/addLight" w={'0.8rem'} />}
iconSpacing={1}
size={'sm'}
fontSize={'sm'}
onClick={onOpenKbSelect}
>
{t('common:Choose')}
</Button>
<Button
variant={'transparentBase'}
leftIcon={<MyIcon name={'edit'} w={'14px'} />}
iconSpacing={1}
size={'sm'}
fontSize={'sm'}
onClick={onOpenDatasetParams}
>
{t('common:Params')}
</Button>
</Flex>
{appForm.dataset.datasets?.length > 0 && (
<Box my={3}>
<SearchParamsTip
searchMode={appForm.dataset.searchMode}
similarity={appForm.dataset.similarity}
limit={appForm.dataset.limit}
usingReRank={appForm.dataset.usingReRank}
datasetSearchUsingExtensionQuery={appForm.dataset.datasetSearchUsingExtensionQuery}
queryExtensionModel={appForm.dataset.datasetSearchExtensionModel}
/>
</Box>
)}
<Grid gridTemplateColumns={'repeat(2, minmax(0, 1fr))'} gridGap={[2, 4]}>
{selectDatasets.map((item) => (
<MyTooltip key={item.datasetId} label={t('common:core.dataset.Read Dataset')}>
<Flex
overflow={'hidden'}
alignItems={'center'}
p={2}
bg={'white'}
boxShadow={'0 4px 8px -2px rgba(16,24,40,.1),0 2px 4px -2px rgba(16,24,40,.06)'}
borderRadius={'md'}
border={theme.borders.base}
cursor={'pointer'}
onClick={() =>
router.push({
pathname: '/dataset/detail',
query: {
datasetId: item.datasetId
}
})
}
>
<Avatar src={item.avatar} w={'1.5rem'} borderRadius={'sm'} />
<Box
ml={2}
flex={'1 0 0'}
w={0}
className={'textEllipsis'}
fontSize={'sm'}
color={'myGray.900'}
>
{item.name}
</Box>
</Flex>
</MyTooltip>
))}
</Grid>
</Box>
{/* tool choice */}
<Box {...BoxStyles}>
<ToolSelect appForm={appForm} setAppForm={setAppForm} />
</Box>
{/* File select */}
<Box {...BoxStyles}>
<FileSelectConfig
forbidVision={!selectedModel?.vision}
value={appForm.chatConfig.fileSelectConfig}
onChange={(e) => {
setAppForm((state) => ({
...state,
chatConfig: {
...state.chatConfig,
fileSelectConfig: e
}
}));
}}
/>
</Box>
{/* tts */}
<Box {...BoxStyles}>
<TTSSelect
value={appForm.chatConfig.ttsConfig}
onChange={(e) => {
setAppForm((state) => ({
...state,
chatConfig: {
...state.chatConfig,
ttsConfig: e
}
}));
}}
/>
</Box>
{/* whisper */}
<Box {...BoxStyles}>
<WhisperConfig
isOpenAudio={appForm.chatConfig.ttsConfig?.type !== TTSTypeEnum.none}
value={appForm.chatConfig.whisperConfig}
onChange={(e) => {
setAppForm((state) => ({
...state,
chatConfig: {
...state.chatConfig,
whisperConfig: e
}
}));
}}
/>
</Box>
{/* question guide */}
<Box {...BoxStyles}>
<QGConfig
value={appForm.chatConfig.questionGuide}
onChange={(e) => {
setAppForm((state) => ({
...state,
chatConfig: {
...state.chatConfig,
questionGuide: e
}
}));
}}
/>
</Box>
{/* question tips */}
<Box {...BoxStyles}>
<InputGuideConfig
appId={appDetail._id}
value={appForm.chatConfig.chatInputGuide}
onChange={(e) => {
setAppForm((state) => ({
...state,
chatConfig: {
...state.chatConfig,
chatInputGuide: e
}
}));
}}
/>
</Box>
</Box>
{isOpenDatasetSelect && (
<DatasetSelectModal
isOpen={isOpenDatasetSelect}
defaultSelectedDatasets={selectDatasets.map((item) => ({
datasetId: item.datasetId,
vectorModel: item.vectorModel,
name: item.name,
avatar: item.avatar
}))}
onClose={onCloseKbSelect}
onChange={(e) => {
setAppForm((state) => ({
...state,
dataset: {
...state.dataset,
datasets: e
}
}));
}}
/>
)}
{isOpenDatasetParams && (
<DatasetParamsModal
{...appForm.dataset}
maxTokens={tokenLimit}
onClose={onCloseDatasetParams}
onSuccess={(e) => {
setAppForm((state) => ({
...state,
dataset: {
...state.dataset,
...e
}
}));
}}
/>
)}
</>
);
};
export default React.memo(EditForm);

View File

@@ -0,0 +1,281 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useContextSelector } from 'use-context-selector';
import { AppContext } from '../context';
import FolderPath from '@/components/common/folder/Path';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { getAppFolderPath } from '@/web/core/app/api/app';
import { Box, Flex, IconButton } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import RouteTab from '../RouteTab';
import { useTranslation } from 'next-i18next';
import type { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import { form2AppWorkflow } from '@/web/core/app/utils';
import { TabEnum } from '../context';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTag from '@fastgpt/web/components/common/Tag/index';
import { publishStatusStyle } from '../constants';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import SaveButton from '../Workflow/components/SaveButton';
import { useBoolean, useDebounceEffect, useLockFn } from 'ahooks';
import { appWorkflow2Form } from '@fastgpt/global/core/app/utils';
import type { onSaveSnapshotFnType, SimpleAppSnapshotType } from './useSnapshots';
import { compareSimpleAppSnapshot } from './useSnapshots';
import PublishHistories from '../PublishHistoriesSlider';
import type { AppVersionSchemaType } from '@fastgpt/global/core/app/version';
import { useBeforeunload } from '@fastgpt/web/hooks/useBeforeunload';
import { isProduction } from '@fastgpt/global/common/system/constants';
import { useToast } from '@fastgpt/web/hooks/useToast';
import {
checkWorkflowNodeAndConnection,
storeEdge2RenderEdge,
storeNode2FlowNode
} from '@/web/core/workflow/utils';
const Header = ({
forbiddenSaveSnapshot,
appForm,
setAppForm,
past,
setPast,
saveSnapshot
}: {
forbiddenSaveSnapshot: React.MutableRefObject<boolean>;
appForm: AppSimpleEditFormType;
setAppForm: (form: AppSimpleEditFormType) => void;
past: SimpleAppSnapshotType[];
setPast: (value: React.SetStateAction<SimpleAppSnapshotType[]>) => void;
saveSnapshot: onSaveSnapshotFnType;
}) => {
const { t } = useTranslation();
const { isPc } = useSystem();
const { toast } = useToast();
const router = useRouter();
const appId = useContextSelector(AppContext, (v) => v.appId);
const onSaveApp = useContextSelector(AppContext, (v) => v.onSaveApp);
const currentTab = useContextSelector(AppContext, (v) => v.currentTab);
const { lastAppListRouteType } = useSystemStore();
const { data: paths = [] } = useRequest2(
() => getAppFolderPath({ sourceId: appId, type: 'parent' }),
{
manual: false,
refreshDeps: [appId]
}
);
const onClickRoute = useCallback(
(parentId: string) => {
router.push({
pathname: '/dashboard/apps',
query: {
parentId,
type: lastAppListRouteType
}
});
},
[router, lastAppListRouteType]
);
const { runAsync: onClickSave, loading } = useRequest2(
async ({
isPublish,
versionName = formatTime2YMDHMS(new Date()),
autoSave
}: {
isPublish?: boolean;
versionName?: string;
autoSave?: boolean;
}) => {
const { nodes, edges } = form2AppWorkflow(appForm, t);
await onSaveApp({
nodes,
edges,
chatConfig: appForm.chatConfig,
isPublish,
versionName,
autoSave
});
setPast((prevPast) =>
prevPast.map((item, index) =>
index === 0
? {
...item,
isSaved: true
}
: item
)
);
}
);
const [isShowHistories, { setTrue: setIsShowHistories, setFalse: closeHistories }] =
useBoolean(false);
const onSwitchTmpVersion = useCallback(
(data: SimpleAppSnapshotType, customTitle: string) => {
setAppForm(data.appForm);
// Remove multiple "copy-"
const copyText = t('app:version_copy');
const regex = new RegExp(`(${copyText}-)\\1+`, 'g');
const title = customTitle.replace(regex, `$1`);
return saveSnapshot({
appForm: data.appForm,
title
});
},
[saveSnapshot, setAppForm, t]
);
const onSwitchCloudVersion = useCallback(
(appVersion: AppVersionSchemaType) => {
const appForm = appWorkflow2Form({
nodes: appVersion.nodes,
chatConfig: appVersion.chatConfig
});
const res = saveSnapshot({
appForm,
title: `${t('app:version_copy')}-${appVersion.versionName}`
});
forbiddenSaveSnapshot.current = true;
setAppForm(appForm);
return res;
},
[forbiddenSaveSnapshot, saveSnapshot, setAppForm, t]
);
// Check if the workflow is published
const [isSaved, setIsSaved] = useState(false);
useDebounceEffect(
() => {
const savedSnapshot = past.find((snapshot) => snapshot.isSaved);
const val = compareSimpleAppSnapshot(savedSnapshot?.appForm, appForm);
setIsSaved(val);
},
[past],
{ wait: 500 }
);
const onLeaveAutoSave = useLockFn(async () => {
if (isSaved) return;
try {
console.log('Leave auto save');
return onClickSave({ isPublish: false, autoSave: true });
} catch (error) {
console.error(error);
}
});
useEffect(() => {
return () => {
if (isProduction) {
onLeaveAutoSave();
}
};
}, []);
useBeforeunload({
tip: t('common:core.tip.leave page'),
callback: onLeaveAutoSave
});
return (
<Box h={14}>
{!isPc && (
<Flex justifyContent={'center'}>
<RouteTab />
</Flex>
)}
<Flex w={'full'} alignItems={'center'} position={'relative'} h={'full'}>
<Box flex={'1'}>
<FolderPath
rootName={t('app:all_apps')}
paths={paths}
hoverStyle={{ color: 'primary.600' }}
onClick={onClickRoute}
fontSize={'14px'}
/>
</Box>
{isPc && (
<Box position={'absolute'} left={'50%'} transform={'translateX(-50%)'}>
<RouteTab />
</Box>
)}
{currentTab === TabEnum.appEdit && (
<Flex alignItems={'center'}>
{!isShowHistories && (
<>
{isPc && (
<MyTag
mr={3}
type={'borderFill'}
showDot
colorSchema={
isSaved
? publishStatusStyle.published.colorSchema
: publishStatusStyle.unPublish.colorSchema
}
>
{t(
isSaved
? publishStatusStyle.published.text
: publishStatusStyle.unPublish.text
)}
</MyTag>
)}
<IconButton
mr={[2, 4]}
icon={<MyIcon name={'history'} w={'18px'} />}
aria-label={''}
size={'sm'}
w={'30px'}
variant={'whitePrimary'}
onClick={setIsShowHistories}
/>
<SaveButton
isLoading={loading}
onClickSave={onClickSave}
checkData={() => {
const { nodes: storeNodes, edges: storeEdges } = form2AppWorkflow(appForm, t);
const nodes = storeNodes.map((item) => storeNode2FlowNode({ item, t }));
const edges = storeEdges.map((item) => storeEdge2RenderEdge({ edge: item }));
const checkResults = checkWorkflowNodeAndConnection({ nodes, edges });
if (checkResults) {
toast({
title: t('app:app.error.publish_unExist_app'),
status: 'warning'
});
}
return !checkResults;
}}
/>
</>
)}
</Flex>
)}
</Flex>
{isShowHistories && currentTab === TabEnum.appEdit && (
<PublishHistories<SimpleAppSnapshotType>
onClose={closeHistories}
past={past}
onSwitchTmpVersion={onSwitchTmpVersion}
onSwitchCloudVersion={onSwitchCloudVersion}
positionStyles={{
top: 14,
bottom: 3
}}
/>
)}
</Box>
);
};
export default Header;

View File

@@ -0,0 +1,130 @@
import { Button, HStack, ModalBody, ModalFooter } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import React from 'react';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { Box } from '@chakra-ui/react';
import type { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import { childAppSystemKey } from './ToolSelectModal';
import { Controller, useForm } from 'react-hook-form';
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import RenderPluginInput from '@/components/core/chat/ChatContainer/PluginRunBox/components/renderPluginInput';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import UseGuideModal from '@/components/common/Modal/UseGuideModal';
const ConfigToolModal = ({
configTool,
onCloseConfigTool,
onAddTool
}: {
configTool: AppSimpleEditFormType['selectedTools'][number];
onCloseConfigTool: () => void;
onAddTool: (tool: AppSimpleEditFormType['selectedTools'][number]) => void;
}) => {
const { t } = useTranslation();
const {
handleSubmit,
control,
formState: { errors }
} = useForm({
defaultValues: configTool
? configTool.inputs.reduce(
(acc, input) => {
acc[input.key] = input.value || input.defaultValue;
return acc;
},
{} as Record<string, any>
)
: {}
});
return (
<MyModal
isOpen
isCentered
title={t('common:core.app.ToolCall.Parameter setting')}
iconSrc="core/app/toolCall"
overflow={'auto'}
>
<ModalBody>
<HStack mb={4} spacing={1} fontSize={'sm'}>
<MyIcon name={'common/info'} w={'1.25rem'} />
<Box flex={1}>{t('app:tool_input_param_tip')}</Box>
{!!(configTool?.courseUrl || configTool?.userGuide) && (
<UseGuideModal
title={configTool?.name}
iconSrc={configTool?.avatar}
text={configTool?.userGuide}
link={configTool?.courseUrl}
>
{({ onClick }) => (
<Box cursor={'pointer'} color={'primary.500'} onClick={onClick}>
{t('app:workflow.Input guide')}
</Box>
)}
</UseGuideModal>
)}
</HStack>
{configTool.inputs
.filter(
(input) =>
!input.toolDescription &&
!childAppSystemKey.includes(input.key) &&
!input.renderTypeList.includes(FlowNodeInputTypeEnum.selectLLMModel) &&
!input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect)
)
.map((input) => {
return (
<Controller
key={input.key}
control={control}
name={input.key}
rules={{
validate: (value) => {
if (input.valueType === WorkflowIOValueTypeEnum.boolean) {
return value !== undefined;
}
return !!value;
}
}}
render={({ field: { onChange, value } }) => {
return (
<RenderPluginInput
value={value}
isInvalid={errors && Object.keys(errors).includes(input.key)}
onChange={onChange}
input={input}
setUploading={() => {}}
/>
);
}}
/>
);
})}
</ModalBody>
<ModalFooter gap={6}>
<Button onClick={onCloseConfigTool} variant={'whiteBase'}>
{t('common:Cancel')}
</Button>
<Button
variant={'primary'}
onClick={handleSubmit((data) => {
onAddTool({
...configTool,
inputs: configTool.inputs.map((input) => ({
...input,
value: data[input.key] ?? input.value
}))
});
onCloseConfigTool();
})}
>
{t('common:Confirm')}
</Button>
</ModalFooter>
</MyModal>
);
};
export default React.memo(ConfigToolModal);

View File

@@ -0,0 +1,227 @@
import React, { useState, useMemo, useCallback } from 'react';
import {
Box,
Button,
Flex,
Text,
Checkbox,
VStack,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure
} from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { getTeamGateConfig } from '@/web/support/user/team/gate/api';
import { getSystemPlugTemplates, getTeamPlugTemplates } from '@/web/core/app/api/plugin';
import type { NodeTemplateListItemType } from '@fastgpt/global/core/workflow/type/node.d';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
type GateToolSelectProps = {
selectedToolIds: string[];
onToolsChange: (toolIds: string[]) => void;
buttonSize?: string;
};
const GateToolSelect = ({
selectedToolIds,
onToolsChange,
buttonSize = 'md'
}: GateToolSelectProps) => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
// 获取门户配置中的工具列表
const { data: gateConfig, loading: loadingGateConfig } = useRequest2(() => getTeamGateConfig(), {
manual: false
});
console.log('gateConfig', gateConfig);
// 获取系统插件模板
const { data: systemPlugins = [], loading: loadingSystemPlugins } = useRequest2(
() => getSystemPlugTemplates({ parentId: '', searchKey: '' }),
{
manual: false
}
);
// 获取团队插件模板
const { data: teamPlugins = [], loading: loadingTeamPlugins } = useRequest2(
() => getTeamPlugTemplates({ parentId: '', searchKey: '' }),
{
manual: false
}
);
// 合并所有可用工具
const allAvailableTools = useMemo(() => {
return [...systemPlugins, ...teamPlugins];
}, [systemPlugins, teamPlugins]);
// 筛选出gate配置中指定的工具如果没有指定则显示所有工具
const availableTools = useMemo(() => {
if (!allAvailableTools.length) return [];
// 如果gate配置中有指定工具只显示这些工具否则显示所有工具
if (gateConfig?.tools?.length) {
return allAvailableTools.filter((tool) => gateConfig.tools.includes(tool.id));
}
return allAvailableTools;
}, [gateConfig?.tools, allAvailableTools]);
// 处理单个工具的选择/取消选择
const handleToolSelect = useCallback(
(toolId: string, checked: boolean) => {
const newSelectedIds = checked
? [...selectedToolIds, toolId]
: selectedToolIds.filter((id) => id !== toolId);
onToolsChange(newSelectedIds);
},
[selectedToolIds, onToolsChange]
);
const selectedCount = selectedToolIds.length;
const loading = loadingGateConfig || loadingSystemPlugins || loadingTeamPlugins;
// 调试信息
console.log('GateToolSelect Debug:', {
isOpen,
loading,
availableTools: availableTools.length,
gateConfigTools: gateConfig?.tools?.length || 0,
systemPlugins: systemPlugins.length,
teamPlugins: teamPlugins.length,
allAvailableTools: allAvailableTools.length
});
return (
<>
<Button
leftIcon={
<MyIcon name={'support/gate/chat/toolkitLine'} w={'18px'} h={'18px'} color="blue.500" />
}
size={buttonSize}
display="flex"
padding="8px 12px"
justifyContent="center"
alignItems="center"
gap="4px"
iconSpacing="4px"
borderRadius="9999px"
border="0.5px solid var(--Royal-Blue-200, #C5D7FF)"
background="var(--light-fastgpt-primary-container-low, #F0F4FF)"
color="blue.500"
fontWeight="500"
onClick={() => {
console.log('Button clicked, opening modal');
onOpen();
}}
flexShrink={0}
_hover={{
background: 'var(--light-fastgpt-primary-container-low, #E6EDFF)'
}}
>
<Box display={{ base: 'none', md: 'block' }}>{t('common:tool_select')}:&nbsp;</Box>
{selectedCount}
</Button>
<Modal isOpen={isOpen} onClose={onClose} size="md">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<Flex align="center" gap={2}>
<MyIcon
name={'support/gate/chat/toolkitLine'}
w={'20px'}
h={'20px'}
color="blue.500"
/>
<Text></Text>
<Text fontSize="sm" color="myGray.600">
({availableTools.length} )
</Text>
</Flex>
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
{loading ? (
<Flex justify="center" py={8}>
<Text fontSize="sm" color="myGray.500">
{t('common:Loading')}
</Text>
</Flex>
) : availableTools.length === 0 ? (
<Box py={8} textAlign="center">
<EmptyTip text="暂无可用工具" />
<Text fontSize="sm" color="myGray.500" mt={3}>
</Text>
</Box>
) : (
<VStack align="stretch" spacing={2}>
{availableTools.map((tool) => (
<Box
key={tool.id}
p={4}
borderRadius="md"
cursor="pointer"
border="1px solid"
borderColor="gray.200"
transition="all 0.2s"
_hover={{
bg: 'blue.50',
borderColor: 'blue.300'
}}
onClick={() => handleToolSelect(tool.id, !selectedToolIds.includes(tool.id))}
>
<Flex align="center">
<Checkbox
size="md"
isChecked={selectedToolIds.includes(tool.id)}
onChange={(e) => {
e.stopPropagation();
handleToolSelect(tool.id, e.target.checked);
}}
mr={4}
colorScheme="blue"
/>
<Avatar src={tool.avatar} w="32px" h="32px" mr={3} />
<Box flex={1}>
<Text fontSize="md" fontWeight="medium" color="myGray.900">
{tool.name}
</Text>
{tool.intro && (
<Text fontSize="sm" color="myGray.600" mt={1} noOfLines={2}>
{tool.intro}
</Text>
)}
</Box>
</Flex>
</Box>
))}
</VStack>
)}
{selectedToolIds.length > 0 && (
<Box mt={4} p={3} bg="blue.50" borderRadius="md">
<Text fontSize="sm" color="blue.700">
{selectedToolIds.length}
</Text>
</Box>
)}
</ModalBody>
</ModalContent>
</Modal>
</>
);
};
export default React.memo(GateToolSelect);

View File

@@ -0,0 +1,184 @@
import { Box, Button, Flex, Grid, useDisclosure } from '@chakra-ui/react';
import React, { useState } from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useTranslation } from 'next-i18next';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import { SmallAddIcon } from '@chakra-ui/icons';
import { type AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { theme } from '@fastgpt/web/styles/theme';
import DeleteIcon, { hoverDeleteStyles } from '@fastgpt/web/components/common/Icon/delete';
import ToolSelectModal, { childAppSystemKey } from './ToolSelectModal';
import {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import Avatar from '@fastgpt/web/components/common/Avatar';
import ConfigToolModal from './ConfigToolModal';
import { getWebLLMModel } from '@/web/common/system/utils';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import { formatToolError } from '@fastgpt/global/core/app/utils';
const ToolSelect = ({
appForm,
setAppForm
}: {
appForm: AppSimpleEditFormType;
setAppForm: React.Dispatch<React.SetStateAction<AppSimpleEditFormType>>;
}) => {
const { t } = useTranslation();
const [configTool, setConfigTool] = useState<
AppSimpleEditFormType['selectedTools'][number] | null
>(null);
const {
isOpen: isOpenToolsSelect,
onOpen: onOpenToolsSelect,
onClose: onCloseToolsSelect
} = useDisclosure();
const selectedModel = getWebLLMModel(appForm.aiSettings.model);
return (
<>
<Flex alignItems={'center'}>
<Flex alignItems={'center'} flex={1}>
<MyIcon name={'core/app/toolCall'} w={'20px'} />
<FormLabel ml={2}>{t('common:core.app.Tool call')}</FormLabel>
<QuestionTip ml={1} label={t('app:plugin_dispatch_tip')} />
</Flex>
<Button
variant={'transparentBase'}
leftIcon={<SmallAddIcon />}
iconSpacing={1}
mr={'-5px'}
size={'sm'}
fontSize={'sm'}
onClick={onOpenToolsSelect}
>
{t('common:Choose')}
</Button>
</Flex>
<Grid
mt={appForm.selectedTools.length > 0 ? 2 : 0}
gridTemplateColumns={'repeat(2, minmax(0, 1fr))'}
gridGap={[2, 4]}
>
{appForm.selectedTools.map((item) => {
const toolError = formatToolError(item.pluginData?.error);
return (
<MyTooltip key={item.id} label={item.intro}>
<Flex
overflow={'hidden'}
alignItems={'center'}
p={2.5}
bg={'white'}
boxShadow={'0 4px 8px -2px rgba(16,24,40,.1),0 2px 4px -2px rgba(16,24,40,.06)'}
borderRadius={'md'}
border={theme.borders.base}
borderColor={toolError ? 'red.600' : ''}
_hover={{
...hoverDeleteStyles,
borderColor: toolError ? 'red.600' : 'primary.300'
}}
cursor={'pointer'}
onClick={() => {
if (
item.inputs
.filter((input) => !childAppSystemKey.includes(input.key))
.every(
(input) =>
input.toolDescription ||
input.renderTypeList.includes(FlowNodeInputTypeEnum.selectLLMModel) ||
input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect)
) ||
toolError ||
item.flowNodeType === FlowNodeTypeEnum.tool ||
item.flowNodeType === FlowNodeTypeEnum.toolSet
) {
return;
}
setConfigTool(item);
}}
>
<Avatar src={item.avatar} w={'1.5rem'} h={'1.5rem'} borderRadius={'sm'} />
<Box
flex={'1 0 0'}
ml={2}
gap={2}
className={'textEllipsis'}
fontSize={'sm'}
color={'myGray.900'}
>
{item.name}
</Box>
{toolError && (
<Flex
bg={'red.50'}
alignItems={'center'}
h={6}
px={2}
rounded={'6px'}
fontSize={'xs'}
fontWeight={'medium'}
>
<MyIcon name={'common/errorFill'} w={'14px'} mr={1} />
<Box color={'red.600'}>{t(toolError as any)}</Box>
</Flex>
)}
<DeleteIcon
ml={2}
onClick={(e) => {
e.stopPropagation();
setAppForm((state: AppSimpleEditFormType) => ({
...state,
selectedTools: state.selectedTools.filter((tool) => tool.id !== item.id)
}));
}}
/>
</Flex>
</MyTooltip>
);
})}
</Grid>
{isOpenToolsSelect && (
<ToolSelectModal
selectedTools={appForm.selectedTools}
chatConfig={appForm.chatConfig}
selectedModel={selectedModel}
onAddTool={(e) => {
setAppForm((state) => ({
...state,
selectedTools: [...state.selectedTools, e]
}));
}}
onRemoveTool={(e) => {
setAppForm((state) => ({
...state,
selectedTools: state.selectedTools.filter((item) => item.pluginId !== e.id)
}));
}}
onClose={onCloseToolsSelect}
/>
)}
{configTool && (
<ConfigToolModal
configTool={configTool}
onCloseConfigTool={() => setConfigTool(null)}
onAddTool={(e) => {
setAppForm((state) => ({
...state,
selectedTools: state.selectedTools.map((item) =>
item.pluginId === configTool.pluginId ? e : item
)
}));
}}
/>
)}
</>
);
};
export default React.memo(ToolSelect);

View File

@@ -0,0 +1,576 @@
import React, { useCallback, useMemo, useState } from 'react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useTranslation } from 'next-i18next';
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Button,
css,
Flex,
Grid
} from '@chakra-ui/react';
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import {
type FlowNodeTemplateType,
type NodeTemplateListItemType,
type NodeTemplateListType
} from '@fastgpt/global/core/workflow/type/node.d';
import MyIcon from '@fastgpt/web/components/common/Icon';
import {
getPluginGroups,
getPreviewPluginNode,
getSystemPlugTemplates,
getSystemPluginPaths
} from '@/web/core/app/api/plugin';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { getTeamPlugTemplates } from '@/web/core/app/api/plugin';
import { type ParentIdType } from '@fastgpt/global/common/parentFolder/type';
import { getAppFolderPath } from '@/web/core/app/api/app';
import FolderPath from '@/components/common/folder/Path';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import CostTooltip from '@/components/core/app/plugin/CostTooltip';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { useContextSelector } from 'use-context-selector';
import { AppContext } from '../../context';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import { useMemoizedFn } from 'ahooks';
import MyAvatar from '@fastgpt/web/components/common/Avatar';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { type AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import { useToast } from '@fastgpt/web/hooks/useToast';
import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.d';
import { workflowStartNodeId } from '@/web/core/app/constants';
import ConfigToolModal from './ConfigToolModal';
type Props = {
selectedTools: FlowNodeTemplateType[];
chatConfig: AppSimpleEditFormType['chatConfig'];
selectedModel: LLMModelItemType;
onAddTool: (tool: FlowNodeTemplateType) => void;
onRemoveTool: (tool: NodeTemplateListItemType) => void;
};
export const childAppSystemKey: string[] = [
NodeInputKeyEnum.forbidStream,
NodeInputKeyEnum.history,
NodeInputKeyEnum.historyMaxAmount,
NodeInputKeyEnum.userChatInput
];
enum TemplateTypeEnum {
'systemPlugin' = 'systemPlugin',
'teamPlugin' = 'teamPlugin'
}
const ToolSelectModal = ({ onClose, ...props }: Props & { onClose: () => void }) => {
const { t } = useTranslation();
const { appDetail } = useContextSelector(AppContext, (v) => v);
const [templateType, setTemplateType] = useState(TemplateTypeEnum.systemPlugin);
const [parentId, setParentId] = useState<ParentIdType>('');
const [searchKey, setSearchKey] = useState('');
const {
data: templates = [],
runAsync: loadTemplates,
loading: isLoading
} = useRequest2(
async ({
type = templateType,
parentId = '',
searchVal = searchKey
}: {
type?: TemplateTypeEnum;
parentId?: ParentIdType;
searchVal?: string;
}) => {
if (type === TemplateTypeEnum.systemPlugin) {
return getSystemPlugTemplates({ parentId, searchKey: searchVal });
} else if (type === TemplateTypeEnum.teamPlugin) {
return getTeamPlugTemplates({
parentId,
searchKey: searchVal
}).then((res) => res.filter((app) => app.id !== appDetail._id));
}
},
{
onSuccess(_, [{ type = templateType, parentId = '' }]) {
setTemplateType(type);
setParentId(parentId);
},
refreshDeps: [templateType, searchKey, parentId],
errorToast: t('common:core.module.templates.Load plugin error')
}
);
const { data: paths = [] } = useRequest2(
() => {
if (templateType === TemplateTypeEnum.teamPlugin)
return getAppFolderPath({ sourceId: parentId, type: 'current' });
return getSystemPluginPaths({ sourceId: parentId, type: 'current' });
},
{
manual: false,
refreshDeps: [parentId]
}
);
const onUpdateParentId = useCallback(
(parentId: ParentIdType) => {
loadTemplates({
parentId
});
},
[loadTemplates]
);
useRequest2(() => loadTemplates({ searchVal: searchKey }), {
manual: false,
throttleWait: 300,
refreshDeps: [searchKey]
});
return (
<MyModal
isOpen
title={t('common:core.app.Tool call')}
iconSrc="core/app/toolCall"
onClose={onClose}
maxW={['90vw', '700px']}
w={'700px'}
h={['90vh', '80vh']}
>
{/* Header: row and search */}
<Box px={[3, 6]} pt={4} display={'flex'} justifyContent={'space-between'} w={'full'}>
<FillRowTabs
list={[
{
icon: 'phoneTabbar/tool',
label: t('common:navbar.Toolkit'),
value: TemplateTypeEnum.systemPlugin
},
{
icon: 'core/modules/teamPlugin',
label: t('common:core.module.template.Team app'),
value: TemplateTypeEnum.teamPlugin
}
]}
py={'5px'}
px={'15px'}
value={templateType}
onChange={(e) =>
loadTemplates({
type: e as TemplateTypeEnum,
parentId: null
})
}
/>
<Box w={300}>
<SearchInput
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder={
templateType === TemplateTypeEnum.systemPlugin
? t('common:plugin.Search plugin')
: t('app:search_app')
}
/>
</Box>
</Box>
{/* route components */}
{!searchKey && parentId && (
<Flex mt={2} px={[3, 6]}>
<FolderPath paths={paths} FirstPathDom={null} onClick={onUpdateParentId} />
</Flex>
)}
<MyBox isLoading={isLoading} mt={2} px={[3, 6]} pb={3} flex={'1 0 0'} overflowY={'auto'}>
<RenderList
templates={templates}
type={templateType}
setParentId={onUpdateParentId}
{...props}
/>
</MyBox>
</MyModal>
);
};
export default React.memo(ToolSelectModal);
const RenderList = React.memo(function RenderList({
templates,
type,
onAddTool,
onRemoveTool,
setParentId,
selectedTools,
chatConfig,
selectedModel
}: Props & {
templates: NodeTemplateListItemType[];
type: TemplateTypeEnum;
setParentId: (parentId: ParentIdType) => any;
}) {
const { t } = useTranslation();
const [configTool, setConfigTool] = useState<FlowNodeTemplateType>();
const onCloseConfigTool = useCallback(() => setConfigTool(undefined), []);
const { toast } = useToast();
const { runAsync: onClickAdd, loading: isLoading } = useRequest2(
async (template: NodeTemplateListItemType) => {
const res = await getPreviewPluginNode({ appId: template.id });
/* Invalid plugin check
1. Reference type. but not tool description;
2. Has dataset select
3. Has dynamic external data
*/
const oneFileInput =
res.inputs.filter((input) =>
input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect)
).length === 1;
const canUploadFile =
chatConfig?.fileSelectConfig?.canSelectFile || chatConfig?.fileSelectConfig?.canSelectImg;
const invalidFileInput = oneFileInput && !!canUploadFile;
if (
res.inputs.some(
(input) =>
(input.renderTypeList.length === 1 &&
input.renderTypeList[0] === FlowNodeInputTypeEnum.reference &&
!input.toolDescription) ||
input.renderTypeList.includes(FlowNodeInputTypeEnum.selectDataset) ||
input.renderTypeList.includes(FlowNodeInputTypeEnum.addInputParam) ||
(input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect) && !invalidFileInput)
)
) {
return toast({
title: t('app:simple_tool_tips'),
status: 'warning'
});
}
// 判断是否可以直接添加工具,满足以下任一条件:
// 1. 有工具描述
// 2. 是模型选择类型
// 3. 是文件上传类型且:已开启文件上传、非必填、只有一个文件上传输入
const hasInputForm =
res.inputs.length > 0 &&
res.inputs.some((input) => {
if (input.toolDescription) {
return false;
}
if (input.key === NodeInputKeyEnum.forbidStream) {
return false;
}
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.input)) {
return true;
}
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.textarea)) {
return true;
}
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.numberInput)) {
return true;
}
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.switch)) {
return true;
}
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.select)) {
return true;
}
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.JSONEditor)) {
return true;
}
return false;
});
// 构建默认表单数据
const defaultForm = {
...res,
inputs: res.inputs.map((input) => {
// 如果是模型选择类型,使用当前选中的模型
// if (input.renderTypeList.includes(FlowNodeInputTypeEnum.selectLLMModel)) {
// return {
// ...input,
// value: selectedModel.model
// };
// }
// 如果是文件上传类型,设置为从工作流开始节点获取用户文件
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect)) {
return {
...input,
value: [[workflowStartNodeId, NodeOutputKeyEnum.userFiles]]
};
}
return input;
})
};
if (hasInputForm) {
setConfigTool(defaultForm);
} else {
onAddTool(defaultForm);
}
},
{
errorToast: t('common:core.module.templates.Load plugin error')
}
);
const { data: pluginGroups = [] } = useRequest2(getPluginGroups, {
manual: false
});
const formatTemplatesArray = useMemo(() => {
const data = (() => {
if (type === TemplateTypeEnum.systemPlugin) {
return pluginGroups.map((group) => {
const copy: NodeTemplateListType = group.groupTypes.map((type) => ({
list: [],
type: type.typeId,
label: type.typeName
}));
templates.forEach((item) => {
const index = copy.findIndex((template) => template.type === item.templateType);
if (index === -1) return;
copy[index].list.push(item);
});
return {
label: group.groupName,
list: copy.filter((item) => item.list.length > 0)
};
});
}
return [
{
list: [
{
list: templates,
type: '',
label: ''
}
],
label: ''
}
];
})();
return data.filter(({ list }) => list.length > 0);
}, [pluginGroups, templates, type]);
const gridStyle = useMemo(() => {
if (type === TemplateTypeEnum.teamPlugin) {
return {
gridTemplateColumns: ['1fr', '1fr'],
py: 2,
avatarSize: '2rem'
};
}
return {
gridTemplateColumns: ['1fr', '1fr 1fr'],
py: 3,
avatarSize: '1.75rem'
};
}, [type]);
const PluginListRender = useMemoizedFn(({ list = [] }: { list: NodeTemplateListType }) => {
return (
<>
{list.map((item, i) => {
return (
<Box
key={item.type}
css={css({
span: {
display: 'block'
}
})}
>
<Flex>
<Box fontSize={'sm'} my={2} fontWeight={'500'} flex={1} color={'myGray.900'}>
{t(item.label as any)}
</Box>
</Flex>
<Grid gridTemplateColumns={gridStyle.gridTemplateColumns} rowGap={2} columnGap={3}>
{item.list.map((template) => {
const selected = selectedTools.some((tool) => tool.pluginId === template.id);
return (
<MyTooltip
key={template.id}
placement={'right'}
label={
<Box py={2}>
<Flex alignItems={'center'}>
<MyAvatar
src={template.avatar}
w={'1.75rem'}
objectFit={'contain'}
borderRadius={'sm'}
/>
<Box fontWeight={'bold'} ml={3} color={'myGray.900'}>
{t(template.name as any)}
</Box>
</Flex>
<Box mt={2} color={'myGray.500'} maxH={'100px'} overflow={'hidden'}>
{t(template.intro as any) || t('common:core.workflow.Not intro')}
</Box>
{type === TemplateTypeEnum.systemPlugin && (
<CostTooltip
cost={template.currentCost}
hasTokenFee={template.hasTokenFee}
/>
)}
</Box>
}
>
<Flex
alignItems={'center'}
py={gridStyle.py}
px={3}
_hover={{ bg: 'myWhite.600' }}
borderRadius={'sm'}
whiteSpace={'nowrap'}
overflow={'hidden'}
textOverflow={'ellipsis'}
>
<MyAvatar
src={template.avatar}
w={gridStyle.avatarSize}
objectFit={'contain'}
borderRadius={'sm'}
flexShrink={0}
/>
<Box
color={'myGray.900'}
fontWeight={'500'}
fontSize={'sm'}
flex={'1 0 0'}
ml={3}
className="textEllipsis"
>
{t(template.name as any)}
</Box>
{selected ? (
<Button
size={'sm'}
variant={'grayDanger'}
leftIcon={<MyIcon name={'delete'} w={'16px'} mr={-1} />}
onClick={() => onRemoveTool(template)}
px={2}
fontSize={'mini'}
>
{t('common:Remove')}
</Button>
) : template.flowNodeType === 'toolSet' ? (
<Flex gap={2}>
<Button
size={'sm'}
variant={'whiteBase'}
isLoading={isLoading}
leftIcon={<MyIcon name={'common/arrowRight'} w={'16px'} mr={-1.5} />}
onClick={() => setParentId(template.id)}
px={2}
fontSize={'mini'}
>
{t('common:Open')}
</Button>
<Button
size={'sm'}
variant={'primaryOutline'}
leftIcon={<MyIcon name={'common/addLight'} w={'16px'} mr={-1.5} />}
isLoading={isLoading}
onClick={() => onClickAdd(template)}
px={2}
fontSize={'mini'}
>
{t('common:Add')}
</Button>
</Flex>
) : template.isFolder ? (
<Button
size={'sm'}
variant={'whiteBase'}
leftIcon={<MyIcon name={'common/arrowRight'} w={'16px'} mr={-1.5} />}
onClick={() => setParentId(template.id)}
px={2}
fontSize={'mini'}
>
{t('common:Open')}
</Button>
) : (
<Button
size={'sm'}
variant={'primaryOutline'}
leftIcon={<MyIcon name={'common/addLight'} w={'16px'} mr={-1.5} />}
isLoading={isLoading}
onClick={() => onClickAdd(template)}
px={2}
fontSize={'mini'}
>
{t('common:Add')}
</Button>
)}
</Flex>
</MyTooltip>
);
})}
</Grid>
</Box>
);
})}
</>
);
});
return templates.length === 0 ? (
<EmptyTip text={t('app:module.No Modules')} />
) : (
<Box flex={'1 0 0'} overflow={'overlay'}>
<Accordion defaultIndex={[0]} allowMultiple reduceMotion>
{formatTemplatesArray.length > 1 ? (
<>
{formatTemplatesArray.map(({ list, label }, index) => (
<AccordionItem key={index} border={'none'}>
<AccordionButton
fontSize={'sm'}
fontWeight={'500'}
color={'myGray.900'}
justifyContent={'space-between'}
alignItems={'center'}
borderRadius={'md'}
px={3}
>
{t(label as any)}
<AccordionIcon />
</AccordionButton>
<AccordionPanel py={0}>
<PluginListRender list={list} />
</AccordionPanel>
</AccordionItem>
))}
</>
) : (
<PluginListRender list={formatTemplatesArray?.[0]?.list} />
)}
</Accordion>
{!!configTool && (
<ConfigToolModal
configTool={configTool}
onCloseConfigTool={onCloseConfigTool}
onAddTool={onAddTool}
/>
)}
</Box>
);
});

View File

@@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { appWorkflow2Form, getDefaultAppForm } from '@fastgpt/global/core/app/utils';
import Header from './Header';
import Edit from './Edit';
import { useContextSelector } from 'use-context-selector';
import { AppContext, TabEnum } from '../context';
import dynamic from 'next/dynamic';
import { Box, Flex } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import type { SimpleAppSnapshotType } from './useSnapshots';
import { useSimpleAppSnapshots } from './useSnapshots';
import { useDebounceEffect, useMount } from 'ahooks';
import { v1Workflow2V2 } from '@/web/core/workflow/adapt';
import { getAppConfigByDiff } from '@/web/core/app/diff';
const Logs = dynamic(() => import('../Logs/index'));
const PublishChannel = dynamic(() => import('../Publish'));
const SimpleEdit = () => {
const { t } = useTranslation();
const { currentTab, appDetail } = useContextSelector(AppContext, (v) => v);
const { forbiddenSaveSnapshot, past, setPast, saveSnapshot } = useSimpleAppSnapshots(
appDetail._id
);
const [appForm, setAppForm] = useState(getDefaultAppForm());
// Init app form
useMount(() => {
if (appDetail.version !== 'v2') {
return setAppForm(
appWorkflow2Form({
nodes: v1Workflow2V2((appDetail.modules || []) as any)?.nodes,
chatConfig: appDetail.chatConfig
})
);
}
// 读取旧的存储记录
const pastSnapshot = (() => {
try {
const pastSnapshot = localStorage.getItem(`${appDetail._id}-past`);
return pastSnapshot ? (JSON.parse(pastSnapshot) as SimpleAppSnapshotType[]) : [];
} catch (error) {
return [];
}
})();
const defaultState = pastSnapshot?.[pastSnapshot.length - 1]?.state;
if (pastSnapshot?.[0]?.diff && defaultState) {
setPast(
pastSnapshot
.map((item) => {
if (!item.state && !item.diff) return;
if (!item.diff) {
return {
title: t('app:initial_form'),
isSaved: true,
appForm: defaultState
};
}
const currentState = getAppConfigByDiff(defaultState, item.diff);
return {
title: item.title,
isSaved: item.isSaved,
appForm: currentState
};
})
.filter(Boolean) as SimpleAppSnapshotType[]
);
const pastState = getAppConfigByDiff(defaultState, pastSnapshot[0].diff);
localStorage.removeItem(`${appDetail._id}-past`);
return setAppForm(pastState);
}
// 无旧的记录,正常初始化
if (past.length === 0) {
const appForm = appWorkflow2Form({
nodes: appDetail.modules,
chatConfig: appDetail.chatConfig
});
saveSnapshot({
appForm,
title: t('app:initial_form'),
isSaved: true
});
setAppForm(appForm);
} else {
setAppForm(past[0].appForm);
}
});
// Save snapshot to local
useDebounceEffect(
() => {
saveSnapshot({
appForm
});
},
[appForm],
{ wait: 500 }
);
return (
<Flex h={'100%'} flexDirection={'column'} px={[3, 0]} pr={[3, 3]}>
<Header
appForm={appForm}
forbiddenSaveSnapshot={forbiddenSaveSnapshot}
setAppForm={setAppForm}
past={past}
setPast={setPast}
saveSnapshot={saveSnapshot}
/>
{currentTab === TabEnum.appEdit ? (
<Edit appForm={appForm} setAppForm={setAppForm} setPast={setPast} />
) : (
<Box flex={'1 0 0'} h={0} mt={[4, 0]} mb={[2, 4]}>
{currentTab === TabEnum.publish && <PublishChannel />}
{currentTab === TabEnum.logs && <Logs />}
</Box>
)}
</Flex>
);
};
export default React.memo(SimpleEdit);

View File

@@ -0,0 +1,10 @@
.EditAppBox {
&::-webkit-scrollbar-thumb {
background: #dfe2ea !important;
transition: background 1s;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--chakra-colors-gray-300) !important;
}
}

View File

@@ -0,0 +1,99 @@
import { useMemoizedFn } from 'ahooks';
import { useRef, useState } from 'react';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
import type { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import { isEqual } from 'lodash';
export type SimpleAppSnapshotType = {
title: string;
isSaved?: boolean;
appForm: AppSimpleEditFormType;
// abandon
state?: AppSimpleEditFormType;
diff?: Record<string, any>;
};
export type onSaveSnapshotFnType = (props: {
appForm: AppSimpleEditFormType; // Current edited app form data
title?: string;
isSaved?: boolean;
}) => Promise<boolean>;
export const compareSimpleAppSnapshot = (
appForm1?: AppSimpleEditFormType,
appForm2?: AppSimpleEditFormType
) => {
if (
appForm1?.chatConfig &&
appForm2?.chatConfig &&
!isEqual(
{
welcomeText: appForm1.chatConfig?.welcomeText || '',
variables: appForm1.chatConfig?.variables || [],
questionGuide: appForm1.chatConfig?.questionGuide || undefined,
ttsConfig: appForm1.chatConfig?.ttsConfig || undefined,
whisperConfig: appForm1.chatConfig?.whisperConfig || undefined,
chatInputGuide: appForm1.chatConfig?.chatInputGuide || undefined,
fileSelectConfig: appForm1.chatConfig?.fileSelectConfig || undefined
},
{
welcomeText: appForm2.chatConfig?.welcomeText || '',
variables: appForm2.chatConfig?.variables || [],
questionGuide: appForm2.chatConfig?.questionGuide || undefined,
ttsConfig: appForm2.chatConfig?.ttsConfig || undefined,
whisperConfig: appForm2.chatConfig?.whisperConfig || undefined,
chatInputGuide: appForm2.chatConfig?.chatInputGuide || undefined,
fileSelectConfig: appForm2.chatConfig?.fileSelectConfig || undefined
}
)
) {
console.log('chatConfig not equal');
return false;
}
return isEqual({ ...appForm1, chatConfig: undefined }, { ...appForm2, chatConfig: undefined });
};
export const useSimpleAppSnapshots = (appId: string) => {
const forbiddenSaveSnapshot = useRef(false);
const [past, setPast] = useState<SimpleAppSnapshotType[]>([]);
const saveSnapshot: onSaveSnapshotFnType = useMemoizedFn(async ({ appForm, title, isSaved }) => {
if (forbiddenSaveSnapshot.current) {
forbiddenSaveSnapshot.current = false;
return false;
}
if (past.length === 0) {
setPast([
{
title: title || formatTime2YMDHMS(new Date()),
isSaved,
appForm
}
]);
return true;
}
const pastState = past[0];
const isPastEqual = compareSimpleAppSnapshot(pastState?.appForm, appForm);
if (isPastEqual) return false;
setPast((past) => [
{
appForm,
title: title || formatTime2YMDHMS(new Date()),
isSaved
},
...past.slice(0, 99)
]);
return true;
});
return { forbiddenSaveSnapshot, past, setPast, saveSnapshot };
};
export default function Snapshots() {
return <></>;
}

View File

@@ -0,0 +1,187 @@
import { useUserStore } from '@/web/support/user/useUserStore';
import React, { useCallback, useEffect, useMemo } from 'react';
import type { StartChatFnProps } from '@/components/core/chat/ChatContainer/type';
import { streamFetch } from '@/web/common/api/fetch';
import { useMemoizedFn } from 'ahooks';
import { useContextSelector } from 'use-context-selector';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import type { StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import dynamic from 'next/dynamic';
import { Box } from '@chakra-ui/react';
import type { AppChatConfigType, AppDetailType } from '@fastgpt/global/core/app/type';
import ChatBox from '@/components/core/chat/ChatContainer/ChatBox';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { getInitChatInfo } from '@/web/core/chat/api';
import { useTranslation } from 'next-i18next';
import { ChatContext } from '@/web/core/chat/context/chatContext';
import { getChatTitleFromChatMessage } from '@fastgpt/global/core/chat/utils';
import { GPTMessages2Chats } from '@fastgpt/global/core/chat/adapt';
const PluginRunBox = dynamic(() => import('@/components/core/chat/ChatContainer/PluginRunBox'));
export const useChatGate = ({
selectedToolIds,
onSelectedToolIdsChange,
nodes,
edges,
chatConfig,
isReady,
appDetail
}: {
selectedToolIds?: string[];
onSelectedToolIdsChange?: (toolIds: string[]) => void;
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
chatConfig: AppChatConfigType;
isReady: boolean;
appDetail: AppDetailType;
}) => {
const { t } = useTranslation();
const { userInfo } = useUserStore();
const { setChatId, chatId, appId } = useChatStore();
const onUpdateHistoryTitle = useContextSelector(ChatContext, (v) => v.onUpdateHistoryTitle);
const startChat = useMemoizedFn(
async ({
messages,
responseChatItemId,
controller,
generatingMessage,
variables
}: StartChatFnProps) => {
const histories = messages.slice(-1);
// 流请求,获取数据
const { responseText } = await streamFetch({
url: '/api/core/chat/chatGate',
data: {
// Send histories and user messages
messages: histories,
nodes,
edges,
variables,
responseChatItemId,
appId,
appName: t('chat:chat_gate_app', { name: appDetail.name }),
chatId,
chatConfig,
metadata: {
source: 'web',
userAgent: navigator.userAgent
},
selectedToolIds: selectedToolIds || []
},
onMessage: generatingMessage,
abortCtrl: controller
});
// 更新聊天标题
const newTitle = getChatTitleFromChatMessage(GPTMessages2Chats(histories)[0]);
// 更新历史标题
onUpdateHistoryTitle?.({ chatId, newTitle });
// 更新聊天窗口标题
setChatBoxData((state) => ({
...state,
title: newTitle
}));
return { responseText };
}
);
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables);
const clearChatRecords = useContextSelector(ChatItemContext, (v) => v.clearChatRecords);
const pluginInputs = useMemo(() => {
return nodes.find((node) => node.flowNodeType === FlowNodeTypeEnum.pluginInput)?.inputs || [];
}, [nodes]);
// Set chat box data
useEffect(() => {
setChatBoxData({
userAvatar: userInfo?.avatar,
appId: appId,
app: {
chatConfig,
name: appDetail.name,
avatar: appDetail.avatar,
intro: appDetail.intro,
type: appDetail.type,
pluginInputs
}
});
}, [
appDetail.avatar,
appDetail.intro,
appDetail.name,
appDetail.type,
appId,
chatConfig,
pluginInputs,
setChatBoxData,
userInfo?.avatar
]);
// init chat data
const { loading } = useRequest2(
async () => {
if (!appId || !chatId) return;
const res = await getInitChatInfo({ appId, chatId });
resetVariables({
variables: res.variables,
variableList: res.app?.chatConfig?.variables
});
},
{
manual: false,
refreshDeps: [appId, chatId]
}
);
const restartChat = useCallback(() => {
clearChatRecords();
setChatId();
}, [clearChatRecords, setChatId]);
const CustomChatContainer = useMemoizedFn(() =>
appDetail.type === AppTypeEnum.plugin ? (
<Box p={5} pb={16}>
<PluginRunBox
appId={appId}
chatId={chatId}
onNewChat={restartChat}
onStartChat={startChat}
/>
</Box>
) : (
<ChatBox
isReady={isReady}
appId={appId}
chatId={chatId}
showMarkIcon
chatType={'chat'}
onStartChat={startChat}
selectedToolIds={selectedToolIds}
onSelectedToolIdsChange={onSelectedToolIdsChange}
/>
)
);
return {
ChatContainer: CustomChatContainer,
restartChat,
loading
};
};
export default function Dom() {
return <></>;
}

View File

@@ -19,6 +19,9 @@ const ToolMenu = ({ history }: { history: ChatItemType[] }) => {
const chatData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const showRouteToAppDetail = useContextSelector(ChatItemContext, (v) => v.showRouteToAppDetail);
// 检查当前路由是否以/chat/gate开头如果是则禁止显示应用详情
const isGateRoute = router.pathname.startsWith('/chat/gate');
return (
<MyMenu
Button={
@@ -60,7 +63,7 @@ const ToolMenu = ({ history }: { history: ChatItemType[] }) => {
// }
]
},
...(showRouteToAppDetail
...(showRouteToAppDetail && !isGateRoute
? [
{
children: [

View File

@@ -0,0 +1,241 @@
import { Box, Flex, Text, Tooltip, Button } from '@chakra-ui/react';
import type { AppListItemType } from '@fastgpt/global/core/app/type.d';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'next-i18next';
type Props = {
app: AppListItemType;
selectedId?: string;
tagMap?: Map<string, any>;
};
const MAX_VISIBLE_TAGS = 2;
const AppCard = ({ app, selectedId, tagMap }: Props) => {
const router = useRouter();
const { t } = useTranslation();
const tags = app.tags || [];
const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS);
const remainingCount = Math.max(0, tags.length - MAX_VISIBLE_TAGS);
const renderTags = (showAll = false) => {
const tagsToShow = showAll ? tags : visibleTags;
return (
<Flex gap="4px" alignItems="center">
{tagsToShow.map((tagId) => {
const tag = tagMap?.get(tagId);
if (!tag) return null;
return (
<Flex
key={tagId}
justifyContent="center"
alignItems="center"
padding="10px 8px"
height="22px"
bg="#F4F4F5"
borderRadius="6px"
minW="fit-content"
>
<Text
fontSize="12px"
fontWeight="500"
lineHeight="16px"
color="#525252"
whiteSpace="nowrap"
>
{tag.name}
</Text>
</Flex>
);
})}
{!showAll && remainingCount > 0 && (
<Tooltip
label={
<Flex gap="4px" maxW="300px" p={2} flexWrap="wrap">
{tags.slice(MAX_VISIBLE_TAGS).map((tagId) => {
const tag = tagMap?.get(tagId);
if (!tag) return null;
return (
<Flex
key={tagId}
justifyContent="center"
alignItems="center"
padding="10px 8px"
height="22px"
bg="#F4F4F5"
borderRadius="6px"
minW="fit-content"
>
<Text
fontSize="12px"
fontWeight="500"
lineHeight="16px"
color="#525252"
whiteSpace="nowrap"
>
{tag.name}
</Text>
</Flex>
);
})}
</Flex>
}
hasArrow
placement="top"
bg="white"
color="inherit"
p={0}
boxShadow="lg"
>
<Flex
justifyContent="center"
alignItems="center"
padding="10px 8px"
height="22px"
bg="#F4F4F5"
borderRadius="6px"
minW="fit-content"
>
<Text fontSize="12px" fontWeight="500" lineHeight="16px" color="#525252">
+{remainingCount}
</Text>
</Flex>
</Tooltip>
)}
</Flex>
);
};
return (
<Flex
position="relative"
flexDirection="column"
justifyContent="space-between"
alignItems="flex-start"
padding="20px 20px 16px"
width="370px"
height="150px"
cursor="pointer"
borderRadius="12px"
border="1px solid"
borderColor={selectedId === app._id ? 'blue.500' : '#E8EBF0'}
bg="#FFFFFF"
boxShadow="0px 4px 4px rgba(19, 51, 107, 0.05), 0px 0px 1px rgba(19, 51, 107, 0.08)"
_hover={{
transform: 'translateY(-2px)',
transition: 'all 0.2s ease-in-out'
}}
onClick={(e) => {
// 防止按钮点击事件冒泡
if ((e.target as HTMLElement).tagName !== 'BUTTON') {
router.push(`/chat/gate/application?appId=${app._id}`);
}
}}
>
{/* 头部区域 */}
<Flex alignItems="flex-start" gap="12px" width="330px" height="44px" alignSelf="stretch">
{/* 图标 */}
<Box
width="32px"
height="32px"
borderRadius="4px"
overflow="hidden"
bg="blue.50"
flexShrink={0}
>
{app.avatar ? (
<Avatar src={app.avatar} w="100%" h="100%" />
) : (
<Flex
w="100%"
h="100%"
alignItems="center"
justifyContent="center"
fontSize="20px"
fontWeight="bold"
color="blue.500"
>
{app.name[0]?.toUpperCase()}
</Flex>
)}
</Box>
{/* 文本信息 */}
<Flex
flexDirection="column"
alignItems="flex-start"
gap="4px"
width="286px"
height="44px"
flex={1}
>
<Text
width="100%"
height="24px"
fontFamily="PingFang SC"
fontWeight="500"
fontSize="16px"
lineHeight="24px"
letterSpacing="0.15px"
color={selectedId === app._id ? 'blue.500' : '#111824'}
noOfLines={1}
alignSelf="stretch"
>
{app.name}
</Text>
<Text
width="273px"
height="16px"
fontFamily="PingFang SC"
fontWeight="400"
fontSize="12px"
lineHeight="16px"
letterSpacing="0.004em"
color="#667085"
noOfLines={1}
>
{app.intro || '-'}
</Text>
</Flex>
</Flex>
{/* 底部标签区域 */}
<Flex justifyContent="space-between" alignItems="center" width="100%" height="22px">
{/* 标签容器 */}
<Flex justifyContent="flex-start" alignItems="center" gap="4px" height="22px" flex={1}>
{renderTags()}
</Flex>
{/* 试用按钮 */}
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
router.push(`/chat/gate/application?appId=${app._id}`);
}}
px={0}
py={0}
height="auto"
minW="unset"
bg="transparent"
_hover={{ bg: 'transparent', textDecoration: 'underline' }}
_active={{ bg: 'transparent' }}
_focus={{ boxShadow: 'none' }}
fontFamily="PingFang SC"
fontSize="12px"
fontWeight="400"
lineHeight="16px"
letterSpacing="0.048px"
color="#8A95A7"
>
{t('common:have_a_try')}
</Button>
</Flex>
</Flex>
);
};
export default AppCard;

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { Flex } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
interface Props {
isFolded: boolean;
onClick: () => void;
position?: 'sidebar' | 'navbar';
}
const FoldButton = ({ isFolded, onClick, position = 'sidebar' }: Props) => {
return (
<Flex
position={position === 'sidebar' ? 'absolute' : 'relative'}
right={position === 'sidebar' ? 0 : 'auto'}
top={position === 'sidebar' ? '50%' : 'auto'}
transform={position === 'sidebar' ? 'translate(50%,-50%)' : 'none'}
display={'flex'}
width={'16px'}
height={'80px'}
justifyContent={'center'}
alignItems={'center'}
gap={'4px'}
flexShrink={0}
borderRadius={'999px'}
bg={'var(--Gray-Modern-50, #F7F8FA)'}
boxShadow={
'0px 4px 10px 0px rgba(19, 51, 107, 0.10), 0px 0px 1px 0px rgba(19, 51, 107, 0.10)'
}
cursor={'pointer'}
transition={'0.2s'}
zIndex={100}
opacity={position === 'navbar' ? 1 : isFolded ? 0.8 : 0}
visibility={position === 'navbar' ? 'visible' : isFolded ? 'visible' : 'hidden'}
onClick={onClick}
_hover={{
opacity: 1
}}
>
<MyIcon
name={'support/gate/chat/historySlider/chevron-left2'}
transform={position === 'navbar' ? 'rotate(180deg)' : isFolded ? 'rotate(180deg)' : ''}
w={'14px'}
color={'black'}
/>
</Flex>
);
};
export default FoldButton;

View File

@@ -0,0 +1,300 @@
import React, { useMemo } from 'react';
import { Box, Button, Flex, useTheme, IconButton, Text } from '@chakra-ui/react';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { useEditTitle } from '@/web/common/hooks/useEditTitle';
import { useRouter } from 'next/router';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useTranslation } from 'next-i18next';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import { useUserStore } from '@/web/support/user/useUserStore';
import MyMenu from '@fastgpt/web/components/common/MyMenu';
import { useContextSelector } from 'use-context-selector';
import { ChatContext } from '@/web/core/chat/context/chatContext';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { formatTimeToChatTime } from '@fastgpt/global/common/string/time';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
type HistoryItemType = {
id: string;
title: string;
customTitle?: string;
top?: boolean;
updateTime: Date;
};
const GateChatHistorySlider = ({ confirmClearText }: { confirmClearText: string }) => {
const theme = useTheme();
const router = useRouter();
const { t } = useTranslation();
const { isPc } = useSystem();
const { userInfo } = useUserStore();
const { appId, chatId: activeChatId } = useChatStore();
const onChangeChatId = useContextSelector(ChatContext, (v) => v.onChangeChatId);
const isLoading = useContextSelector(ChatContext, (v) => v.isLoading);
const ScrollData = useContextSelector(ChatContext, (v) => v.ScrollData);
const histories = useContextSelector(ChatContext, (v) => v.histories);
const onDelHistory = useContextSelector(ChatContext, (v) => v.onDelHistory);
const onClearHistory = useContextSelector(ChatContext, (v) => v.onClearHistories);
const onUpdateHistory = useContextSelector(ChatContext, (v) => v.onUpdateHistory);
const appName = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.app.name);
const appAvatar = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.app.avatar);
const showRouteToAppDetail = useContextSelector(ChatItemContext, (v) => v.showRouteToAppDetail);
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
const concatHistory = useMemo(() => {
const formatHistories: HistoryItemType[] = histories.map((item) => {
return {
id: item.chatId,
title: item.title,
customTitle: item.customTitle,
top: item.top,
updateTime: item.updateTime
};
});
const newChat: HistoryItemType = {
id: activeChatId,
title: t('common:core.chat.New Chat'),
updateTime: new Date()
};
const activeChat = histories.find((item) => item.chatId === activeChatId);
return !activeChat ? [newChat].concat(formatHistories) : formatHistories;
}, [activeChatId, histories, t]);
// custom title edit
const { onOpenModal, EditModal: EditTitleModal } = useEditTitle({
title: t('common:core.chat.Custom History Title'),
placeholder: t('common:core.chat.Custom History Title Description')
});
const { openConfirm, ConfirmModal } = useConfirm({
content: confirmClearText
});
const canRouteToDetail = useMemo(
() => appId && userInfo?.team.permission.hasWritePer && showRouteToAppDetail,
[appId, userInfo?.team.permission.hasWritePer, showRouteToAppDetail]
);
return (
<MyBox
display={'flex'}
flexDirection={'column'}
w={'100%'}
h={'100%'}
bg={'white'}
borderRight={['', theme.borders.base]}
whiteSpace={'nowrap'}
>
{/* menu */}
<Flex w={'100%'} px={'16px'} pt={'16px'} h={'auto'} mb={5} flexDirection={'column'}>
{/* Title */}
<Text
display="flex"
alignItems="center"
pl="8px"
fontWeight="semibold"
fontSize="lg"
mb={4}
>
{t('common:navbar.Chat')}
</Text>
<Flex w={'100%'} h={'36px'} justify={['space-between', '']} alignItems={'center'}>
{!isPc && (
<Flex height={'100%'} align={'center'} justify={'center'}>
<MyIcon ml={2} name="core/chat/sideLine" />
<Box ml={2} fontWeight={'bold'}>
{t('common:core.chat.History')}
</Box>
</Flex>
)}
<Button
variant={'whitePrimary'}
flex={['0 0 auto', 1]}
h={'100%'}
px={6}
color={'primary.600'}
borderRadius={'xl'}
leftIcon={<MyIcon name={'support/gate/chat/historySlider/new_chat'} />}
overflow={'hidden'}
onClick={() => {
onChangeChatId();
setCiteModalData(undefined);
}}
>
{t('common:core.chat.New Chat')}
</Button>
{/* Clear */}
{isPc && histories.length > 0 && (
<IconButton
ml={3}
h={'100%'}
variant={'whiteDanger'}
size={'mdSquare'}
aria-label={''}
borderRadius={'50%'}
icon={<MyIcon name={'support/gate/chat/historySlider/clear-all'} />}
onClick={() =>
openConfirm(() => {
onClearHistory();
})()
}
/>
)}
</Flex>
</Flex>
<ScrollData flex={'1 0 0'} h={0} px={'16px'} overflow={'overlay'}>
{/* chat history */}
<>
{concatHistory.map((item, i) => (
<Flex
position={'relative'}
key={item.id}
alignItems={'center'}
justifyContent={'space-between'}
px={'8px'}
py={'9px'}
h={'40px'}
cursor={'pointer'}
userSelect={'none'}
borderRadius={'md'}
fontSize={'sm'}
_hover={{
bg: 'myGray.50',
'& .more': {
display: 'block'
},
'& .time': {
display: isPc ? 'none' : 'block'
}
}}
bg={item.top ? '#E6F6F6 !important' : ''}
{...(item.id === activeChatId
? {
backgroundColor: 'primary.50 !important',
color: 'primary.600'
}
: {
onClick: () => {
onChangeChatId(item.id);
setCiteModalData(undefined);
}
})}
{...(i !== concatHistory.length - 1 && {
mb: '4px'
})}
>
<Box flex={'1 0 0'} className="textEllipsis">
{item.customTitle || item.title}
</Box>
{!!item.id && (
<Flex gap={2} alignItems={'center'}>
<Box
className="time"
display={'block'}
fontWeight={'400'}
fontSize={'mini'}
color={'myGray.500'}
>
{t(formatTimeToChatTime(item.updateTime) as any).replace('#', ':')}
</Box>
<Box className="more" display={['block', 'none']}>
<MyMenu
Button={
<IconButton
size={'xs'}
variant={'whiteBase'}
icon={<MyIcon name={'more'} w={'14px'} p={1} />}
aria-label={''}
/>
}
menuList={[
{
children: [
{
label: item.top
? t('common:core.chat.Unpin')
: t('common:core.chat.Pin'),
icon: 'core/chat/setTopLight',
onClick: () => {
onUpdateHistory({
chatId: item.id,
top: !item.top
});
}
},
{
label: t('common:custom_title'),
icon: 'common/customTitleLight',
onClick: () => {
onOpenModal({
defaultVal: item.customTitle || item.title,
onSuccess: (e) =>
onUpdateHistory({
chatId: item.id,
customTitle: e
})
});
}
},
{
label: t('common:Delete'),
icon: 'delete',
onClick: () => {
onDelHistory(item.id);
if (item.id === activeChatId) {
onChangeChatId();
setCiteModalData(undefined);
}
},
type: 'danger'
}
]
}
]}
/>
</Box>
</Flex>
)}
</Flex>
))}
</>
</ScrollData>
{/* exec */}
{!isPc && !!canRouteToDetail && (
<Flex
mt={2}
borderTop={theme.borders.base}
alignItems={'center'}
cursor={'pointer'}
p={3}
onClick={() => router.push('/dashboard/apps')}
>
<IconButton
mr={3}
icon={<MyIcon name={'common/backFill'} w={'18px'} color={'primary.500'} />}
bg={'white'}
boxShadow={'1px 1px 9px rgba(0,0,0,0.15)'}
size={'smSquare'}
borderRadius={'50%'}
aria-label={''}
/>
{t('common:core.chat.Exit Chat')}
</Flex>
)}
<EditTitleModal />
<ConfirmModal />
</MyBox>
);
};
export default GateChatHistorySlider;

View File

@@ -0,0 +1,664 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Box, Flex, Text, HStack } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { useUserStore } from '@/web/support/user/useUserStore';
import { HUMAN_ICON } from '@fastgpt/global/common/system/constants';
import MyIcon from '@fastgpt/web/components/common/Icon';
import type { AppListItemType } from '@fastgpt/global/core/app/type';
import { useRouter } from 'next/router';
import MyPopover from '@fastgpt/web/components/common/MyPopover/index';
import dynamic from 'next/dynamic';
import { getMyApps } from '@/web/core/app/api';
import type {
GetResourceFolderListProps,
GetResourceListItemResponse
} from '@fastgpt/global/common/parentFolder/type';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import type { getGateConfigCopyRightResponse } from '@fastgpt/global/support/user/team/gate/api';
import { getTeamGateConfigCopyRight } from '@/web/support/user/team/gate/api';
import type { GateSchemaType } from '@fastgpt/global/support/user/team/gate/type';
const SelectOneResource = dynamic(() => import('@/components/common/folder/SelectOneResource'));
type Props = {
apps?: AppListItemType[];
activeAppId?: string;
gateConfig?: GateSchemaType;
};
const GateNavBar = ({ apps, activeAppId, gateConfig }: Props) => {
const { t } = useTranslation();
const router = useRouter();
const { userInfo, setUserInfo } = useUserStore();
const [copyRightConfig, setCopyRightConfig] = useState<getGateConfigCopyRightResponse | null>(
null
);
// 加载 gateConfig
useEffect(() => {
const loadConfig = async () => {
try {
const config = await getTeamGateConfigCopyRight();
setCopyRightConfig(config);
} catch (error) {
console.error('Failed to load gate config:', error);
}
};
loadConfig();
}, []);
const [isCollapsed, setIsCollapsed] = useState(false);
const companyNameRef = useRef<HTMLSpanElement>(null);
const [companyNameScale, setCompanyNameScale] = useState(1);
const [showUserPopover, setShowUserPopover] = useState(false);
const [userPopoverVisibility, setUserPopoverVisibility] = useState(false);
const userPopoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isChatPage = router.pathname === '/chat/gate';
const isStorePage = router.pathname === '/chat/gate/store';
useEffect(() => {
if (companyNameRef.current && !isCollapsed) {
const containerWidth = 130;
const scale = Math.min(1, containerWidth / (companyNameRef.current.offsetWidth + 5));
setCompanyNameScale(scale);
}
}, [copyRightConfig?.name, isCollapsed]);
const handleLogout = useCallback(() => {
setUserInfo(null);
router.replace('/login');
}, [router, setUserInfo]);
const handleUserPopoverEnter = () => {
if (userPopoverTimeoutRef.current) {
clearTimeout(userPopoverTimeoutRef.current);
userPopoverTimeoutRef.current = null;
}
setShowUserPopover(true);
setUserPopoverVisibility(true);
};
const handleUserPopoverLeave = () => {
setShowUserPopover(false); // 先触发淡出动画
userPopoverTimeoutRef.current = setTimeout(() => {
setUserPopoverVisibility(false); // 动画完成后才真正隐藏元素
}, 300); // 与动画时长相同
};
return (
<Flex
w={isCollapsed ? '64px' : '15%'}
minW={isCollapsed ? '64px' : '226px'}
maxW={isCollapsed ? '64px' : '226px'}
h="100%"
bg="#F4F4F7"
direction="column"
justify="space-between"
p={isCollapsed ? '24px 12px 12px 12px' : '24px 12px 12px 12px'}
transition="all 0.4s ease-in-out"
zIndex={1}
>
{/* Logo and Navigation Items */}
<Flex
direction="column"
align={isCollapsed ? 'center' : 'flex-start'}
gap={3}
w="100%"
transition="all 0.4s ease-in-out"
>
<Box
w={isCollapsed ? 'auto' : 'auto'}
h={isCollapsed ? 'auto' : 'auto'}
display="flex"
position="relative"
transition="all 0.4s ease-in-out"
justifyContent={isCollapsed ? 'center' : 'flex-start'}
>
{copyRightConfig?.banner ? (
// 如果有banner只显示banner宽度自适应
<Flex
align="center"
cursor="pointer"
onClick={() => setIsCollapsed(!isCollapsed)}
position="relative"
gap={3}
style={{
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)'
}}
width="100%"
>
<Box
height="36px"
width={isCollapsed ? '36px' : '100%'}
overflow="hidden"
flexShrink={0}
transition="all 0.4s ease-in-out"
display="flex"
justifyContent="center"
alignItems="center"
>
<Avatar
boxSize="100%"
src={isCollapsed ? copyRightConfig.logo : copyRightConfig.banner}
objectFit={'contain'}
/>
</Box>
</Flex>
) : (
// 如果没有banner显示logo和文字
<Flex
align="center"
cursor="pointer"
onClick={() => setIsCollapsed(!isCollapsed)}
position="relative"
gap={3}
style={{
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)'
}}
>
{copyRightConfig?.logo ? (
<Flex
boxSize="36px"
borderRadius="9px"
overflow="hidden"
flexShrink={0}
transition="all 0.4s ease-in-out"
justifyContent="center"
alignItems="center"
>
<Avatar
boxSize="100%"
src={copyRightConfig.logo}
borderRadius="9px"
objectFit="cover"
/>
</Flex>
) : (
<Box
boxSize="36px"
bg="white"
border="0.75px solid #ECECEC"
borderRadius="9px"
overflow="hidden"
flexShrink={0}
transition="all 0.4s ease-in-out"
>
<Avatar boxSize="100%" src={HUMAN_ICON} borderRadius="9px" objectFit="cover" />
</Box>
)}
<Box
opacity={isCollapsed ? 0 : 1}
maxW={isCollapsed ? 0 : '130px'}
w="130px"
transition="all 0.4s ease-in-out"
overflow="hidden"
transform="scale(1, 1)"
transformOrigin="left center"
className="company-name"
position={isCollapsed ? 'absolute' : 'relative'}
width={isCollapsed ? '0' : 'auto'}
height={isCollapsed ? '0' : 'auto'}
>
<Text
as="span"
fontSize="md"
fontWeight="bold"
color="#111824"
fontFamily="Inter"
whiteSpace="nowrap"
ref={companyNameRef}
style={{
transform: `scale(${companyNameScale})`,
display: 'inline-block',
transformOrigin: 'left'
}}
>
{copyRightConfig?.name || '没渲染出来'}
</Text>
</Box>
</Flex>
)}
</Box>
{/* Navigation Items */}
<Flex
direction="column"
w="100%"
alignItems={isCollapsed ? 'center' : 'flex-start'}
transition="all 0.2s"
>
{/* Chat Button */}
<Flex
align="center"
p="8px"
gap="8px"
w={isCollapsed ? '44px' : '100%'}
h="44px"
borderRadius="8px"
cursor="pointer"
bg={isChatPage ? 'rgba(17, 24, 36, 0.05)' : 'transparent'}
_hover={{ bg: isChatPage ? 'rgba(17, 24, 36, 0.1)' : 'rgba(17, 24, 36, 0.05)' }}
flexGrow={0}
transition="all 0.4s ease-in-out"
className="nav-item"
onClick={() => {
if (isChatPage) {
// 如果已经在聊天页面,通过更改路由参数来触发页面刷新
router.replace({
pathname: router.pathname,
query: { ...router.query, refresh: Date.now() }
});
} else {
router.push('/chat/gate');
}
}}
justifyContent={isCollapsed ? 'center' : 'flex-start'}
sx={{
'&.nav-item': {
'& > .nav-content': {
display: 'flex',
alignItems: 'center',
gap: '8px',
width: isCollapsed ? '20px' : '100%',
transition: 'all 0.4s ease-in-out'
}
}
}}
>
<Box className="nav-content">
<MyIcon
name="support/gate/chat/sidebar/chatGray"
width="20px"
height="20px"
color={isChatPage ? '#3370FF' : '#8A95A7'}
fill={isChatPage ? '#3370FF' : '#8A95A7'}
/>
<Box
opacity={isCollapsed ? 0 : 1}
maxW={isCollapsed ? 0 : '130px'}
transition="all 0.4s ease-in-out"
overflow="hidden"
>
<Text
fontSize="14px"
fontWeight="500"
lineHeight="20px"
letterSpacing="0.1px"
fontFamily="PingFang SC"
color={isChatPage ? '#3370FF' : '#667085'}
transformOrigin="left center"
whiteSpace="nowrap"
>
{t('common:navbar.Chat')}
</Text>
</Box>
</Box>
</Flex>
{/* App Store Button */}
<Flex
align="center"
p="8px"
gap="8px"
w={isCollapsed ? '44px' : '100%'}
h="44px"
borderRadius="8px"
cursor="pointer"
bg={isStorePage ? 'rgba(17, 24, 36, 0.05)' : 'transparent'}
_hover={{ bg: isStorePage ? 'rgba(17, 24, 36, 0.1)' : 'rgba(17, 24, 36, 0.05)' }}
flexGrow={0}
transition="all 0.4s ease-in-out"
className="nav-item"
onClick={() => router.push('/chat/gate/store')}
justifyContent={isCollapsed ? 'center' : 'flex-start'}
sx={{
'&.nav-item': {
'& > .nav-content': {
display: 'flex',
alignItems: 'center',
gap: '8px',
width: isCollapsed ? '20px' : '100%',
transition: 'all 0.4s ease-in-out'
}
}
}}
>
<Box className="nav-content">
<MyIcon
name="support/gate/chat/sidebar/appGray"
width="20px"
height="20px"
color={isStorePage ? '#3370FF' : '#8A95A7'}
fill={isStorePage ? '#3370FF' : '#8A95A7'}
/>
<Box
opacity={isCollapsed ? 0 : 1}
maxW={isCollapsed ? 0 : '130px'}
transition="all 0.4s ease-in-out"
overflow="hidden"
>
<Text
fontSize="14px"
fontWeight="500"
lineHeight="20px"
letterSpacing="0.1px"
fontFamily="PingFang SC"
color={isStorePage ? '#3370FF' : '#667085'}
transformOrigin="left center"
whiteSpace="nowrap"
>
{t('common:App')}
</Text>
</Box>
</Box>
</Flex>
{/* Divider */}
<Box w="100%" h="2px" bg="#E8EBF0" transition="width 0.2s" my={3} />
{/* Recent Apps - matched with SliderApps style */}
{apps && apps.length > 0 && (
<>
<HStack
px={2}
w={isCollapsed ? '44px' : '100%'}
color={'myGray.500'}
fontSize={'sm'}
justifyContent={isCollapsed ? 'center' : 'space-between'}
transition="all 0.2s"
opacity={isCollapsed ? 0 : 1}
mb={2}
sx={{
'& > .recent-title': {
opacity: isCollapsed ? 0 : 1,
transform: `scale(${isCollapsed ? 0 : 1})`,
transformOrigin: 'left center',
transition: 'all 0.2s',
whiteSpace: 'nowrap'
}
}}
>
<Box className="recent-title">{t('common:core.chat.Recent use')}</Box>
<MyPopover
placement="bottom-end"
offset={[20, 10]}
p={4}
trigger="hover"
Trigger={
<HStack
spacing={0.5}
cursor={'pointer'}
px={2}
py={'0.5'}
borderRadius={'md'}
userSelect={'none'}
opacity={isCollapsed ? 0 : 1}
transform={`scale(${isCollapsed ? 0 : 1})`}
transformOrigin="left center"
transition="all 0.2s"
_hover={{
bg: 'myGray.200'
}}
>
<Box
opacity={isCollapsed ? 0 : 1}
transform={`scale(${isCollapsed ? 0 : 1})`}
transformOrigin="left center"
transition="all 0.2s"
whiteSpace="nowrap"
>
{t('common:More')}
</Box>
<MyIcon
name={'common/select'}
w={'1rem'}
opacity={isCollapsed ? 0 : 1}
transition="all 0.2s"
/>
</HStack>
}
>
{({ onClose }) => (
<Box minH={'200px'}>
<SelectOneResource
maxH={'60vh'}
value={activeAppId}
onSelect={(id) => {
if (!id) return;
router.replace({
pathname: '/chat/gate/application',
query: {
...router.query,
appId: id
}
});
onClose();
}}
server={useCallback(async ({ parentId }: GetResourceFolderListProps) => {
return getMyApps({
parentId,
type: [
AppTypeEnum.folder,
AppTypeEnum.simple,
AppTypeEnum.workflow,
AppTypeEnum.plugin
]
}).then((res) =>
res.map<GetResourceListItemResponse>((item) => ({
id: item._id,
name: item.name,
avatar: item.avatar,
isFolder: item.type === AppTypeEnum.folder
}))
);
}, [])}
/>
</Box>
)}
</MyPopover>
</HStack>
<Box
maxH={isCollapsed ? '0' : 'calc(100vh - 300px)'}
opacity={isCollapsed ? 0 : 1}
transition="all 0.2s"
overflowY="auto"
w="100%"
px={0}
>
{apps.map((item) => (
<Flex
key={item._id}
py={2}
px={2}
mb={2}
cursor={'pointer'}
borderRadius={'md'}
alignItems={'center'}
fontSize={'sm'}
w="100%"
{...(item._id === activeAppId
? {
background: 'rgba(51, 112, 255, 0.05)',
color: '#3370FF'
}
: {
_hover: {
bg: 'rgba(17, 24, 36, 0.05)',
color: '#3370FF'
}
})}
onClick={
item._id !== activeAppId
? () =>
router.replace({
pathname: '/chat/gate/application',
query: {
...router.query,
appId: item._id
}
})
: undefined
}
>
<Avatar src={item.avatar} w={'1.5rem'} borderRadius={'md'} />
<Box
flex="1"
ml={2}
className={'textEllipsis'}
fontWeight={500}
opacity={isCollapsed ? 0 : 1}
transform={`scale(${isCollapsed ? 0 : 1})`}
transformOrigin="left center"
transition="all 0.2s"
>
{item.name}
</Box>
</Flex>
))}
</Box>
</>
)}
</Flex>
</Flex>
{/* User Profile with Popover */}
<Box
position="relative"
onMouseEnter={handleUserPopoverEnter}
onMouseLeave={handleUserPopoverLeave}
>
<Flex
align="center"
gap={2}
w="100%"
justifyContent={isCollapsed ? 'center' : 'flex-start'}
transition="all 0.2s"
cursor="pointer"
position="relative"
>
{userInfo?.avatar ? (
<Flex boxSize="36px" borderRadius="50%" overflow="hidden" flexShrink={0}>
<Avatar boxSize="100%" src={userInfo?.avatar} borderRadius="50%" objectFit="cover" />
</Flex>
) : (
<Box
boxSize="36px"
border="2px solid #fff"
borderRadius="50%"
overflow="hidden"
flexShrink={0}
>
<Avatar
boxSize="100%"
src={userInfo?.avatar || HUMAN_ICON}
borderRadius="50%"
objectFit="cover"
/>
</Box>
)}
<Box
opacity={isCollapsed ? 0 : 1}
transform={`scale(${isCollapsed ? 0 : 1})`}
transformOrigin="left center"
transition="all 0.2s"
overflow="hidden"
flex="1"
>
<Text
fontSize="xs"
fontWeight="medium"
letterSpacing="0.1px"
color="#111824"
fontFamily="PingFang SC"
className="textEllipsis"
>
{userInfo?.username || 'unauthorized'}
</Text>
</Box>
</Flex>
{/* Custom Popover */}
<Flex
position="absolute"
left={isCollapsed ? '40px' : '45px'}
bottom="0"
direction="column"
alignItems="flex-start"
width="192px"
padding="16px 16px 8px 16px"
borderRadius="10px"
bg="white"
boxShadow="0px 32px 64px -12px rgba(19, 51, 107, 0.20), 0px 0px 1px 0px rgba(19, 51, 107, 0.20)"
zIndex={10}
opacity={showUserPopover ? 1 : 0}
transform={showUserPopover ? 'translateY(0)' : 'translateY(10px)'}
transition="opacity 0.3s ease, transform 0.3s ease"
display={userPopoverVisibility ? 'flex' : 'none'}
pointerEvents={showUserPopover ? 'auto' : 'none'}
onMouseEnter={handleUserPopoverEnter}
onMouseLeave={handleUserPopoverLeave}
>
<Flex alignItems="center" gap={3} width="100%">
{userInfo?.avatar ? (
<Flex boxSize="36px" borderRadius="50%" overflow="hidden" flexShrink={0}>
<Avatar
boxSize="100%"
src={userInfo?.avatar}
borderRadius="50%"
objectFit="cover"
/>
</Flex>
) : (
<Box
boxSize="36px"
border="2px solid #fff"
borderRadius="50%"
overflow="hidden"
flexShrink={0}
>
<Avatar
boxSize="100%"
src={userInfo?.avatar || HUMAN_ICON}
borderRadius="50%"
objectFit="cover"
/>
</Box>
)}
<Box>
<Text
fontSize="sm"
fontWeight="bold"
color="#111824"
className="textEllipsis"
maxW="120px"
>
{userInfo?.username || 'unauthorized'}
</Text>
</Box>
</Flex>
{/* Divider */}
<Box w="100%" h="1px" bg="#E8EBF0" transition="width 0.2s" mt={'12px'} mb={'4px'} />
<Flex
alignItems="center"
width="100%"
cursor="pointer"
p={2}
borderRadius="md"
_hover={{ bg: 'rgba(17, 24, 36, 0.05)' }}
onClick={handleLogout}
>
<MyIcon name="support/account/loginoutLight" width="16px" height="16px" />
<Text fontSize="sm" ml={2}>
{t('account:logout')}
</Text>
</Flex>
</Flex>
</Box>
</Flex>
);
};
export default GateNavBar;

View File

@@ -0,0 +1,73 @@
import React, { useState, useEffect, useRef } from 'react';
import { Box, Flex } from '@chakra-ui/react';
import type { BoxProps } from '@chakra-ui/react';
import FoldButton from './FoldButton';
interface Props extends BoxProps {
externalTrigger?: Boolean;
onFoldChange?: (isFolded: boolean) => void;
defaultFolded?: boolean;
}
const GateSideBar = (e?: Props) => {
const {
w = ['100%', '0 0 250px', '0 0 250px', '0 0 270px', '0 0 290px'],
children,
externalTrigger,
onFoldChange,
defaultFolded = false,
...props
} = e || {};
const [isFolded, setIsFolded] = useState(defaultFolded);
// 保存上一次折叠状态
const preFoledStatus = useRef<Boolean>(defaultFolded);
// 同步外部传入的折叠状态
useEffect(() => {
setIsFolded(defaultFolded);
}, [defaultFolded]);
useEffect(() => {
if (externalTrigger) {
setIsFolded(true);
preFoledStatus.current = isFolded;
} else {
// @ts-ignore
setIsFolded(preFoledStatus.current);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalTrigger]);
const handleFoldToggle = () => {
const newFoldState = !isFolded;
setIsFolded(newFoldState);
onFoldChange?.(newFoldState);
};
return (
<Box
position={'relative'}
flex={isFolded ? '0 0 0' : w}
w={['100%', 0]}
h={'100%'}
overflow={'visible'}
transition={'0.2s'}
_hover={{
'& > div': { visibility: 'visible', opacity: 1 }
}}
{...props}
>
{/* 只在非完全折叠状态下显示侧边栏的折叠按钮 */}
{!defaultFolded && (
<FoldButton isFolded={isFolded} onClick={handleFoldToggle} position="sidebar" />
)}
<Box position={'relative'} h={'100%'} overflow={isFolded ? 'hidden' : 'visible'}>
{children}
</Box>
</Box>
);
};
export default GateSideBar;

View File

@@ -40,7 +40,11 @@ type FormType = {
curlContent: string;
};
export type CreateAppType = AppTypeEnum.simple | AppTypeEnum.workflow | AppTypeEnum.plugin;
export type CreateAppType =
| AppTypeEnum.simple
| AppTypeEnum.workflow
| AppTypeEnum.plugin
| AppTypeEnum.gate;
const CreateModal = ({ onClose, type }: { type: CreateAppType; onClose: () => void }) => {
const { t } = useTranslation();

View File

@@ -38,6 +38,12 @@ const AppTypeTag = ({ type }: { type: AppTypeEnum }) => {
bg: '',
color: ''
},
[AppTypeEnum.gate]: {
label: t('app:type.Gate'),
icon: 'support/gate/gateLight',
bg: '',
color: ''
},
[AppTypeEnum.tool]: undefined,
[AppTypeEnum.folder]: undefined
});

View File

@@ -0,0 +1,278 @@
import { serviceSideProps } from '@/web/common/i18n/utils';
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import AccountContainer from '@/pageComponents/account/AccountContainer';
import { Box, Flex, Spinner, Center } from '@chakra-ui/react';
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
import { useTranslation } from 'next-i18next';
import dynamic from 'next/dynamic';
import ConfigButtons from '@/pageComponents/account/gateway/ConfigButtons';
import { getTeamGateConfig, getTeamGateConfigCopyRight } from '@/web/support/user/team/gate/api';
import type { GateSchemaType } from '@fastgpt/global/support/user/team/gate/type';
import type { getGateConfigCopyRightResponse } from '@fastgpt/global/support/user/team/gate/api';
import { getAppDetailById, getMyAppsGate } from '@/web/core/app/api';
import type {
AppDetailType,
AppListItemType,
AppSimpleEditFormType
} from '@fastgpt/global/core/app/type';
import { defaultApp } from '@/web/core/app/constants';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import router from 'next/router';
// 动态导入两个新组件
const HomeTable = dynamic(() => import('@/pageComponents/account/gateway/HomeTable'));
const CopyrightTable = dynamic(() => import('@/pageComponents/account/gateway/CopyrightTable'));
const GateAppsList = dynamic(() => import('@/pageComponents/account/gateway/GateAppsList'));
const AppTable = dynamic(() => import('@/pageComponents/account/gateway/AppTable'));
const Logs = dynamic(() => import('@/pageComponents/account/gateway/logs'));
type TabType = 'home' | 'copyright' | 'app' | 'logs';
const GatewayConfig = () => {
const { t } = useTranslation();
const [gateConfig, setGateConfig] = useState<GateSchemaType | undefined>(undefined);
// 添加 appForm 状态
const [appForm, setAppForm] = useState<AppSimpleEditFormType | undefined>(undefined);
//从 appForm 中获取 selectedTools的 id 组成 string 数组
//gateConfig?.tools 改成
const [copyRightConfig, setCopyRightConfig] = useState<
getGateConfigCopyRightResponse | undefined
>(undefined);
const [tab, setTab] = useState<TabType>('home');
const [isLoadingApps, setIsLoadingApps] = useState(true);
const [gateApps, setGateApps] = useState<AppListItemType[]>([]);
useEffect(() => {
const fetchGateApps = async () => {
try {
const gateApps = await getMyAppsGate();
setGateApps(gateApps);
setIsLoadingApps(false);
} catch (error) {
console.error('Failed to load gate apps:', error);
setIsLoadingApps(false);
}
};
fetchGateApps();
}, []);
console.log('gateAppsList', gateApps);
const gateAppId = useMemo(() => gateApps[0]?._id || '', [gateApps]);
const [appDetail, setAppDetail] = useState<AppDetailType>(defaultApp);
const { loading: loadingApp, runAsync: reloadApp } = useRequest2(
() => {
if (gateAppId) {
return getAppDetailById(gateAppId);
}
return Promise.resolve(defaultApp);
},
{
manual: false,
refreshDeps: [gateAppId],
errorToast: t('common:core.app.error.Get app failed'),
onError(err: any) {
router.replace('/dashboard/apps');
},
onSuccess(res) {
setAppDetail(res);
}
}
);
// 添加 handleToolsChange 函数
const handleToolsChange = useCallback(
(newTools: string[]) => {
if (!gateConfig) return;
setGateConfig({
...gateConfig,
tools: newTools
});
},
[gateConfig]
);
// 添加 handleSloganChange 函数
const handleSloganChange = useCallback(
(newSlogan: string) => {
if (!gateConfig) return;
setGateConfig({
...gateConfig,
slogan: newSlogan
});
},
[gateConfig]
);
const handlePlaceholderChange = useCallback(
(newPlaceholder: string) => {
if (!gateConfig) return;
setGateConfig({
...gateConfig,
placeholderText: newPlaceholder
});
},
[gateConfig]
);
const handleCopyRightNameChange = useCallback(
(newName: string) => {
if (!copyRightConfig) return;
setCopyRightConfig({
...copyRightConfig,
name: newName
});
},
[copyRightConfig]
);
const handleCopyRightLogoChange = useCallback(
(newLogo: string) => {
if (!copyRightConfig) return;
setCopyRightConfig({
...copyRightConfig,
logo: newLogo
});
},
[copyRightConfig]
);
const handleCopyRightBannerChange = useCallback(
(newBanner: string) => {
if (!copyRightConfig) return;
setCopyRightConfig({
...copyRightConfig,
banner: newBanner
});
},
[copyRightConfig]
);
// 添加 handleAppFormChange 函数
const handleAppFormChange = useCallback(
(newAppForm: AppSimpleEditFormType) => {
setAppForm(newAppForm);
handleToolsChange(
newAppForm.selectedTools
.map((tool) => tool.pluginId)
.filter((id): id is string => id !== undefined) || []
);
},
[handleToolsChange]
);
// 加载 gateConfig
useEffect(() => {
const loadConfig = async () => {
try {
const config = await getTeamGateConfig();
setGateConfig(config);
const copyRightConfig = await getTeamGateConfigCopyRight();
setCopyRightConfig(copyRightConfig);
} catch (error) {
console.error('Failed to load gate config:', error);
}
};
loadConfig();
}, []);
// 设置标志让在app tab下不显示 config按钮
const isAppTab = useMemo(() => tab === 'app', [tab]);
const Tab = useMemo(() => {
return (
<FillRowTabs<TabType>
list={[
{ label: t('account:config_home'), value: 'home' },
{ label: t('account:config_copyright'), value: 'copyright' },
{ label: t('account:config_app'), value: 'app' },
{ label: t('account:logs'), value: 'logs' }
]}
value={tab}
py={1}
onChange={setTab}
/>
);
}, [t, tab]);
const content = useMemo(() => {
if (!gateConfig || !copyRightConfig) {
return (
<Center w="100%" h="100%">
<Spinner size="md" color="blue.500" thickness="3px" />
</Center>
);
}
return (
<Flex h={'100%'}>
<Flex flex={1} flexDirection={'column'} gap={4} py={4} px={6}>
<Flex alignItems={'center'}>
{Tab}
<Box flex={1} />
{!isAppTab && (
<ConfigButtons
tab={tab}
appForm={appForm}
gateConfig={gateConfig}
copyRightConfig={copyRightConfig}
/>
)}
</Flex>
{tab === 'home' && (
<HomeTable
appDetail={appDetail}
tools={gateConfig.tools}
slogan={gateConfig.slogan}
placeholderText={gateConfig.placeholderText}
onToolsChange={handleToolsChange}
onSloganChange={handleSloganChange}
onPlaceholderChange={handlePlaceholderChange}
// 添加 appForm 相关 props
onAppFormChange={handleAppFormChange}
/>
)}
{tab === 'copyright' && (
<CopyrightTable
gateName={copyRightConfig.name}
gateLogo={copyRightConfig.logo}
gateBanner={copyRightConfig.banner}
onNameChange={handleCopyRightNameChange}
onLogoChange={handleCopyRightLogoChange}
onBannerChange={handleCopyRightBannerChange}
/>
)}
{tab === 'app' && <AppTable />}
{tab === 'logs' && <Logs gateAppId={gateAppId} />}
</Flex>
</Flex>
);
}, [
gateConfig,
copyRightConfig,
isLoadingApps,
gateApps,
Tab,
isAppTab,
tab,
appForm,
appDetail,
handleToolsChange,
handleSloganChange,
handlePlaceholderChange,
handleAppFormChange,
handleCopyRightNameChange,
handleCopyRightLogoChange,
handleCopyRightBannerChange,
gateAppId
]);
return <AccountContainer>{content}</AccountContainer>;
};
export async function getServerSideProps(content: any) {
return {
props: {
...(await serviceSideProps(content, ['app', 'account', 'account_gate']))
}
};
}
export default GatewayConfig;

View File

@@ -23,6 +23,7 @@ import { type ApiRequestProps } from '@fastgpt/service/type/next';
export type CreateAppBody = {
parentId?: ParentIdType;
name?: string;
intro?: string;
avatar?: string;
type?: AppTypeEnum;
modules: AppSchema['modules'];
@@ -32,7 +33,7 @@ export type CreateAppBody = {
};
async function handler(req: ApiRequestProps<CreateAppBody>) {
const { parentId, name, avatar, type, modules, edges, chatConfig, utmParams } = req.body;
const { parentId, name, avatar, intro, type, modules, edges, chatConfig, utmParams } = req.body;
if (!name || !type || !Array.isArray(modules)) {
return Promise.reject(CommonErrEnum.inheritPermissionError);
@@ -53,6 +54,7 @@ async function handler(req: ApiRequestProps<CreateAppBody>) {
const appId = await onCreateApp({
parentId,
name,
intro,
avatar,
type,
modules,

View File

@@ -0,0 +1,164 @@
import { MongoApp } from '@fastgpt/service/core/app/schema';
import type { AppListItemType } from '@fastgpt/global/core/app/type';
import { NextAPI } from '@/service/middleware/entry';
import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema';
import {
PerResourceTypeEnum,
ReadPermissionVal
} from '@fastgpt/global/support/permission/constant';
import { AppPermission } from '@fastgpt/global/support/permission/app/controller';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type';
import { parseParentIdInMongo } from '@fastgpt/global/common/parentFolder/utils';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { AppDefaultPermissionVal } from '@fastgpt/global/support/permission/app/constant';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { replaceRegChars } from '@fastgpt/global/common/string/tools';
import { concatPer } from '@fastgpt/service/support/permission/controller';
import { getGroupsByTmbId } from '@fastgpt/service/support/permission/memberGroup/controllers';
import { getOrgIdSetWithParentByTmbId } from '@fastgpt/service/support/permission/org/controllers';
import { addSourceMember } from '@fastgpt/service/support/user/utils';
export type ListGateAppBody = {
parentId?: ParentIdType;
searchKey?: string;
};
async function handler(req: ApiRequestProps<ListGateAppBody>): Promise<AppListItemType[]> {
const { parentId, searchKey } = req.body;
// Auth user permission
const [{ tmbId, teamId, permission: teamPer }] = await Promise.all([
authUserPer({
req,
authToken: true,
authApiKey: true,
per: ReadPermissionVal
})
]);
// Get team all app permissions
const [perList, myGroupMap, myOrgSet] = await Promise.all([
MongoResourcePermission.find({
resourceType: PerResourceTypeEnum.app,
teamId,
resourceId: {
$exists: true
}
}).lean(),
getGroupsByTmbId({
tmbId,
teamId
}).then((item) => {
const map = new Map<string, 1>();
item.forEach((item) => {
map.set(String(item._id), 1);
});
return map;
}),
getOrgIdSetWithParentByTmbId({
teamId,
tmbId
})
]);
// Get my permissions
const myPerList = perList.filter(
(item) =>
String(item.tmbId) === String(tmbId) ||
myGroupMap.has(String(item.groupId)) ||
myOrgSet.has(String(item.orgId))
);
// Filter apps by permission, if not owner, only get apps that I have permission to access
const idList = { _id: { $in: myPerList.map((item) => item.resourceId) } };
const appPerQuery = teamPer.isOwner
? {}
: parentId
? {
$or: [idList, parseParentIdInMongo(parentId)]
}
: { $or: [idList, { parentId: null }] };
const searchMatch = searchKey
? {
$or: [
{ name: { $regex: new RegExp(`${replaceRegChars(searchKey)}`, 'i') } },
{ intro: { $regex: new RegExp(`${replaceRegChars(searchKey)}`, 'i') } }
]
}
: {};
const findAppsQuery = {
...appPerQuery,
teamId,
...searchMatch,
type: AppTypeEnum.gate, // 仅获取 gate 类型
...parseParentIdInMongo(parentId)
};
const limit = searchKey ? 20 : 1000;
const myApps = await MongoApp.find(
findAppsQuery,
'_id parentId avatar type name intro tmbId updateTime pluginData inheritPermission'
)
.sort({
updateTime: -1
})
.limit(limit)
.lean();
// Add app permission and filter apps by read permission
const formatApps = myApps
.map((app) => {
const { Per, privateApp } = (() => {
const getPer = (appId: string) => {
const tmbPer = myPerList.find(
(item) => String(item.resourceId) === appId && !!item.tmbId
)?.permission;
const groupPer = concatPer(
myPerList
.filter(
(item) => String(item.resourceId) === appId && (!!item.groupId || !!item.orgId)
)
.map((item) => item.permission)
);
return new AppPermission({
per: tmbPer ?? groupPer ?? AppDefaultPermissionVal,
isOwner: String(app.tmbId) === String(tmbId) || teamPer.isOwner
});
};
const getClbCount = (appId: string) => {
return perList.filter((item) => String(item.resourceId) === String(appId)).length;
};
// Check parent folder clb
if (app.parentId && app.inheritPermission) {
return {
Per: getPer(String(app.parentId)),
privateApp: getClbCount(String(app.parentId)) <= 1
};
}
return {
Per: getPer(String(app._id)),
privateApp: getClbCount(String(app._id)) === 0
};
})();
return {
...app,
permission: Per,
private: privateApp
};
})
.filter((app) => app.permission.hasReadPer);
return addSourceMember({
list: formatApps
});
}
export default NextAPI(handler);

View File

@@ -125,7 +125,8 @@ async function handler(req: ApiRequestProps<ListAppBody>): Promise<AppListItemTy
return {
...appPerQuery,
teamId,
...searchMatch
...searchMatch,
type: { $ne: AppTypeEnum.gate } // 排除 gate 类型
};
}
@@ -133,7 +134,8 @@ async function handler(req: ApiRequestProps<ListAppBody>): Promise<AppListItemTy
...appPerQuery,
teamId,
...(type && (Array.isArray(type) ? { type: { $in: type } } : { type })),
...parseParentIdInMongo(parentId)
...parseParentIdInMongo(parentId),
...(type ? {} : { type: { $ne: AppTypeEnum.gate } }) // 当未指定类型时排除 gate 类型
};
})();
const limit = (() => {
@@ -144,7 +146,8 @@ async function handler(req: ApiRequestProps<ListAppBody>): Promise<AppListItemTy
const myApps = await MongoApp.find(
findAppsQuery,
'_id parentId avatar type name intro tmbId updateTime pluginData inheritPermission',
'_id parentId avatar type name intro tmbId updateTime pluginData inheritPermission tags',
{
limit: limit
}

View File

@@ -0,0 +1,55 @@
/*
批量获取插件信息
*/
import type { NextApiResponse } from 'next';
import {
getChildAppPreviewNode,
splitCombineToolId
} from '@fastgpt/service/core/app/plugin/controller';
import type { FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node.d';
import { NextAPI } from '@/service/middleware/entry';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { PluginSourceEnum } from '@fastgpt/global/core/plugin/constants';
export type GetBatchPluginsBody = {
pluginIds: string[];
};
async function handler(
req: ApiRequestProps<GetBatchPluginsBody>,
_res: NextApiResponse<any>
): Promise<Record<string, FlowNodeTemplateType>> {
const { pluginIds } = req.body;
if (!pluginIds || !Array.isArray(pluginIds) || pluginIds.length === 0) {
return {};
}
// 创建一个结果对象
const result: Record<string, FlowNodeTemplateType> = {};
// 并行处理所有插件请求
await Promise.all(
pluginIds.map(async (pluginId) => {
try {
const { source } = await splitCombineToolId(pluginId);
if (source === PluginSourceEnum.personal) {
await authApp({ req, authToken: true, appId: pluginId, per: ReadPermissionVal });
}
const pluginData = await getChildAppPreviewNode({ appId: pluginId });
result[pluginId] = pluginData;
} catch (error) {
console.error(`Error fetching plugin ${pluginId}:`, error);
// 可以选择在结果中标记错误,或者跳过这个插件
}
})
);
return result;
}
export default NextAPI(handler);

View File

@@ -0,0 +1,43 @@
import { NextAPI } from '@/service/middleware/entry';
import { batchAddAppsToTag } from '@fastgpt/service/core/app/tags/controller';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
type Props = {
tagId: string;
appIds: string[];
};
async function handler(req: ApiRequestProps<Props>) {
// 确保只处理 POST 请求
if (req.method !== 'POST') {
throw new Error('Method Not Allowed');
}
const { tagId, appIds } = req.body;
if (!tagId) {
throw new Error('tagId is required');
}
if (!appIds || !Array.isArray(appIds)) {
throw new Error('appIds must be an array');
}
const { teamId } = await authUserPer({
req,
authToken: true,
per: WritePermissionVal
});
await batchAddAppsToTag({
tagId,
appIds,
teamId
});
return { success: true };
}
export default NextAPI(handler);

View File

@@ -0,0 +1,38 @@
import { NextAPI } from '@/service/middleware/entry';
import { addTagToApp } from '@fastgpt/service/core/app/tags/controller';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
type Props = {
appId: string;
tagId: string;
};
async function handler(req: ApiRequestProps<Props>) {
// 确保只处理 POST 请求
if (req.method !== 'POST') {
throw new Error('Method Not Allowed');
}
const { appId, tagId } = req.body;
if (!appId || !tagId) {
throw new Error('App ID and Tag ID cannot be empty');
}
const { teamId } = await authApp({
req,
authToken: true,
appId,
per: WritePermissionVal
});
return addTagToApp({
appId,
tagId,
teamId
});
}
export default NextAPI(handler);

View File

@@ -0,0 +1,41 @@
import { NextAPI } from '@/service/middleware/entry';
import { batchAddTagsToApp } from '@fastgpt/service/core/app/tags/controller';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
type Props = {
appId: string;
tagIds: string[];
};
async function handler(req: ApiRequestProps<Props>) {
if (req.method !== 'POST') {
throw new Error('Method Not Allowed');
}
const { appId, tagIds } = req.body;
if (!appId) {
throw new Error('App ID cannot be empty');
}
if (!tagIds || !Array.isArray(tagIds) || tagIds.length === 0) {
throw new Error('Tag IDs must be a non-empty array');
}
const { teamId } = await authApp({
req,
authToken: true,
appId,
per: WritePermissionVal
});
return batchAddTagsToApp({
appId,
tagIds,
teamId
});
}
export default NextAPI(handler);

View File

@@ -0,0 +1,36 @@
import { NextAPI } from '@/service/middleware/entry';
import { batchDeleteTags } from '@fastgpt/service/core/app/tags/controller';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
type Props = {
tagIds: string[];
};
async function handler(req: ApiRequestProps<Props>) {
// 确保只处理 DELETE 请求
if (req.method !== 'DELETE') {
throw new Error('Method Not Allowed');
}
const { tagIds } = req.body;
if (!tagIds || !Array.isArray(tagIds) || tagIds.length === 0) {
throw new Error('Tag IDs must be a non-empty array');
}
const { teamId } = await authUserPer({
req,
authToken: true,
per: WritePermissionVal
});
return batchDeleteTags({
tagIds,
teamId
});
}
export default NextAPI(handler);

View File

@@ -0,0 +1,37 @@
import { NextAPI } from '@/service/middleware/entry';
import { batchRemoveTagsFromApp } from '@fastgpt/service/core/app/tags/controller';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
type Props = {
appId: string;
tagIds: string[];
};
async function handler(req: ApiRequestProps<Props>) {
const { appId, tagIds } = req.body;
if (!appId) {
throw new Error('App ID cannot be empty');
}
if (!tagIds || !Array.isArray(tagIds) || tagIds.length === 0) {
throw new Error('Tag IDs must be a non-empty array');
}
const { teamId } = await authApp({
req,
authToken: true,
appId,
per: WritePermissionVal
});
return batchRemoveTagsFromApp({
appId,
tagIds,
teamId
});
}
export default NextAPI(handler);

View File

@@ -0,0 +1,37 @@
import { NextAPI } from '@/service/middleware/entry';
import { createTag } from '@fastgpt/service/core/app/tags/controller';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
type Props = {
name: string;
color?: string;
};
async function handler(req: ApiRequestProps<Props>) {
// 确保只处理 POST 请求
if (req.method !== 'POST') {
throw new Error('Method Not Allowed');
}
const { name, color } = req.body;
if (!name) {
throw new Error('Tag name is required');
}
const { teamId } = await authUserPer({
req,
authToken: true,
per: WritePermissionVal
});
return createTag({
teamId,
name,
color
});
}
export default NextAPI(handler);

View File

@@ -0,0 +1,35 @@
import { NextAPI } from '@/service/middleware/entry';
import { deleteTag } from '@fastgpt/service/core/app/tags/controller';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
type Props = {
tagId: string;
};
async function handler(req: ApiRequestProps<Props>) {
// 确保只处理 DELETE 请求
if (req.method !== 'DELETE') {
throw new Error('Method Not Allowed');
}
const tagId = req.query.tagId as string;
if (!tagId) {
throw new Error('Tag ID cannot be empty');
}
const { teamId } = await authUserPer({
req,
authToken: true,
per: WritePermissionVal
});
return deleteTag({
tagId,
teamId
});
}
export default NextAPI(handler);

View File

@@ -0,0 +1,33 @@
import { NextAPI } from '@/service/middleware/entry';
import { getTeamTags, getTagsWithCount } from '@fastgpt/service/core/app/tags/controller';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
import { TeamReadPermissionVal } from '@fastgpt/global/support/permission/user/constant';
type Props = {
withCount?: boolean;
};
async function handler(req: ApiRequestProps<Props>) {
// 确保只处理 GET 请求
if (req.method !== 'GET') {
throw new Error('Method Not Allowed');
}
const withCount = req.query?.withCount === 'true';
const { teamId } = await authUserPer({
req,
authToken: true,
authApiKey: true,
per: TeamReadPermissionVal
});
if (withCount) {
return getTagsWithCount(teamId);
}
return getTeamTags(teamId);
}
export default NextAPI(handler);

View File

@@ -0,0 +1,39 @@
import { NextAPI } from '@/service/middleware/entry';
import { removeTagFromApp } from '@fastgpt/service/core/app/tags/controller';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
type Props = {
appId: string;
tagId: string;
};
async function handler(req: ApiRequestProps<Props>) {
// 确保只处理 DELETE 请求
if (req.method !== 'DELETE') {
throw new Error('Method Not Allowed');
}
const appId = req.query.appId as string;
const tagId = req.query.tagId as string;
if (!appId || !tagId) {
throw new Error('App ID and Tag ID cannot be empty');
}
const { teamId } = await authApp({
req,
authToken: true,
appId,
per: WritePermissionVal
});
return removeTagFromApp({
appId,
tagId,
teamId
});
}
export default NextAPI(handler);

View File

@@ -0,0 +1,39 @@
import { NextAPI } from '@/service/middleware/entry';
import { updateTag } from '@fastgpt/service/core/app/tags/controller';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
type Props = {
tagId: string;
name?: string;
color?: string;
};
async function handler(req: ApiRequestProps<Props>) {
// 确保只处理 PUT 请求
if (req.method !== 'PUT') {
throw new Error('Method Not Allowed');
}
const { tagId, name, color } = req.body;
if (!tagId) {
throw new Error('Tag ID cannot be empty');
}
const { teamId } = await authUserPer({
req,
authToken: true,
per: WritePermissionVal
});
return updateTag({
tagId,
teamId,
name,
color
});
}
export default NextAPI(handler);

View File

@@ -0,0 +1,72 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { NextAPI } from '@/service/middleware/entry';
import { CommonErrEnum } from '@fastgpt/global/common/error/code/common';
import { rewriteAppWorkflowToDetail } from '@fastgpt/service/core/app/utils';
import { getGateConfig } from '@fastgpt/service/support/user/team/gate/controller';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { TeamReadPermissionVal } from '@fastgpt/global/support/permission/user/constant';
/* 获取特色应用详情 - 只允许获取特色应用列表中的应用 */
async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
const { appId } = req.query as { appId: string };
if (!appId) {
throw CommonErrEnum.missingParams;
}
// 先验证用户团队权限
const { teamId: userTeamId } = await authUserPer({
req,
authToken: true,
authApiKey: true,
per: TeamReadPermissionVal
});
// 获取团队门户配置中的特色应用列表
const gateConfig = await getGateConfig(userTeamId);
if (!gateConfig || !gateConfig.featuredApps?.length) {
throw new Error('特色应用列表为空');
}
// 验证请求的应用是否在特色应用列表中
const isInFeaturedApps = gateConfig.featuredApps.includes(appId);
if (!isInFeaturedApps) {
throw new Error('该应用不在特色应用列表中');
}
// 验证应用权限
const { app, teamId, isRoot } = await authApp({
req,
authToken: true,
appId,
per: TeamReadPermissionVal
});
// 确保应用属于同一个团队
if (String(teamId) !== String(userTeamId)) {
throw new Error('无权限访问该应用');
}
await rewriteAppWorkflowToDetail({
nodes: app.modules,
teamId,
ownerTmbId: app.tmbId,
isRoot
});
if (!app.permission.hasWritePer) {
return {
...app,
modules: [],
edges: []
};
}
return {
...app
};
}
export default NextAPI(handler);

View File

@@ -0,0 +1,72 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { NextAPI } from '@/service/middleware/entry';
import { CommonErrEnum } from '@fastgpt/global/common/error/code/common';
import { rewriteAppWorkflowToDetail } from '@fastgpt/service/core/app/utils';
import { getGateConfig } from '@fastgpt/service/support/user/team/gate/controller';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { TeamReadPermissionVal } from '@fastgpt/global/support/permission/user/constant';
/* 获取快捷应用详情 - 只允许获取快捷应用列表中的应用 */
async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
const { appId } = req.query as { appId: string };
if (!appId) {
throw CommonErrEnum.missingParams;
}
// 先验证用户团队权限
const { teamId: userTeamId } = await authUserPer({
req,
authToken: true,
authApiKey: true,
per: TeamReadPermissionVal
});
// 获取团队门户配置中的快速应用列表
const gateConfig = await getGateConfig(userTeamId);
if (!gateConfig || !gateConfig.quickApps?.length) {
throw new Error('快捷应用列表为空');
}
// 验证请求的应用是否在快捷应用列表中
const isInQuickApps = gateConfig.quickApps.includes(appId);
if (!isInQuickApps) {
throw new Error('该应用不在快捷应用列表中');
}
// 验证应用权限
const { app, teamId, isRoot } = await authApp({
req,
authToken: true,
appId,
per: TeamReadPermissionVal
});
// 确保应用属于同一个团队
if (String(teamId) !== String(userTeamId)) {
throw new Error('无权限访问该应用');
}
await rewriteAppWorkflowToDetail({
nodes: app.modules,
teamId,
ownerTmbId: app.tmbId,
isRoot
});
if (!app.permission.hasWritePer) {
return {
...app,
modules: [],
edges: []
};
}
return {
...app
};
}
export default NextAPI(handler);

View File

@@ -0,0 +1,337 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { sseErrRes } from '@fastgpt/service/common/response';
import {
DispatchNodeResponseKeyEnum,
SseResponseEventEnum
} from '@fastgpt/global/core/workflow/runtime/constants';
import { responseWrite } from '@fastgpt/service/common/response';
import { createChatUsage } from '@fastgpt/service/support/wallet/usage/controller';
import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants';
import type { AIChatItemType, UserChatItemType } from '@fastgpt/global/core/chat/type';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { dispatchWorkFlow } from '@fastgpt/service/core/workflow/dispatch';
import { getUserChatInfoAndAuthTeamPoints } from '@fastgpt/service/support/permission/auth/team';
import type { StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
import {
concatHistories,
getChatTitleFromChatMessage,
removeEmptyUserInput
} from '@fastgpt/global/core/chat/utils';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import {
getPluginRunUserQuery,
updatePluginInputByVariables
} from '@fastgpt/global/core/workflow/utils';
import { NextAPI } from '@/service/middleware/entry';
import { chatValue2RuntimePrompt, GPTMessages2Chats } from '@fastgpt/global/core/chat/adapt';
import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type';
import type { AppChatConfigType } from '@fastgpt/global/core/app/type';
import {
getLastInteractiveValue,
getMaxHistoryLimitFromNodes,
getWorkflowEntryNodeIds,
storeEdges2RuntimeEdges,
rewriteNodeOutputByHistories,
storeNodes2RuntimeNodes,
textAdaptGptResponse
} from '@fastgpt/global/core/workflow/runtime/utils';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { getWorkflowResponseWrite } from '@fastgpt/service/core/workflow/dispatch/utils';
import { WORKFLOW_MAX_RUN_TIMES } from '@fastgpt/service/core/workflow/constants';
import { getPluginInputsFromStoreNodes } from '@fastgpt/global/core/app/plugin/utils';
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 {
ChatItemValueTypeEnum,
ChatRoleEnum,
ChatSourceEnum
} from '@fastgpt/global/core/chat/constants';
import { saveChat, updateInteractiveChat } from '@fastgpt/service/core/chat/saveChat';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { addLog } from '@fastgpt/service/common/system/log';
import requestIp from 'request-ip';
export type Props = {
messages: ChatCompletionMessageParam[];
responseChatItemId: string;
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
variables: Record<string, any>;
appId: string;
appName: string;
chatId: string;
chatConfig: AppChatConfigType;
metadata?: Record<string, any>;
selectedToolIds?: string[];
};
async function handler(req: NextApiRequest, res: NextApiResponse) {
const startTime = Date.now();
const originIp = requestIp.getClientIp(req);
let {
nodes = [],
edges = [],
messages = [],
responseChatItemId,
variables = {},
appName,
appId,
chatConfig,
chatId,
metadata = {},
selectedToolIds = []
} = req.body as Props;
try {
if (!Array.isArray(nodes)) {
throw new Error('Nodes is not array');
}
if (!Array.isArray(edges)) {
throw new Error('Edges is not array');
}
//对边进行过滤只保留selectedToolIds中的边
console.log('selectedToolIds', selectedToolIds);
// 创建从 pluginId 到 nodeId 的映射
const pluginIdToNodeIdMap = new Map<string, string>();
nodes.forEach((node) => {
if (node.pluginId) {
pluginIdToNodeIdMap.set(node.pluginId, node.nodeId);
}
});
console.log('pluginIdToNodeIdMap', Object.fromEntries(pluginIdToNodeIdMap));
// 获取选中工具对应的 nodeId 集合
const selectedNodeIds = new Set<string>();
selectedToolIds.forEach((pluginId) => {
const nodeId = pluginIdToNodeIdMap.get(pluginId);
if (nodeId) {
selectedNodeIds.add(nodeId);
}
});
console.log('selectedNodeIds', Array.from(selectedNodeIds));
// 过滤边:保留第一个边和目标节点在选中工具中的边
const filteredEdges = edges.filter((edge, index) => {
// 保留第一个边
if (index === 0) {
return true;
}
// 保留目标节点在选中工具中的边
return selectedNodeIds.has(edge.target);
});
console.log('Original edges count:', edges.length);
console.log('Filtered edges count:', filteredEdges.length);
console.log('Filtered edges:', filteredEdges);
// 使用过滤后的边
edges = filteredEdges;
const chatMessages = GPTMessages2Chats(messages);
// console.log(JSON.stringify(chatMessages, null, 2), '====', chatMessages.length);
/* user auth */
const { app, teamId, tmbId } = await authApp({
req,
authToken: true,
appId,
per: ReadPermissionVal
});
const isPlugin = app.type === AppTypeEnum.plugin;
const isTool = app.type === AppTypeEnum.tool;
const userQuestion: UserChatItemType = await (async () => {
if (isPlugin) {
return getPluginRunUserQuery({
pluginInputs: getPluginInputsFromStoreNodes(app.modules),
variables,
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) {
return Promise.reject('User question is empty');
}
return latestHumanChat;
})();
const limit = getMaxHistoryLimitFromNodes(nodes);
const [{ histories }, chatDetail, { timezone, externalProvider }] = await Promise.all([
getChatItems({
appId,
chatId,
offset: 0,
limit,
field: `dataId obj value nodeOutputs`
}),
MongoChat.findOne({ appId: app._id, chatId }, 'source variableList variables'),
// auth balance
getUserChatInfoAndAuthTeamPoints(tmbId)
]);
if (chatDetail?.variables) {
variables = {
...chatDetail.variables,
...variables
};
}
const newHistories = concatHistories(histories, chatMessages);
const interactive = getLastInteractiveValue(newHistories) || undefined;
// Get runtimeNodes
let runtimeNodes = storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes, interactive));
if (isPlugin) {
runtimeNodes = updatePluginInputByVariables(runtimeNodes, variables);
variables = {};
}
runtimeNodes = rewriteNodeOutputByHistories(runtimeNodes, interactive);
const workflowResponseWrite = getWorkflowResponseWrite({
res,
detail: true,
streamResponse: true,
id: chatId,
showNodeStatus: true
});
/* start process */
const { flowResponses, assistantResponses, newVariables, flowUsages, durationSeconds } =
await dispatchWorkFlow({
res,
requestOrigin: req.headers.origin,
mode: 'test',
timezone,
externalProvider,
uid: tmbId,
runningAppInfo: {
id: appId,
teamId: app.teamId,
tmbId: app.tmbId
},
runningUserInfo: {
teamId,
tmbId
},
chatId,
responseChatItemId,
runtimeNodes,
runtimeEdges: storeEdges2RuntimeEdges(edges, interactive),
variables,
query: removeEmptyUserInput(userQuestion.value),
lastInteractive: interactive,
chatConfig,
histories: newHistories,
stream: true,
maxRunTimes: WORKFLOW_MAX_RUN_TIMES,
workflowStreamResponse: workflowResponseWrite,
version: 'v2',
responseDetail: true
});
workflowResponseWrite({
event: SseResponseEventEnum.answer,
data: textAdaptGptResponse({
text: null,
finish_reason: 'stop'
})
});
responseWrite({
res,
event: SseResponseEventEnum.answer,
data: '[DONE]'
});
// save chat
const isInteractiveRequest = !!getLastInteractiveValue(histories);
const { text: userInteractiveVal } = chatValue2RuntimePrompt(userQuestion.value);
const newTitle = isPlugin
? variables.cTime ?? getSystemTime(timezone)
: getChatTitleFromChatMessage(userQuestion);
const aiResponse: AIChatItemType & { dataId?: string } = {
dataId: responseChatItemId,
obj: ChatRoleEnum.AI,
value: assistantResponses,
[DispatchNodeResponseKeyEnum.nodeResponse]: flowResponses
};
const saveChatId = chatId || getNanoid(24);
if (isInteractiveRequest) {
await updateInteractiveChat({
chatId: saveChatId,
appId: app._id,
userInteractiveVal,
aiResponse,
newVariables,
durationSeconds
});
} else {
await saveChat({
chatId: saveChatId,
appId: app._id,
teamId,
tmbId: tmbId,
nodes,
appChatConfig: chatConfig,
variables: newVariables,
isUpdateUseTime: false, // owner update use time
newTitle,
source: ChatSourceEnum.online,
sourceName: '',
content: [userQuestion, aiResponse],
metadata: {
originIp,
...metadata
},
durationSeconds
});
}
addLog.info(`chatGate running time: ${(Date.now() - startTime) / 1000}s`);
createChatUsage({
appName,
appId,
teamId,
tmbId,
source: UsageSourceEnum.fastgpt,
flowUsages
});
} catch (err: any) {
res.status(500);
sseErrRes(res, err);
}
res.end();
}
export default NextAPI(handler);
export const config = {
api: {
bodyParser: {
sizeLimit: '10mb'
},
responseLimit: '20mb'
}
};

View File

@@ -59,6 +59,7 @@ import { rewriteNodeOutputByHistories } from '@fastgpt/global/core/workflow/runt
import { getWorkflowResponseWrite } from '@fastgpt/service/core/workflow/dispatch/utils';
import { WORKFLOW_MAX_RUN_TIMES } from '@fastgpt/service/core/workflow/constants';
import { getPluginInputsFromStoreNodes } from '@fastgpt/global/core/app/plugin/utils';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { type ExternalProviderType } from '@fastgpt/global/core/workflow/runtime/type';
type FastGptWebChatProps = {
@@ -77,6 +78,8 @@ export type Props = ChatCompletionCreateParams &
detail?: boolean;
retainDatasetCite?: boolean;
variables: Record<string, any>; // Global variables or plugin inputs
gateModel?: string; // gate model
selectedTool?: string; // selected tool ID for gate
};
type AuthResponseType = {
@@ -112,7 +115,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
messages = [],
variables = {},
responseChatItemId = getNanoid(),
metadata
metadata,
gateModel,
selectedTool
} = req.body as Props;
const originIp = requestIp.getClientIp(req);
@@ -230,7 +235,29 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
getAppLatestVersion(app._id, app),
MongoChat.findOne({ appId: app._id, chatId }, 'source variableList variables')
]);
if (app.name === 'gate') {
nodes.forEach((node) => {
if (node.flowNodeType === FlowNodeTypeEnum.chatNode) {
node.inputs.forEach((input) => {
if (input.key === 'model') {
input.value = gateModel;
}
});
}
// 如果指定了工具,通过直接操作工具节点启用该工具
if (selectedTool && node.flowNodeType === FlowNodeTypeEnum.tools) {
const selectedToolEntries = node.inputs
.filter((input) => input.key === 'selectedTools')
.flatMap((input) => (Array.isArray(input.value) ? input.value : []));
if (selectedToolEntries.some((toolEntry) => toolEntry.id === selectedTool)) {
// 找到了对应的工具,可以在这里激活它
console.log('找到并激活工具:', selectedTool);
}
}
});
}
// Get store variables(Api variable precedence)
if (chatDetail?.variables) {
variables = {

View File

@@ -0,0 +1,278 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { sseErrRes } from '@fastgpt/service/common/response';
import {
DispatchNodeResponseKeyEnum,
SseResponseEventEnum
} from '@fastgpt/global/core/workflow/runtime/constants';
import { responseWrite } from '@fastgpt/service/common/response';
import { createChatUsage } from '@fastgpt/service/support/wallet/usage/controller';
import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants';
import type { AIChatItemType, UserChatItemType } from '@fastgpt/global/core/chat/type';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { dispatchWorkFlow } from '@fastgpt/service/core/workflow/dispatch';
import { getUserChatInfoAndAuthTeamPoints } from '@fastgpt/service/support/permission/auth/team';
import type { StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
import {
concatHistories,
getChatTitleFromChatMessage,
removeEmptyUserInput
} from '@fastgpt/global/core/chat/utils';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import {
getPluginRunUserQuery,
updatePluginInputByVariables
} from '@fastgpt/global/core/workflow/utils';
import { NextAPI } from '@/service/middleware/entry';
import { chatValue2RuntimePrompt, GPTMessages2Chats } from '@fastgpt/global/core/chat/adapt';
import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type';
import type { AppChatConfigType } from '@fastgpt/global/core/app/type';
import {
getLastInteractiveValue,
getMaxHistoryLimitFromNodes,
getWorkflowEntryNodeIds,
storeEdges2RuntimeEdges,
rewriteNodeOutputByHistories,
storeNodes2RuntimeNodes,
textAdaptGptResponse
} from '@fastgpt/global/core/workflow/runtime/utils';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { getWorkflowResponseWrite } from '@fastgpt/service/core/workflow/dispatch/utils';
import { WORKFLOW_MAX_RUN_TIMES } from '@fastgpt/service/core/workflow/constants';
import { getPluginInputsFromStoreNodes } from '@fastgpt/global/core/app/plugin/utils';
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 {
ChatItemValueTypeEnum,
ChatRoleEnum,
ChatSourceEnum
} from '@fastgpt/global/core/chat/constants';
import { saveChat, updateInteractiveChat } from '@fastgpt/service/core/chat/saveChat';
export type Props = {
messages: ChatCompletionMessageParam[];
responseChatItemId: string;
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
variables: Record<string, any>;
appId: string;
appName: string;
chatId: string;
chatConfig: AppChatConfigType;
};
async function handler(req: NextApiRequest, res: NextApiResponse) {
let {
nodes = [],
edges = [],
messages = [],
responseChatItemId,
variables = {},
appName,
appId,
chatConfig,
chatId
} = req.body as Props;
try {
if (!Array.isArray(nodes)) {
throw new Error('Nodes is not array');
}
if (!Array.isArray(edges)) {
throw new Error('Edges is not array');
}
const chatMessages = GPTMessages2Chats(messages);
// console.log(JSON.stringify(chatMessages, null, 2), '====', chatMessages.length);
/* user auth */
const { app, teamId, tmbId } = await authApp({
req,
authToken: true,
appId,
per: ReadPermissionVal
});
const isPlugin = app.type === AppTypeEnum.plugin;
const isTool = app.type === AppTypeEnum.tool;
const userQuestion: UserChatItemType = await (async () => {
if (isPlugin) {
return getPluginRunUserQuery({
pluginInputs: getPluginInputsFromStoreNodes(app.modules),
variables,
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) {
return Promise.reject('User question is empty');
}
return latestHumanChat;
})();
const limit = getMaxHistoryLimitFromNodes(nodes);
const [{ histories }, chatDetail, { timezone, externalProvider }] = await Promise.all([
getChatItems({
appId,
chatId,
offset: 0,
limit,
field: `dataId obj value nodeOutputs`
}),
MongoChat.findOne({ appId: app._id, chatId }, 'source variableList variables'),
// auth balance
getUserChatInfoAndAuthTeamPoints(tmbId)
]);
if (chatDetail?.variables) {
variables = {
...chatDetail.variables,
...variables
};
}
const newHistories = concatHistories(histories, chatMessages);
const interactive = getLastInteractiveValue(newHistories) || undefined;
// Get runtimeNodes
let runtimeNodes = storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes, interactive));
if (isPlugin) {
runtimeNodes = updatePluginInputByVariables(runtimeNodes, variables);
variables = {};
}
runtimeNodes = rewriteNodeOutputByHistories(runtimeNodes, interactive);
const workflowResponseWrite = getWorkflowResponseWrite({
res,
detail: true,
streamResponse: true,
id: chatId,
showNodeStatus: true
});
/* start process */
const { flowResponses, assistantResponses, newVariables, flowUsages, durationSeconds } =
await dispatchWorkFlow({
res,
requestOrigin: req.headers.origin,
mode: 'test',
timezone,
externalProvider,
uid: tmbId,
runningAppInfo: {
id: appId,
teamId: app.teamId,
tmbId: app.tmbId
},
runningUserInfo: {
teamId,
tmbId
},
chatId,
responseChatItemId,
runtimeNodes,
runtimeEdges: storeEdges2RuntimeEdges(edges, interactive),
variables,
query: removeEmptyUserInput(userQuestion.value),
lastInteractive: interactive,
chatConfig,
histories: newHistories,
stream: true,
maxRunTimes: WORKFLOW_MAX_RUN_TIMES,
workflowStreamResponse: workflowResponseWrite,
version: 'v2',
responseDetail: true
});
workflowResponseWrite({
event: SseResponseEventEnum.answer,
data: textAdaptGptResponse({
text: null,
finish_reason: 'stop'
})
});
responseWrite({
res,
event: SseResponseEventEnum.answer,
data: '[DONE]'
});
// save chat
const isInteractiveRequest = !!getLastInteractiveValue(histories);
const { text: userInteractiveVal } = chatValue2RuntimePrompt(userQuestion.value);
const newTitle = isPlugin
? variables.cTime ?? getSystemTime(timezone)
: getChatTitleFromChatMessage(userQuestion);
const aiResponse: AIChatItemType & { dataId?: string } = {
dataId: responseChatItemId,
obj: ChatRoleEnum.AI,
value: assistantResponses,
[DispatchNodeResponseKeyEnum.nodeResponse]: flowResponses
};
if (isInteractiveRequest) {
await updateInteractiveChat({
chatId,
appId: app._id,
userInteractiveVal,
aiResponse,
newVariables,
durationSeconds
});
} else {
await saveChat({
chatId,
appId: app._id,
teamId,
tmbId: tmbId,
nodes,
appChatConfig: chatConfig,
variables: newVariables,
isUpdateUseTime: false, // owner update use time
newTitle,
source: ChatSourceEnum.test,
content: [userQuestion, aiResponse],
durationSeconds
});
}
createChatUsage({
appName,
appId,
teamId,
tmbId,
source: UsageSourceEnum.fastgpt,
flowUsages
});
} catch (err: any) {
res.status(500);
sseErrRes(res, err);
}
res.end();
}
export default NextAPI(handler);
export const config = {
api: {
bodyParser: {
sizeLimit: '10mb'
},
responseLimit: '20mb'
}
};

View File

@@ -59,6 +59,7 @@ import { rewriteNodeOutputByHistories } from '@fastgpt/global/core/workflow/runt
import { getWorkflowResponseWrite } from '@fastgpt/service/core/workflow/dispatch/utils';
import { WORKFLOW_MAX_RUN_TIMES } from '@fastgpt/service/core/workflow/constants';
import { getPluginInputsFromStoreNodes } from '@fastgpt/global/core/app/plugin/utils';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { type ExternalProviderType } from '@fastgpt/global/core/workflow/runtime/type';
type FastGptWebChatProps = {
@@ -77,6 +78,7 @@ export type Props = ChatCompletionCreateParams &
detail?: boolean;
retainDatasetCite?: boolean;
variables: Record<string, any>; // Global variables or plugin inputs
gateModel?: string;
};
type AuthResponseType = {
@@ -112,7 +114,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
messages = [],
variables = {},
responseChatItemId = getNanoid(),
metadata
metadata,
gateModel
} = req.body as Props;
const originIp = requestIp.getClientIp(req);
@@ -190,7 +193,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
})();
retainDatasetCite = retainDatasetCite && !!responseDetail;
const isPlugin = app.type === AppTypeEnum.plugin;
// Check message type
if (isPlugin) {
detail = true;
@@ -231,6 +233,18 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
MongoChat.findOne({ appId: app._id, chatId }, 'source variableList variables')
]);
if (app.name === 'gate') {
nodes.forEach((node) => {
if (node.flowNodeType === FlowNodeTypeEnum.chatNode) {
node.inputs.forEach((input) => {
if (input.key === 'model') {
input.value = gateModel;
}
});
}
});
}
// Get store variables(Api variable precedence)
if (chatDetail?.variables) {
variables = {

View File

@@ -14,6 +14,10 @@ const SimpleEdit = dynamic(() => import('@/pageComponents/app/detail/SimpleApp')
ssr: false,
loading: () => <Loading fixed={false} />
});
const Gate = dynamic(() => import('@/pageComponents/app/detail/Gate'), {
ssr: false,
loading: () => <Loading fixed={false} />
});
const Workflow = dynamic(() => import('@/pageComponents/app/detail/Workflow'), {
ssr: false,
loading: () => <Loading fixed={false} />
@@ -45,6 +49,7 @@ const AppDetail = () => {
) : (
<>
{appDetail.type === AppTypeEnum.simple && <SimpleEdit />}
{appDetail.type === AppTypeEnum.gate && <Gate />}
{appDetail.type === AppTypeEnum.workflow && <Workflow />}
{appDetail.type === AppTypeEnum.plugin && <Plugin />}
{appDetail.type === AppTypeEnum.toolSet && <MCPTools />}

View File

@@ -0,0 +1,366 @@
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import NextHead from '@/components/common/NextHead';
import { useRouter } from 'next/router';
import { getInitChatInfo } from '@/web/core/chat/api';
import { Box, Flex, Drawer, DrawerOverlay, DrawerContent, useTheme } from '@chakra-ui/react';
import { streamFetch } from '@/web/common/api/fetch';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useTranslation } from 'next-i18next';
import FoldButton from '@/pageComponents/chat/gatechat/FoldButton';
import type { StartChatFnProps } from '@/components/core/chat/ChatContainer/type';
import PageContainer from '@/components/PageContainer';
import SideBar from '@/components/SideBar';
import ChatHistorySlider from '@/pageComponents/chat/ChatHistorySlider';
import SliderApps from '@/pageComponents/chat/SliderApps';
import ChatHeader from '@/pageComponents/chat/ChatHeader';
import { useUserStore } from '@/web/support/user/useUserStore';
import { serviceSideProps } from '@/web/common/i18n/utils';
import { getChatTitleFromChatMessage } from '@fastgpt/global/core/chat/utils';
import { GPTMessages2Chats } from '@fastgpt/global/core/chat/adapt';
import { getMyApps } from '@/web/core/app/api';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useMount } from 'ahooks';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { GetChatTypeEnum } from '@/global/core/chat/constants';
import ChatContextProvider, { ChatContext } from '@/web/core/chat/context/chatContext';
import type { AppListItemType } from '@fastgpt/global/core/app/type';
import { useContextSelector } from 'use-context-selector';
import dynamic from 'next/dynamic';
import ChatBox from '@/components/core/chat/ChatContainer/ChatBox';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import ChatItemContextProvider, { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import ChatRecordContextProvider, {
ChatRecordContext
} from '@/web/core/chat/context/chatRecordContext';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
import GateNavBar from '../../../pageComponents/chat/gatechat/GateNavBar';
import GateSideBar from '@/pageComponents/chat/gatechat/GateSideBar';
import GatePageContainer from '@/components/GatePageContainer';
import GateChatHistorySlider from '@/pageComponents/chat/gatechat/GateChatHistorySlider';
import type { GateSchemaType } from '@fastgpt/global/support/user/team/gate/type';
import { getTeamGateConfig } from '@/web/support/user/team/gate/api';
const CustomPluginRunBox = dynamic(() => import('@/pageComponents/chat/CustomPluginRunBox'));
const Chat = ({
myApps,
gateConfig
}: {
myApps: AppListItemType[];
gateConfig?: GateSchemaType;
}) => {
const router = useRouter();
const { t } = useTranslation();
const { isPc } = useSystem();
const { userInfo } = useUserStore();
const { setLastChatAppId, chatId, appId, outLinkAuthData } = useChatStore();
const isOpenSlider = useContextSelector(ChatContext, (v) => v.isOpenSlider);
const onCloseSlider = useContextSelector(ChatContext, (v) => v.onCloseSlider);
const forbidLoadChat = useContextSelector(ChatContext, (v) => v.forbidLoadChat);
const onChangeChatId = useContextSelector(ChatContext, (v) => v.onChangeChatId);
const onUpdateHistoryTitle = useContextSelector(ChatContext, (v) => v.onUpdateHistoryTitle);
const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables);
const isPlugin = useContextSelector(ChatItemContext, (v) => v.isPlugin);
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
const datasetCiteData = useContextSelector(ChatItemContext, (v) => v.datasetCiteData);
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const totalRecordsCount = useContextSelector(ChatRecordContext, (v) => v.totalRecordsCount);
// Load chat init data
const { loading } = useRequest2(
async () => {
if (!appId || forbidLoadChat.current) return;
const res = await getInitChatInfo({ appId, chatId });
res.userAvatar = userInfo?.avatar;
// Wait for state update to complete
setChatBoxData(res);
// reset chat variables
resetVariables({
variables: res.variables,
variableList: res.app?.chatConfig?.variables
});
},
{
manual: false,
refreshDeps: [appId, chatId],
onError(e: any) {
// reset all chat tore
if (e?.code === 501) {
setLastChatAppId('');
router.replace('/dashboard/apps');
} else {
router.replace({
query: {
...router.query,
appId: myApps[0]?._id
}
});
}
},
onFinally() {
forbidLoadChat.current = false;
}
}
);
const onStartChat = useCallback(
async ({
messages,
responseChatItemId,
controller,
generatingMessage,
variables
}: StartChatFnProps) => {
// Just send a user prompt
const histories = messages.slice(-1);
const { responseText } = await streamFetch({
data: {
messages: histories,
variables,
responseChatItemId,
appId,
chatId
},
onMessage: generatingMessage,
abortCtrl: controller
});
const newTitle = getChatTitleFromChatMessage(GPTMessages2Chats(histories)[0]);
// new chat
onUpdateHistoryTitle({ chatId, newTitle });
// update chat window
setChatBoxData((state) => ({
...state,
title: newTitle
}));
return { responseText, isNewChat: forbidLoadChat.current };
},
[appId, chatId, onUpdateHistoryTitle, setChatBoxData, forbidLoadChat]
);
const [sidebarFolded, setSidebarFolded] = useState(false);
const handleFoldChange = (isFolded: boolean) => {
setSidebarFolded(isFolded);
};
const RenderHistorySlider = useMemo(() => {
const Children = (
<GateChatHistorySlider confirmClearText={t('common:core.chat.Confirm to clear history')} />
);
return isPc || !appId ? (
<GateSideBar
externalTrigger={!!datasetCiteData}
onFoldChange={handleFoldChange}
defaultFolded={sidebarFolded}
>
{Children}
</GateSideBar>
) : (
<Drawer
isOpen={isOpenSlider}
placement="left"
autoFocus={false}
size={'xs'}
onClose={onCloseSlider}
>
<DrawerOverlay backgroundColor={'rgba(255,255,255,0.5)'} />
<DrawerContent maxWidth={'75vw'}>{Children}</DrawerContent>
</Drawer>
);
}, [t, isPc, appId, datasetCiteData, sidebarFolded, isOpenSlider, onCloseSlider]);
return (
<Flex h={'100%'}>
<NextHead title={gateConfig?.name} icon={gateConfig?.logo}></NextHead>
{isPc && <GateNavBar gateConfig={gateConfig} apps={myApps} activeAppId={appId} />}
{(!datasetCiteData || isPc) && (
<GatePageContainer flex={'1 0 0'} w={0} position={'relative'}>
{sidebarFolded && isPc && appId && (
<Box position="absolute" left="-8px" top="50%" transform="translateY(-50%)" zIndex={10}>
<FoldButton
isFolded={true}
onClick={() => setSidebarFolded(false)}
position="navbar"
/>
</Box>
)}
<Flex h={'100%'} flexDirection={['column', 'row']}>
{/* pc always show history. */}
{RenderHistorySlider}
{/* chat container */}
<Flex
position={'relative'}
h={[0, '100%']}
w={['100%', 0]}
flex={'1 0 0'}
flexDirection={'column'}
>
{/* header */}
<ChatHeader
totalRecordsCount={totalRecordsCount}
apps={myApps}
history={chatRecords}
showHistory
/>
{/* chat box */}
<Box flex={'1 0 0'} bg={'white'}>
{isPlugin ? (
<CustomPluginRunBox
appId={appId}
chatId={chatId}
outLinkAuthData={outLinkAuthData}
onNewChat={() => onChangeChatId(getNanoid())}
onStartChat={onStartChat}
/>
) : (
<ChatBox
appId={appId}
chatId={chatId}
outLinkAuthData={outLinkAuthData}
showEmptyIntro
feedbackType={'user'}
onStartChat={onStartChat}
chatType={'chat'}
isReady={!loading}
/>
)}
</Box>
</Flex>
</Flex>
</GatePageContainer>
)}
{datasetCiteData && (
<PageContainer flex={'1 0 0'} w={0} maxW={'560px'}>
<ChatQuoteList
rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata}
onClose={() => setCiteModalData(undefined)}
/>
</PageContainer>
)}
</Flex>
);
};
const Render = (props: { appId: string; isStandalone?: string }) => {
const { appId, isStandalone } = props;
const { t } = useTranslation();
const { toast } = useToast();
const router = useRouter();
const { source, chatId, lastChatAppId, setSource, setAppId } = useChatStore();
const [gateConfig, setGateConfig] = useState<GateSchemaType | undefined>(undefined);
// 加载 gateConfig
useEffect(() => {
const loadConfig = async () => {
try {
const config = await getTeamGateConfig();
setGateConfig(config);
} catch (error) {
console.error('Failed to load gate config:', error);
}
};
loadConfig();
}, []);
const {
data: myApps = [],
loading: loadingApps,
runAsync: loadMyApps
} = useRequest2(() => getMyApps({ getRecentlyChat: true }), {
manual: false,
refreshDeps: [appId]
});
// 初始化聊天框
useMount(async () => {
// pc: redirect to latest model chat
if (!appId) {
const apps = await loadMyApps();
if (apps.length === 0) {
toast({
status: 'error',
title: t('common:core.chat.You need to a chat app')
});
router.replace('/dashboard/apps');
} else {
router.replace({
query: {
...router.query,
appId: lastChatAppId || apps[0]._id
}
});
}
}
setSource('online');
});
// Watch appId
useEffect(() => {
setAppId(appId);
}, [appId, setAppId]);
const chatHistoryProviderParams = useMemo(
() => ({ appId, source: ChatSourceEnum.online }),
[appId]
);
const chatRecordProviderParams = useMemo(() => {
return {
appId,
type: GetChatTypeEnum.normal,
chatId: chatId
};
}, [appId, chatId]);
return source === ChatSourceEnum.online ? (
<ChatContextProvider params={chatHistoryProviderParams}>
<ChatItemContextProvider
isResponseDetail={false}
showRouteToAppDetail={isStandalone !== '1'}
showRouteToDatasetDetail={isStandalone !== '1'}
isShowReadRawSource={true}
// isShowFullText={true}
showNodeStatus
>
<ChatRecordContextProvider params={chatRecordProviderParams}>
<Chat myApps={myApps} gateConfig={gateConfig} />
</ChatRecordContextProvider>
</ChatItemContextProvider>
</ChatContextProvider>
) : null;
};
export async function getServerSideProps(context: any) {
return {
props: {
appId: context?.query?.appId || '',
isStandalone: context?.query?.isStandalone || '',
...(await serviceSideProps(context, [
'file',
'app',
'chat',
'workflow',
'account_gate',
'common'
]))
}
};
}
export default Render;

View File

@@ -0,0 +1,395 @@
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import NextHead from '@/components/common/NextHead';
import { useRouter } from 'next/router';
import { getInitChatInfo } from '@/web/core/chat/api';
import { Box, Flex, Drawer, DrawerOverlay, DrawerContent } from '@chakra-ui/react';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useTranslation } from 'next-i18next';
import PageContainer from '@/components/PageContainer';
import { useUserStore } from '@/web/support/user/useUserStore';
import { serviceSideProps } from '@/web/common/i18n/utils';
import { getAppDetailById, getMyApps, getMyAppsGate } from '@/web/core/app/api';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useMount } from 'ahooks';
import { GetChatTypeEnum } from '@/global/core/chat/constants';
import ChatContextProvider, { ChatContext } from '@/web/core/chat/context/chatContext';
import type {
AppDetailType,
AppListItemType,
AppSimpleEditFormType
} from '@fastgpt/global/core/app/type';
import { useContextSelector } from 'use-context-selector';
import dynamic from 'next/dynamic';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import ChatItemContextProvider, { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import ChatRecordContextProvider from '@/web/core/chat/context/chatRecordContext';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
import GateNavBar from '../../../pageComponents/chat/gatechat/GateNavBar';
import { getDefaultAppForm, appWorkflow2Form } from '@fastgpt/global/core/app/utils';
import GateChatHistorySlider from '@/pageComponents/chat/gatechat/GateChatHistorySlider';
import GatePageContainer from '@/components/GatePageContainer';
import GateSideBar from '@/pageComponents/chat/gatechat/GateSideBar';
import FoldButton from '@/pageComponents/chat/gatechat/FoldButton';
import { getTeamGateConfig } from '@/web/support/user/team/gate/api';
import type { GateSchemaType } from '@fastgpt/global/support/user/team/gate/type';
const ChatGate = dynamic(() => import('@/pageComponents/app/detail/Gate/ChatGate'));
// AppForm共享上下文
export const AppFormContext = React.createContext<{
appForm: AppSimpleEditFormType;
setAppForm: React.Dispatch<React.SetStateAction<AppSimpleEditFormType>>;
}>({
appForm: getDefaultAppForm(),
setAppForm: () => {}
});
const Chat = ({
myApps,
initialAppDetail,
currentAppId
}: {
myApps: AppListItemType[];
initialAppDetail?: AppDetailType;
currentAppId?: string;
}) => {
const router = useRouter();
const { t } = useTranslation();
const { isPc } = useSystem();
const refresh = router.query.refresh;
// 从 router.query 获取 appId而不是从 store
const appId = (router.query.appId as string) || currentAppId;
const { userInfo } = useUserStore();
const { setLastChatAppId, chatId } = useChatStore();
const [gateConfig, setGateConfig] = useState<GateSchemaType | undefined>(undefined);
// 加载 gateConfig
useEffect(() => {
const loadConfig = async () => {
try {
const config = await getTeamGateConfig();
setGateConfig(config);
} catch (error) {
console.error('Failed to load gate config:', error);
}
};
loadConfig();
}, []);
const isOpenSlider = useContextSelector(ChatContext, (v) => v.isOpenSlider);
const onCloseSlider = useContextSelector(ChatContext, (v) => v.onCloseSlider);
const forbidLoadChat = useContextSelector(ChatContext, (v) => v.forbidLoadChat);
const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables);
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
const datasetCiteData = useContextSelector(ChatItemContext, (v) => v.datasetCiteData);
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
// 添加appForm共享状态使用初始化的appDetail
const [appForm, setAppForm] = useState<AppSimpleEditFormType>(() => {
if (initialAppDetail?.modules) {
return appWorkflow2Form({
nodes: initialAppDetail.modules,
chatConfig: initialAppDetail.chatConfig || {}
});
}
return getDefaultAppForm();
});
const [appDetail, setAppDetail] = useState<AppDetailType | undefined>(
() => initialAppDetail || undefined
);
const [renderEdit, setRenderEdit] = useState(false);
// 添加侧边栏折叠状态
const [sidebarFolded, setSidebarFolded] = useState(false);
// Load chat init data
const { loading } = useRequest2(
async () => {
if (!appId || forbidLoadChat.current) return;
const res = await getInitChatInfo({ appId, chatId });
res.userAvatar = userInfo?.avatar;
// Wait for state update to complete
setChatBoxData(res);
// reset chat variables
resetVariables({
variables: res.variables,
variableList: res.app?.chatConfig?.variables
});
// 如果还没有 AppDetail则重新获取
if (!appDetail && appId) {
try {
const detail = await getAppDetailById(appId);
if (detail?.modules) {
setAppDetail(detail);
const form = appWorkflow2Form({
nodes: detail.modules,
chatConfig: detail.chatConfig || {}
});
setAppForm(form);
}
} catch (error) {
console.error('Failed to fetch app detail:', error);
}
}
},
{
manual: false,
refreshDeps: [appId, chatId, refresh], // 添加refresh作为依赖
onError(e: any) {
// reset all chat tore
if (e?.code === 501) {
setLastChatAppId('');
router.replace('/dashboard/apps');
} else {
router.replace({
query: {
...router.query,
appId: myApps[0]?._id
}
});
}
},
onFinally() {
forbidLoadChat.current = false;
}
}
);
const handleFoldChange = useCallback((isFolded: boolean) => {
setSidebarFolded(isFolded);
}, []);
const RenderHistorySlider = useMemo(() => {
const Children = (
<GateChatHistorySlider confirmClearText={t('common:core.chat.Confirm to clear history')} />
);
return isPc || !appId ? (
<GateSideBar
externalTrigger={!!datasetCiteData}
onFoldChange={handleFoldChange}
defaultFolded={sidebarFolded}
>
{Children}
</GateSideBar>
) : (
<Drawer
isOpen={isOpenSlider}
placement="left"
autoFocus={false}
size={'xs'}
onClose={onCloseSlider}
>
<DrawerOverlay backgroundColor={'rgba(255,255,255,0.5)'} />
<DrawerContent maxWidth={'75vw'}>{Children}</DrawerContent>
</Drawer>
);
}, [
t,
isPc,
appId,
isOpenSlider,
onCloseSlider,
datasetCiteData,
sidebarFolded,
handleFoldChange
]);
return (
<AppFormContext.Provider value={{ appForm, setAppForm }}>
<Flex h={'100%'}>
<NextHead title={gateConfig?.name} icon={gateConfig?.logo}></NextHead>
{isPc && (
<Flex alignItems="center">
<GateNavBar gateConfig={gateConfig} apps={myApps} activeAppId={appId} />
</Flex>
)}
{(!datasetCiteData || isPc) && (
<GatePageContainer flex={'1 0 0'} w={0} position={'relative'}>
{/* 将折叠按钮放在PageContainer内部贴近左侧 */}
{sidebarFolded && isPc && appId && (
<Box
position="absolute"
left="-8px"
top="50%"
transform="translateY(-50%)"
zIndex={10}
>
<FoldButton
isFolded={true}
onClick={() => setSidebarFolded(false)}
position="navbar"
/>
</Box>
)}
<Flex
h={'100%'}
flexDirection={['column', 'row']}
position="relative"
overflow="visible"
>
{RenderHistorySlider}
{/* chat container */}
<Flex
position={'relative'}
h={[0, '100%']}
w={['100%', 0]}
flex={'1 0 0'}
flexDirection={'column'}
>
{/* 聊天界面 */}
{appDetail && (
<ChatGate appForm={appForm} setRenderEdit={setRenderEdit} appDetail={appDetail} />
)}
</Flex>
</Flex>
</GatePageContainer>
)}
{datasetCiteData && (
<PageContainer flex={'1 0 0'} w={0} maxW={'560px'}>
<ChatQuoteList
rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata}
onClose={() => setCiteModalData(undefined)}
/>
</PageContainer>
)}
</Flex>
</AppFormContext.Provider>
);
};
const Render = (props: { appId: string; appDetail?: AppDetailType; isStandalone?: string }) => {
const { appId: propsAppId, appDetail: initialAppDetail, isStandalone } = props;
const { t } = useTranslation();
const { toast } = useToast();
const router = useRouter();
const { source, chatId, lastChatAppId, setSource, setAppId } = useChatStore();
// 从 router.query 获取 appId优先使用 query 中的值
const appId = (router.query.appId as string) || propsAppId;
const [gateConfig, setGateConfig] = useState<GateSchemaType | undefined>(undefined);
// 加载 gateConfig
useEffect(() => {
const loadConfig = async () => {
try {
const config = await getTeamGateConfig();
setGateConfig(config);
} catch (error) {
console.error('Failed to load gate config:', error);
}
};
loadConfig();
}, []);
const { data: myApps = [], runAsync: loadMyApps } = useRequest2(
() => getMyApps({ getRecentlyChat: true }),
{
manual: false,
refreshDeps: [appId]
}
);
// 初始化聊天框
useMount(async () => {
// pc: redirect to latest model chat
if (!appId) {
// 获取Gate应用
const gateApps = await getMyAppsGate();
const gateApp = gateApps[0]; // 获取第一个Gate应用
// 如果找不到Gate应用则加载普通应用
if (!gateApp) {
const apps = await loadMyApps();
if (apps.length === 0) {
toast({
status: 'error',
title: t('common:core.chat.You need to a chat app')
});
router.replace('/dashboard/apps');
} else {
router.replace({
query: {
...router.query,
appId: lastChatAppId || apps[0]._id
}
});
}
} else {
// 使用Gate应用
router.replace({
query: {
...router.query,
appId: gateApp._id
}
});
}
}
setSource('online');
});
// Watch appId - 同步到 store但主要依赖来源不是 store
useEffect(() => {
if (appId) {
setAppId(appId);
}
}, [appId, setAppId]);
const chatHistoryProviderParams = useMemo(
() => ({ appId, source: ChatSourceEnum.online }),
[appId]
);
const chatRecordProviderParams = useMemo(() => {
return {
appId,
type: GetChatTypeEnum.normal,
chatId: chatId
};
}, [appId, chatId]);
return source === ChatSourceEnum.online ? (
<ChatContextProvider params={chatHistoryProviderParams}>
<ChatItemContextProvider
isResponseDetail={false}
showRouteToAppDetail={isStandalone !== '1'}
showRouteToDatasetDetail={isStandalone !== '1'}
isShowReadRawSource={true}
// isShowFullText={true}
showNodeStatus
>
<ChatRecordContextProvider params={chatRecordProviderParams}>
<Chat myApps={myApps} initialAppDetail={initialAppDetail} currentAppId={appId} />
</ChatRecordContextProvider>
</ChatItemContextProvider>
</ChatContextProvider>
) : null;
};
export async function getServerSideProps(context: any) {
return {
props: {
isStandalone: context?.query?.isStandalone || '',
...(await serviceSideProps(context, [
'file',
'app',
'chat',
'workflow',
'account',
'account_gate',
'common'
]))
}
};
}
export default Render;

View File

@@ -0,0 +1,386 @@
import React, { useEffect, useMemo, useState } from 'react';
import NextHead from '@/components/common/NextHead';
import { useRouter } from 'next/router';
import { getInitChatInfo } from '@/web/core/chat/api';
import { Box, Flex } from '@chakra-ui/react';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useTranslation } from 'next-i18next';
import FoldButton from '@/pageComponents/chat/gatechat/FoldButton';
import PageContainer from '@/components/PageContainer';
import { useUserStore } from '@/web/support/user/useUserStore';
import { serviceSideProps } from '@/web/common/i18n/utils';
import { getMyApps } from '@/web/core/app/api';
import { getTeamTags } from '@fastgpt/service/core/app/tags/controller';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useMount } from 'ahooks';
import { GetChatTypeEnum } from '@/global/core/chat/constants';
import ChatContextProvider, { ChatContext } from '@/web/core/chat/context/chatContext';
import type { AppListItemType } from '@fastgpt/global/core/app/type';
import { useContextSelector } from 'use-context-selector';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import ChatItemContextProvider, { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import ChatRecordContextProvider from '@/web/core/chat/context/chatRecordContext';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
import GateNavBar from '../../../pageComponents/chat/gatechat/GateNavBar';
import GatePageContainer from '@/components/GatePageContainer';
import MySelect from '@fastgpt/web/components/common/MySelect';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import AppCard from '@/pageComponents/chat/gatechat/AppCard';
import type { GateSchemaType } from '@fastgpt/global/support/user/team/gate/type';
import { getTeamGateConfig } from '@/web/support/user/team/gate/api';
import { listFeatureApps } from '@/web/support/user/team/gate/featureApp';
const Chat = ({
myApps,
serverTagList = [],
serverTagMap = {},
gateConfig // 添加 gateConfig 参数
}: {
myApps: AppListItemType[];
serverTagList?: any[];
serverTagMap?: Record<string, any>;
gateConfig?: GateSchemaType; // 添加类型定义
}) => {
const router = useRouter();
const { t } = useTranslation();
const { isPc } = useSystem();
const [search, setSearch] = useState('');
const [selectedTag, setSelectedTag] = useState<string>('');
const { userInfo } = useUserStore();
const { setLastChatAppId, chatId, appId } = useChatStore();
const forbidLoadChat = useContextSelector(ChatContext, (v) => v.forbidLoadChat);
const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables);
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
const datasetCiteData = useContextSelector(ChatItemContext, (v) => v.datasetCiteData);
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
// 使用服务端渲染的标签数据
const [tagList, setTagList] = useState<any[]>(serverTagList);
// 使用服务端渲染的标签映射
const tagMap = useMemo(() => {
if (Object.keys(serverTagMap).length > 0) {
// 将对象转换为 Map
const map = new Map<string, any>();
Object.keys(serverTagMap).forEach((key) => {
map.set(key, serverTagMap[key]);
});
return map;
}
// 如果没有服务端数据,则创建新的映射(兼容旧版本)
const map = new Map<string, any>();
tagList.forEach((tag) => {
map.set(tag._id, tag);
});
return map;
}, [serverTagMap, tagList]);
// Get unique tags from all apps
const allTags = useMemo(() => {
const tags = new Set<string>();
myApps.forEach((app) => {
app.tags?.forEach((tag) => tags.add(tag));
});
return [
{ label: t('common:All'), value: '' },
...Array.from(tags).map((tag) => ({
label: tagMap.get(tag)?.name || tag,
value: tag
}))
];
}, [myApps, t, tagMap]);
// Filter apps based on search and selected tag
const filteredApps = useMemo(() => {
return myApps.filter((app) => {
const searchFilter = search
? app.name.toLowerCase().includes(search.toLowerCase()) ||
app.intro?.toLowerCase().includes(search.toLowerCase())
: true;
const tagFilter = selectedTag ? app.tags?.includes(selectedTag) : true;
return searchFilter && tagFilter;
});
}, [myApps, search, selectedTag]);
// Load chat init data
const { loading } = useRequest2(
async () => {
if (!appId || forbidLoadChat.current) return;
const res = await getInitChatInfo({ appId, chatId });
res.userAvatar = userInfo?.avatar;
// Wait for state update to complete
setChatBoxData(res);
// reset chat variables
resetVariables({
variables: res.variables,
variableList: res.app?.chatConfig?.variables
});
},
{
manual: false,
refreshDeps: [appId, chatId],
onError(e: any) {
// reset all chat tore
if (e?.code === 501) {
setLastChatAppId('');
router.replace('/dashboard/apps');
}
},
onFinally() {
forbidLoadChat.current = false;
}
}
);
const [sidebarFolded, setSidebarFolded] = useState(false);
return (
<Flex h={'100%'}>
<NextHead title={gateConfig?.name} icon={gateConfig?.logo}></NextHead>
{isPc && <GateNavBar apps={myApps} activeAppId={appId} gateConfig={gateConfig} />}
{(!datasetCiteData || isPc) && (
<GatePageContainer flex={'1 0 0'} w={0} position={'relative'}>
{sidebarFolded && isPc && appId && (
<Box position="absolute" left="-8px" top="50%" transform="translateY(-50%)" zIndex={10}>
<FoldButton
isFolded={true}
onClick={() => setSidebarFolded(false)}
position="navbar"
/>
</Box>
)}
<Flex h={'100%'} flexDirection={['column', 'row']}>
<Flex
position={'relative'}
h={[0, '100%']}
w={['100%', 0]}
flex={'1 0 0'}
flexDirection={'column'}
p={4}
>
{/* Filter Controls */}
<Flex alignItems="center" justifyContent="space-between" mb={4} gap={4}>
{/* 左侧Tab标签栏 */}
<Flex overflowX="auto" gap={6} flex={1} minW={0}>
{allTags.map((tag) => (
<Box
key={tag.value}
px={2}
py={1}
minW="48px"
fontSize="16px"
fontWeight={selectedTag === tag.value ? 600 : 400}
color={selectedTag === tag.value ? 'blue.600' : 'gray.500'}
borderBottom={
selectedTag === tag.value ? '2px solid #2667FF' : '2px solid transparent'
}
cursor="pointer"
whiteSpace="nowrap"
onClick={() => setSelectedTag(tag.value)}
transition="all 0.2s"
>
{tag.label}
</Box>
))}
</Flex>
{/* 右侧搜索和下拉 */}
<Flex gap={2} alignItems="center" minW="340px">
<Box flex={1} minW="200px">
<SearchInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('app:search_app')}
/>
</Box>
<Box w={'120px'}>
<MySelect
value={selectedTag}
onChange={setSelectedTag}
list={allTags}
placeholder={t('common:select_tag')}
/>
</Box>
</Flex>
</Flex>
{/* App Cards Grid */}
<Flex wrap="wrap" gap={4}>
{filteredApps.map((app) => (
<AppCard key={app._id} app={app} selectedId={appId} tagMap={tagMap} />
))}
</Flex>
</Flex>
</Flex>
</GatePageContainer>
)}
{datasetCiteData && (
<PageContainer flex={'1 0 0'} w={0} maxW={'560px'}>
<ChatQuoteList
rawSearch={datasetCiteData.rawSearch}
metadata={datasetCiteData.metadata}
onClose={() => setCiteModalData(undefined)}
/>
</PageContainer>
)}
</Flex>
);
};
const Render = (props: {
appId: string;
isStandalone?: string;
serverTagList?: any[];
serverTagMap?: Record<string, any>;
}) => {
const { appId, isStandalone, serverTagList = [], serverTagMap = {} } = props;
const { t } = useTranslation();
const { toast } = useToast();
const router = useRouter();
const { source, chatId, lastChatAppId, setSource, setAppId } = useChatStore();
const [gateConfig, setGateConfig] = useState<GateSchemaType | undefined>(undefined);
// 加载 gateConfig
useEffect(() => {
const loadConfig = async () => {
try {
const config = await getTeamGateConfig();
setGateConfig(config);
} catch (error) {
console.error('Failed to load gate config:', error);
}
};
loadConfig();
}, []);
const { data: myApps = [], runAsync: loadMyApps } = useRequest2(
() => listFeatureApps({ getRecentlyChat: true }),
{
manual: false,
refreshDeps: [appId]
}
);
// 初始化聊天框
useMount(async () => {
// 检查gate status如果为false则重定向到应用列表页面
// pc: redirect to latest model chat
if (!appId) {
const apps = await loadMyApps();
if (apps.length === 0) {
toast({
status: 'error',
title: t('common:core.chat.You need to a chat app')
});
router.replace('/dashboard/apps');
} else {
router.replace({
query: {
...router.query,
appId: lastChatAppId || apps[0]._id
}
});
}
}
setSource('online');
});
// Watch appId
useEffect(() => {
setAppId(appId);
}, [appId, setAppId]);
const chatHistoryProviderParams = useMemo(
() => ({ appId, source: ChatSourceEnum.online }),
[appId]
);
const chatRecordProviderParams = useMemo(() => {
return {
appId,
type: GetChatTypeEnum.normal,
chatId: chatId
};
}, [appId, chatId]);
return source === ChatSourceEnum.online ? (
<ChatContextProvider params={chatHistoryProviderParams}>
<ChatItemContextProvider
isResponseDetail={false}
showRouteToAppDetail={isStandalone !== '1'}
showRouteToDatasetDetail={isStandalone !== '1'}
isShowReadRawSource={true}
showNodeStatus
>
<ChatRecordContextProvider params={chatRecordProviderParams}>
<Chat
myApps={myApps}
serverTagList={serverTagList}
serverTagMap={serverTagMap}
gateConfig={gateConfig} // 传递 gateConfig
/>
</ChatRecordContextProvider>
</ChatItemContextProvider>
</ChatContextProvider>
) : null;
};
export async function getServerSideProps(context: any) {
const props = {
...(await serviceSideProps(context, [
'file',
'app',
'chat',
'workflow',
'account_gate',
'common'
]))
};
try {
// 服务端获取 teamId
const { req } = context;
const { teamId } = await authUserPer({
req,
authToken: true,
authApiKey: true,
per: ReadPermissionVal
});
// 服务端获取标签列表
const tagList = await getTeamTags(teamId);
// 创建标签映射
const tagMap: Record<string, any> = {};
tagList.forEach((tag: any) => {
tagMap[tag._id] = tag;
});
return {
props: {
...props,
serverTagList: JSON.parse(JSON.stringify(tagList)),
serverTagMap: JSON.parse(JSON.stringify(tagMap))
}
};
} catch (error) {
console.error('获取标签数据失败:', error);
return { props };
}
}
export default Render;

View File

@@ -108,6 +108,7 @@ const MyApps = ({ MenuIcon }: { MenuIcon: JSX.Element }) => {
const map: Record<AppTypeEnum | 'all', string> = {
all: t('common:core.module.template.Team app'),
[AppTypeEnum.simple]: t('app:type.Simple bot'),
[AppTypeEnum.gate]: t('app:type.Gate'),
[AppTypeEnum.workflow]: t('app:type.Workflow bot'),
[AppTypeEnum.plugin]: t('app:type.Plugin'),
[AppTypeEnum.httpPlugin]: t('app:type.Http plugin'),
@@ -226,6 +227,12 @@ const MyApps = ({ MenuIcon }: { MenuIcon: JSX.Element }) => {
label: t('app:type.MCP tools'),
description: t('app:type.Create mcp tools tip'),
onClick: onOpenCreateMCPTools
},
{
icon: 'support/gate/gateLight',
label: t('app:type.Gate'),
description: t('app:type.Create gate tip'),
onClick: () => setCreateAppType(AppTypeEnum.gate)
}
]
},

View File

@@ -16,6 +16,13 @@ export const getMyApps = (data?: ListAppBody) =>
maxQuantity: 1
});
/**
* 获取门户
*/
export const getMyAppsGate = (data?: ListAppBody) =>
POST<AppListItemType[]>('/core/app/gate/list', data, {
maxQuantity: 1
});
/**
* 创建一个应用
*/

View File

@@ -28,6 +28,7 @@ import type {
getToolVersionListProps,
getToolVersionResponse
} from '@/pages/api/core/app/plugin/getVersionList';
import type { GetBatchPluginsBody } from '@/pages/api/core/app/plugin/getBatchPlugins';
/* ============ team plugin ============== */
export const getTeamPlugTemplates = (data?: ListAppBody) =>
@@ -105,3 +106,9 @@ export const getApiSchemaByUrl = (url: string) =>
timeout: 30000
}
);
// 批量获取插件信息的方法
export const getBatchPlugins = (pluginIds: string[]) =>
POST<Record<string, FlowNodeTemplateType>>('/core/app/plugin/getBatchPlugins', {
pluginIds
} as GetBatchPluginsBody);

View File

@@ -0,0 +1,33 @@
import { GET, POST, PUT, DELETE } from '@/web/common/api/request';
import type { TagSchemaType, TagWithCountType } from '@fastgpt/global/core/app/tags';
export const getTeamTags = (withCount?: boolean) =>
GET<TagSchemaType[] | TagWithCountType[]>(
`/core/app/tags/list${withCount ? '?withCount=true' : ''}`
);
export const createTag = (data: { name: string; color?: string }) =>
POST<TagSchemaType>('/core/app/tags/create', data);
export const updateTag = (data: { tagId: string; name?: string; color?: string }) =>
PUT<TagSchemaType>('/core/app/tags/update', data);
export const deleteTag = (tagId: string) => DELETE<boolean>(`/core/app/tags/delete?tagId=${tagId}`);
export const batchDeleteTags = (tagIds: string[]) =>
DELETE<{ deletedCount: number }>('/core/app/tags/batchDelete', { tagIds });
export const addTagToApp = (appId: string, tagId: string) =>
POST<boolean>('/core/app/tags/addToApp', { appId, tagId });
export const removeTagFromApp = (appId: string, tagId: string) =>
DELETE<boolean>('/core/app/tags/removeFromApp', { appId, tagId });
export const batchAddTagsToApp = (appId: string, tagIds: string[]) =>
POST<boolean>('/core/app/tags/batchAddToApp', { appId, tagIds });
export const batchRemoveTagsFromApp = (appId: string, tagIds: string[]) =>
POST<boolean>('/core/app/tags/batchRemoveFromApp', { appId, tagIds });
export const batchAddAppsToTag = (tagId: string, appIds: string[]) =>
POST<{ success: boolean }>('/core/app/tags/addApptoTag', { tagId, appIds });

View File

@@ -14,7 +14,7 @@ import {
import { i18nT } from '@fastgpt/web/i18n/utils';
export const emptyTemplates: Record<
AppTypeEnum.simple | AppTypeEnum.plugin | AppTypeEnum.workflow,
AppTypeEnum.simple | AppTypeEnum.plugin | AppTypeEnum.workflow | AppTypeEnum.gate,
{
name: string;
avatar: string;
@@ -413,6 +413,249 @@ export const emptyTemplates: Record<
],
edges: [],
chatConfig: {}
},
[AppTypeEnum.gate]: {
avatar: 'core/workflow/template/aiChat',
name: i18nT('app:template.gate'),
nodes: [
{
nodeId: 'userGuide',
name: i18nT('common:core.module.template.system_config'),
intro: i18nT('common:core.module.template.config_params'),
avatar: 'core/workflow/template/systemConfig',
flowNodeType: FlowNodeTypeEnum.systemConfig,
position: {
x: 531.2422736065552,
y: -486.7611729549753
},
version: '481',
inputs: [
{
key: 'welcomeText',
renderTypeList: [FlowNodeInputTypeEnum.hidden],
valueType: WorkflowIOValueTypeEnum.string,
label: 'core.app.Welcome Text',
value: ''
},
{
key: 'variables',
renderTypeList: [FlowNodeInputTypeEnum.hidden],
valueType: WorkflowIOValueTypeEnum.any,
label: 'core.app.Chat Variable',
value: []
},
{
key: 'questionGuide',
valueType: WorkflowIOValueTypeEnum.object,
renderTypeList: [FlowNodeInputTypeEnum.hidden],
label: 'core.app.Question Guide',
value: {
open: true
}
},
{
key: 'tts',
renderTypeList: [FlowNodeInputTypeEnum.hidden],
valueType: WorkflowIOValueTypeEnum.any,
label: '',
value: {
type: 'web'
}
},
{
key: 'whisper',
renderTypeList: [FlowNodeInputTypeEnum.hidden],
valueType: WorkflowIOValueTypeEnum.any,
label: '',
value: {
open: true,
autoSend: false,
autoTTSResponse: false
}
},
{
key: 'scheduleTrigger',
renderTypeList: [FlowNodeInputTypeEnum.hidden],
valueType: WorkflowIOValueTypeEnum.any,
label: '',
value: null
}
],
outputs: []
},
{
nodeId: '448745',
name: i18nT('common:core.module.template.work_start'),
intro: '',
avatar: 'core/workflow/template/workflowStart',
flowNodeType: FlowNodeTypeEnum.workflowStart,
position: {
x: 558.4082376415505,
y: 123.72387429194112
},
version: '481',
inputs: [
{
key: 'userChatInput',
renderTypeList: [FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.textarea],
valueType: WorkflowIOValueTypeEnum.string,
label: i18nT('common:core.module.input.label.user question'),
required: true,
toolDescription: i18nT('common:core.module.input.label.user question')
}
],
outputs: [
{
id: 'userChatInput',
key: 'userChatInput',
label: 'core.module.input.label.user question',
valueType: WorkflowIOValueTypeEnum.string,
type: FlowNodeOutputTypeEnum.static
}
]
},
{
nodeId: 'loOvhld2ZTKa',
name: i18nT('common:core.module.template.ai_chat'),
intro: i18nT('common:core.module.template.ai_chat_intro'),
avatar: 'core/workflow/template/aiChat',
flowNodeType: FlowNodeTypeEnum.chatNode,
showStatus: true,
position: {
x: 1097.7317280958762,
y: -244.16014496351386
},
version: '481',
inputs: [
{
key: 'model',
renderTypeList: [
FlowNodeInputTypeEnum.settingLLMModel,
FlowNodeInputTypeEnum.reference
],
label: 'core.module.input.label.aiModel',
valueType: WorkflowIOValueTypeEnum.string,
value: 'gpt-4o-mini'
},
{
key: 'temperature',
renderTypeList: [FlowNodeInputTypeEnum.hidden],
label: '',
value: undefined,
valueType: WorkflowIOValueTypeEnum.number,
min: 0,
max: 10,
step: 1
},
{
key: 'maxToken',
renderTypeList: [FlowNodeInputTypeEnum.hidden],
label: '',
value: undefined,
valueType: WorkflowIOValueTypeEnum.number,
min: 100,
max: 4000,
step: 50
},
{
key: 'isResponseAnswerText',
renderTypeList: [FlowNodeInputTypeEnum.hidden],
label: '',
value: true,
valueType: WorkflowIOValueTypeEnum.boolean
},
{
key: 'quoteTemplate',
renderTypeList: [FlowNodeInputTypeEnum.hidden],
label: '',
valueType: WorkflowIOValueTypeEnum.string
},
{
key: 'quotePrompt',
renderTypeList: [FlowNodeInputTypeEnum.hidden],
label: '',
valueType: WorkflowIOValueTypeEnum.string
},
{
key: 'systemPrompt',
renderTypeList: [FlowNodeInputTypeEnum.textarea, FlowNodeInputTypeEnum.reference],
max: 3000,
valueType: WorkflowIOValueTypeEnum.string,
label: 'core.ai.Prompt',
description: 'core.app.tip.systemPromptTip',
placeholder: 'core.app.tip.chatNodeSystemPromptTip',
value: ''
},
{
key: 'history',
renderTypeList: [FlowNodeInputTypeEnum.numberInput, FlowNodeInputTypeEnum.reference],
valueType: WorkflowIOValueTypeEnum.chatHistory,
label: 'core.module.input.label.chat history',
required: true,
min: 0,
max: 30,
value: 6
},
{
key: 'userChatInput',
renderTypeList: [FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.textarea],
valueType: WorkflowIOValueTypeEnum.string,
label: i18nT('common:core.module.input.label.user question'),
required: true,
toolDescription: i18nT('common:core.module.input.label.user question'),
value: ['448745', 'userChatInput']
},
{
key: 'quoteQA',
renderTypeList: [FlowNodeInputTypeEnum.settingDatasetQuotePrompt],
label: '',
debugLabel: i18nT('common:core.module.Dataset quote.label'),
description: '',
valueType: WorkflowIOValueTypeEnum.datasetQuote
},
{
key: NodeInputKeyEnum.aiChatReasoning,
renderTypeList: [FlowNodeInputTypeEnum.hidden],
label: '',
valueType: WorkflowIOValueTypeEnum.boolean,
value: true
}
],
outputs: [
{
id: 'history',
key: 'history',
label: 'core.module.output.label.New context',
description: 'core.module.output.description.New context',
valueType: WorkflowIOValueTypeEnum.chatHistory,
type: FlowNodeOutputTypeEnum.static
},
{
id: 'answerText',
key: 'answerText',
label: 'core.module.output.label.Ai response content',
description: 'core.module.output.description.Ai response content',
valueType: WorkflowIOValueTypeEnum.string,
type: FlowNodeOutputTypeEnum.static
}
]
}
],
edges: [
{
source: '448745',
target: 'loOvhld2ZTKa',
sourceHandle: '448745-source-right',
targetHandle: 'loOvhld2ZTKa-target-left'
}
],
chatConfig: {
fileSelectConfig: {
canSelectFile: true,
canSelectImg: true,
maxFiles: 10
}
}
}
};

View File

@@ -27,6 +27,7 @@ type ChatBoxDataType = {
app: {
chatConfig?: AppChatConfigType;
name: string;
intro?: string;
avatar: string;
type: `${AppTypeEnum}`;
pluginInputs: FlowNodeInputItemType[];

View File

@@ -0,0 +1,41 @@
import { GET, PUT } from '@/web/common/api/request';
import type {
putUpdateGateConfigCopyRightData,
putUpdateGateConfigCopyRightResponse,
putUpdateGateConfigData,
putUpdateGateConfigResponse
} from '@fastgpt/global/support/user/team/gate/api.d';
import type { GateSchemaType } from '@fastgpt/global/support/user/team/gate/type';
/**
* 获取门户配置 - Get请求
*/
export const getTeamGateConfig = () => {
return GET<GateSchemaType>('/proApi/support/user/team/gate/config/get');
};
/**
* 创建/更新团队门户配置 - 主页配置
*/
export const updateTeamGateConfig = (data: putUpdateGateConfigData) => {
return PUT<putUpdateGateConfigResponse>('/proApi/support/user/team/gate/config/update', data);
};
/**
* 更新团队门户配置的版权信息
*/
export const updateTeamGateConfigCopyRight = (data: putUpdateGateConfigCopyRightData) => {
return PUT<putUpdateGateConfigCopyRightResponse>(
'/proApi/support/user/team/gate/config/copyright/update',
data
);
};
/**
* 获取团队门户配置的版权信息
*/
export const getTeamGateConfigCopyRight = () => {
return GET<putUpdateGateConfigCopyRightResponse>(
'/proApi/support/user/team/gate/config/copyright/get'
);
};

View File

@@ -0,0 +1,75 @@
import type { ListAppBody } from '@/pages/api/core/app/list';
import { GET, POST } from '@/web/common/api/request';
import type { AppListItemType } from '@fastgpt/global/core/app/type';
// 类型定义
export type OperationResponse = {
status: boolean;
message?: string;
featuredApps?: string[];
};
export type BatchResponse = {
status: boolean;
deletedCount?: number;
updatedCount?: number;
message?: string;
};
export type BatchUpdateRequest = {
updates: {
featuredApps: string[];
}[];
};
// 获取特色应用列表
export const getFeatureApps = () =>
GET<{ featuredApps: string[] }>('/proApi/support/user/team/gate/config/featureApps/get');
// 更新特色应用列表
export const updateFeatureApps = (featuredApps: string[]) =>
POST<OperationResponse>('/proApi/support/user/team/gate/config/featureApps/update', {
featuredApps
});
// 添加特色应用
export const addFeatureApp = (appId: string) =>
POST<OperationResponse>('/proApi/support/user/team/gate/config/featureApps/add', {
appId
});
// 删除特色应用
export const removeFeatureApp = (appId: string) =>
POST<OperationResponse>('/proApi/support/user/team/gate/config/featureApps/remove', {
appId
});
// 重新排序特色应用
export const reorderFeatureApps = (appId: string, toIndex: number) =>
POST<OperationResponse>('/proApi/support/user/team/gate/config/featureApps/reorder', {
appId,
toIndex
});
// 批量删除特色应用
export const batchDeleteFeatureApps = (appIds: string[]) =>
POST<BatchResponse>('/proApi/support/user/team/gate/config/featureApps/batchDelete', {
appIds
});
// 批量更新特色应用
export const batchUpdateFeaturedApps = (updates: BatchUpdateRequest['updates']) =>
POST<BatchResponse>('/proApi/support/user/team/gate/config/featureApps/batchUpdate', {
updates
});
// 清空特色应用列表
export const clearFeatureApps = () =>
POST<OperationResponse>('/proApi/support/user/team/gate/config/featureApps/clear', {});
// 获取特色应用列表
export const listFeatureApps = (data?: ListAppBody) =>
POST<AppListItemType[]>('/proApi/support/user/team/gate/config/featureApps/list', data, {
maxQuantity: 1
});

View File

@@ -0,0 +1,75 @@
import type { ListAppBody } from '@/pages/api/core/app/list';
import { GET, POST } from '@/web/common/api/request';
import type { AppListItemType } from '@fastgpt/global/core/app/type';
// 类型定义
export type OperationResponse = {
status: boolean;
message?: string;
quickApps?: string[];
};
export type BatchResponse = {
status: boolean;
deletedCount?: number;
updatedCount?: number;
message?: string;
};
export type BatchUpdateRequest = {
updates: {
quickApps: string[];
}[];
};
// 获取快速应用列表
export const getQuickApps = () =>
GET<{ quickApps: string[] }>('/proApi/support/user/team/gate/config/quickApps/get');
// 更新快速应用列表
export const updateQuickApps = (quickApps: string[]) =>
POST<OperationResponse>('/proApi/support/user/team/gate/config/quickApps/update', {
quickApps
});
// 添加快速应用
export const addQuickApp = (appId: string) =>
POST<OperationResponse>('/proApi/support/user/team/gate/config/quickApps/add', {
appId
});
// 删除快速应用
export const removeQuickApp = (appId: string) =>
POST<OperationResponse>('/proApi/support/user/team/gate/config/quickApps/remove', {
appId
});
// 重新排序快速应用
export const reorderQuickApps = (appId: string, toIndex: number) =>
POST<OperationResponse>('/proApi/support/user/team/gate/config/quickApps/reorder', {
appId,
toIndex
});
// 批量删除快速应用
export const batchDeleteQuickApps = (appIds: string[]) =>
POST<BatchResponse>('/proApi/support/user/team/gate/config/quickApps/batchDelete', {
appIds
});
// 批量更新快速应用
export const batchUpdateQuickApps = (updates: BatchUpdateRequest['updates']) =>
POST<BatchResponse>('/proApi/support/user/team/gate/config/quickApps/batchUpdate', {
updates
});
// 清空快速应用列表
export const clearQuickApps = () =>
POST<OperationResponse>('/proApi/support/user/team/gate/config/quickApps/clear', {});
// 获取快速应用列表
export const listQuickApps = (data?: ListAppBody) =>
POST<AppListItemType[]>('/proApi/support/user/team/gate/config/quickApps/list', data, {
maxQuantity: 1
});