Gate new (#4928)
* 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:
29
projects/app/src/components/GatePageContainer/index.tsx
Normal file
29
projects/app/src/components/GatePageContainer/index.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -82,6 +82,7 @@ const Navbar = ({ unread }: { unread: number }) => {
|
||||
'/account/team',
|
||||
'/account/usage',
|
||||
'/account/thirdParty',
|
||||
'/account/gateway',
|
||||
'/account/apikey',
|
||||
'/account/setting',
|
||||
'/account/inform',
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,8 @@ export type ChatBoxInputType = {
|
||||
files?: UserInputFileItemType[];
|
||||
isInteractivePrompt?: boolean;
|
||||
hideInUI?: boolean;
|
||||
gateModel?: string;
|
||||
selectedTool?: string | null;
|
||||
};
|
||||
|
||||
export type SendPromptFnType = (
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
688
projects/app/src/pageComponents/account/gateway/AppTable.tsx
Normal file
688
projects/app/src/pageComponents/account/gateway/AppTable.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
607
projects/app/src/pageComponents/account/gateway/HomeTable.tsx
Normal file
607
projects/app/src/pageComponents/account/gateway/HomeTable.tsx
Normal 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'
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
196
projects/app/src/pageComponents/account/gateway/ShareModol.tsx
Normal file
196
projects/app/src/pageComponents/account/gateway/ShareModol.tsx
Normal 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;
|
||||
@@ -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;
|
||||
328
projects/app/src/pageComponents/account/gateway/ToolSelect.tsx
Normal file
328
projects/app/src/pageComponents/account/gateway/ToolSelect.tsx
Normal 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);
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
295
projects/app/src/pageComponents/account/gateway/logs.tsx
Normal file
295
projects/app/src/pageComponents/account/gateway/logs.tsx
Normal 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);
|
||||
@@ -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')
|
||||
}
|
||||
};
|
||||
|
||||
213
projects/app/src/pageComponents/app/detail/Gate/AppCard.tsx
Normal file
213
projects/app/src/pageComponents/app/detail/Gate/AppCard.tsx
Normal 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);
|
||||
104
projects/app/src/pageComponents/app/detail/Gate/ChatGate.tsx
Normal file
104
projects/app/src/pageComponents/app/detail/Gate/ChatGate.tsx
Normal 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);
|
||||
133
projects/app/src/pageComponents/app/detail/Gate/ChatTest.tsx
Normal file
133
projects/app/src/pageComponents/app/detail/Gate/ChatTest.tsx
Normal 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);
|
||||
62
projects/app/src/pageComponents/app/detail/Gate/Edit.tsx
Normal file
62
projects/app/src/pageComponents/app/detail/Gate/Edit.tsx
Normal 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);
|
||||
404
projects/app/src/pageComponents/app/detail/Gate/EditForm.tsx
Normal file
404
projects/app/src/pageComponents/app/detail/Gate/EditForm.tsx
Normal 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);
|
||||
281
projects/app/src/pageComponents/app/detail/Gate/Header.tsx
Normal file
281
projects/app/src/pageComponents/app/detail/Gate/Header.tsx
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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')}: </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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
129
projects/app/src/pageComponents/app/detail/Gate/index.tsx
Normal file
129
projects/app/src/pageComponents/app/detail/Gate/index.tsx
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <></>;
|
||||
}
|
||||
187
projects/app/src/pageComponents/app/detail/useChatGate.tsx
Normal file
187
projects/app/src/pageComponents/app/detail/useChatGate.tsx
Normal 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 <></>;
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
241
projects/app/src/pageComponents/chat/gatechat/AppCard.tsx
Normal file
241
projects/app/src/pageComponents/chat/gatechat/AppCard.tsx
Normal 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;
|
||||
50
projects/app/src/pageComponents/chat/gatechat/FoldButton.tsx
Normal file
50
projects/app/src/pageComponents/chat/gatechat/FoldButton.tsx
Normal 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;
|
||||
@@ -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;
|
||||
664
projects/app/src/pageComponents/chat/gatechat/GateNavBar.tsx
Normal file
664
projects/app/src/pageComponents/chat/gatechat/GateNavBar.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
278
projects/app/src/pages/account/gateway/index.tsx
Normal file
278
projects/app/src/pages/account/gateway/index.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
164
projects/app/src/pages/api/core/app/gate/list.ts
Normal file
164
projects/app/src/pages/api/core/app/gate/list.ts
Normal 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);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
43
projects/app/src/pages/api/core/app/tags/addApptoTag.ts
Normal file
43
projects/app/src/pages/api/core/app/tags/addApptoTag.ts
Normal 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);
|
||||
38
projects/app/src/pages/api/core/app/tags/addToApp.ts
Normal file
38
projects/app/src/pages/api/core/app/tags/addToApp.ts
Normal 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);
|
||||
41
projects/app/src/pages/api/core/app/tags/batchAddToApp.ts
Normal file
41
projects/app/src/pages/api/core/app/tags/batchAddToApp.ts
Normal 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);
|
||||
36
projects/app/src/pages/api/core/app/tags/batchDelete.ts
Normal file
36
projects/app/src/pages/api/core/app/tags/batchDelete.ts
Normal 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);
|
||||
@@ -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);
|
||||
37
projects/app/src/pages/api/core/app/tags/create.ts
Normal file
37
projects/app/src/pages/api/core/app/tags/create.ts
Normal 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);
|
||||
35
projects/app/src/pages/api/core/app/tags/delete.ts
Normal file
35
projects/app/src/pages/api/core/app/tags/delete.ts
Normal 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);
|
||||
33
projects/app/src/pages/api/core/app/tags/list.ts
Normal file
33
projects/app/src/pages/api/core/app/tags/list.ts
Normal 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);
|
||||
39
projects/app/src/pages/api/core/app/tags/removeFromApp.ts
Normal file
39
projects/app/src/pages/api/core/app/tags/removeFromApp.ts
Normal 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);
|
||||
39
projects/app/src/pages/api/core/app/tags/update.ts
Normal file
39
projects/app/src/pages/api/core/app/tags/update.ts
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
337
projects/app/src/pages/api/core/chat/chatGate.ts
Normal file
337
projects/app/src/pages/api/core/chat/chatGate.ts
Normal 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'
|
||||
}
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
278
projects/app/src/pages/api/v1/chat/gate/chat.ts
Normal file
278
projects/app/src/pages/api/v1/chat/gate/chat.ts
Normal 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'
|
||||
}
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
366
projects/app/src/pages/chat/gate/application.tsx
Normal file
366
projects/app/src/pages/chat/gate/application.tsx
Normal 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;
|
||||
395
projects/app/src/pages/chat/gate/index.tsx
Normal file
395
projects/app/src/pages/chat/gate/index.tsx
Normal 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;
|
||||
386
projects/app/src/pages/chat/gate/store.tsx
Normal file
386
projects/app/src/pages/chat/gate/store.tsx
Normal 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;
|
||||
@@ -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)
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
});
|
||||
/**
|
||||
* 创建一个应用
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
33
projects/app/src/web/core/app/api/tags.ts
Normal file
33
projects/app/src/web/core/app/api/tags.ts
Normal 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 });
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ type ChatBoxDataType = {
|
||||
app: {
|
||||
chatConfig?: AppChatConfigType;
|
||||
name: string;
|
||||
intro?: string;
|
||||
avatar: string;
|
||||
type: `${AppTypeEnum}`;
|
||||
pluginInputs: FlowNodeInputItemType[];
|
||||
|
||||
41
projects/app/src/web/support/user/team/gate/api.ts
Normal file
41
projects/app/src/web/support/user/team/gate/api.ts
Normal 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'
|
||||
);
|
||||
};
|
||||
75
projects/app/src/web/support/user/team/gate/featureApp.ts
Normal file
75
projects/app/src/web/support/user/team/gate/featureApp.ts
Normal 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
|
||||
});
|
||||
75
projects/app/src/web/support/user/team/gate/quickApp.ts
Normal file
75
projects/app/src/web/support/user/team/gate/quickApp.ts
Normal 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
|
||||
});
|
||||
Reference in New Issue
Block a user