* feat: Add portal management related icons

* feat: Add portal configuration pages and related translations

* feat: Add new gateway configuration components and icons

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

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

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

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

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

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

* feat: Refactor gateway configuration and chat components

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

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

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

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

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

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

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

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

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

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

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

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

* fix: add isResponseDetail prop to ChatItemContextProvider

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

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

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

* feat: Enhance Gate configuration components and API integration

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

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

* fix: Add isResponseDetail prop to ChatItemContextProvider in ChatPage component

* feat: Enhance ToolSelectModal and GatewayConfig with new functionalities

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

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

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

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

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

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

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

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

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

* feat: Refactor chat components and introduce GateChat functionality

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

* feat: Update GateSideBar styles for improved responsiveness and animation

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

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

* Refactor chat gate components and implement sidebar functionality

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: Implement tag management functionality with CRUD operations

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

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

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

* fix: Update parameter name in getBatchPlugins API for consistency

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

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

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

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

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

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

* feat: Enhance application tag management and localization support

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

* feat: Update localization and refactor chat components

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

* feat: Initialize copyright configuration in GateNavBar component

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

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

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

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

* feat: Refactor gate configuration management

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

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

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

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

* feat: Enhance gate management features and update related components

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

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

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

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

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

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

* refactor: Rename storeEdgesRenderEdge to storeEdge2RenderEdge for consistency

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

View File

@ -27,5 +27,8 @@
},
"markdown.copyFiles.destination": {
"/docSite/content/**/*": "${documentWorkspaceFolder}/docSite/assets/imgs/"
},
"[svg]": {
"editor.defaultFormatter": "jock.svg"
}
}

View File

@ -4,6 +4,9 @@ import { type ErrType } from '../errorCode';
/* dataset: 507000 */
const startCode = 507000;
export enum CommonErrEnum {
methodNotAllowed = 'methodNotAllowed',
systemError = 'systemError',
unauthorized = 'unauthorized',
invalidParams = 'invalidParams',
invalidResource = 'invalidResource',
fileNotFound = 'fileNotFound',
@ -35,6 +38,22 @@ const datasetErr = [
{
statusText: CommonErrEnum.inheritPermissionError,
message: 'error.inheritPermissionError'
},
{
statusText: CommonErrEnum.methodNotAllowed,
message: i18nT('common:code_error.error_message.405')
},
{
statusText: CommonErrEnum.systemError,
message: i18nT('common:code_error.error_message.500')
},
{
statusText: CommonErrEnum.unauthorized,
message: i18nT('common:code_error.error_message.403')
},
{
statusText: CommonErrEnum.invalidParams,
message: i18nT('common:code_error.error_message.422')
}
];
export default datasetErr.reduce((acc, cur, index) => {

View File

@ -7,6 +7,7 @@ import {
} from './type';
export enum AppTypeEnum {
gate = 'gate',
folder = 'folder',
simple = 'simple',
workflow = 'advanced',

24
packages/global/core/app/tags.d.ts vendored Normal file
View File

@ -0,0 +1,24 @@
import { TeamMemberStatusEnum } from 'support/user/team/constant';
import type { SourceMemberType } from 'support/user/type';
export type TagSchemaType = {
_id: string;
teamId: string;
name: string;
color: string;
createTime: Date;
};
export type TagWithCountType = TagSchemaType & {
count: number;
};
export type TagListItemType = {
_id: string;
teamId: string;
name: string;
color: string;
createTime: Date;
count?: number;
sourceMember?: SourceMemberType;
};

View File

@ -65,6 +65,7 @@ export type AppListItemType = {
inheritPermission?: boolean;
private?: boolean;
sourceMember: SourceMemberType;
tags?: string[];
};
export type AppDetailType = AppSchema & {

View File

@ -0,0 +1,31 @@
export type putUpdateGateConfigData = {
status?: boolean;
tools?: GateTool[];
slogan?: string;
placeholderText?: string;
};
export type putUpdateGateConfigResponse = {
status?: boolean;
tools?: string[];
slogan?: string;
placeholderText?: string;
};
export type putUpdateGateConfigCopyRightData = {
name?: string;
logo?: string;
banner?: string;
};
export type putUpdateGateConfigCopyRightResponse = {
name: string;
logo: string;
banner: string;
};
export type getGateConfigCopyRightResponse = {
name: string;
logo: string;
banner: string;
};

View File

@ -0,0 +1,12 @@
export type GateSchemaType = {
teamId: string;
status: boolean;
tools: string[];
featuredApps: string[];
quickApps: string[];
slogan: string;
placeholderText: string;
name: string;
logo: string;
banner: string;
};

View File

@ -64,7 +64,12 @@ const AppSchema = new Schema({
type: Date,
default: () => new Date()
},
tags: [
{
type: Schema.Types.ObjectId,
ref: 'app_tags'
}
],
// role and auth
teamTags: {
type: [String]

View File

@ -0,0 +1,242 @@
import { MongoTag } from './schema';
import { MongoApp } from '../schema';
import { Types } from '../../../common/mongo';
/**
*
*/
export const createTag = async ({
teamId,
name,
color
}: {
teamId: string;
name: string;
color?: string;
}) => {
const tag = await MongoTag.create({
teamId,
name,
color
});
return tag.toObject();
};
/**
*
*/
export const getTeamTags = async (teamId: string) => {
const tags = await MongoTag.find({ teamId }).lean();
return tags;
};
/**
* 使
*/
export const getTagsWithCount = async (teamId: string) => {
return MongoTag.aggregate([
{ $match: { teamId: new Types.ObjectId(teamId) } },
{
$lookup: {
from: 'apps',
localField: '_id',
foreignField: 'tags',
as: 'apps'
}
},
{
$addFields: {
count: { $size: '$apps' }
}
},
{
$project: {
apps: 0
}
}
]);
};
/**
*
*/
export const updateTag = async ({
tagId,
teamId,
name,
color
}: {
tagId: string;
teamId: string;
name?: string;
color?: string;
}) => {
const updateData: Record<string, any> = {};
if (name !== undefined) updateData.name = name;
if (color !== undefined) updateData.color = color;
await MongoTag.updateOne({ _id: tagId, teamId }, { $set: updateData });
return MongoTag.findById(tagId).lean();
};
/**
*
*/
export const deleteTag = async ({ tagId, teamId }: { tagId: string; teamId: string }) => {
// 先从所有 app 中移除该标签
await MongoApp.updateMany({ teamId, tags: tagId }, { $pull: { tags: tagId } });
// 然后删除标签
await MongoTag.deleteOne({ _id: tagId, teamId });
return true;
};
/**
* app
*/
export const addTagToApp = async ({
appId,
tagId,
teamId
}: {
appId: string;
tagId: string;
teamId: string;
}) => {
// 确认标签存在且属于该团队
const tag = await MongoTag.findOne({ _id: tagId, teamId });
if (!tag) {
throw new Error('Tag not found or not authorized');
}
await MongoApp.updateOne({ _id: appId, teamId }, { $addToSet: { tags: tagId } });
return true;
};
/**
* app
*/
export const removeTagFromApp = async ({
appId,
tagId,
teamId
}: {
appId: string;
tagId: string;
teamId: string;
}) => {
await MongoApp.updateOne({ _id: appId, teamId }, { $pull: { tags: tagId } });
return true;
};
/**
*
*/
export const batchDeleteTags = async ({ tagIds, teamId }: { tagIds: string[]; teamId: string }) => {
if (!tagIds || tagIds.length === 0) {
return true;
}
// 先从所有 app 中移除这些标签
await MongoApp.updateMany(
{ teamId, tags: { $in: tagIds } },
{ $pull: { tags: { $in: tagIds } } }
);
// 然后删除标签
const result = await MongoTag.deleteMany({ _id: { $in: tagIds }, teamId });
return { deletedCount: result.deletedCount };
};
/**
* app
*/
export const batchAddTagsToApp = async ({
appId,
tagIds,
teamId
}: {
appId: string;
tagIds: string[];
teamId: string;
}) => {
if (!tagIds || tagIds.length === 0) {
return true;
}
// 确认标签存在且属于该团队
const tags = await MongoTag.find({ _id: { $in: tagIds }, teamId });
if (tags.length !== tagIds.length) {
throw new Error('Some tags not found or not authorized');
}
await MongoApp.updateOne({ _id: appId, teamId }, { $addToSet: { tags: { $each: tagIds } } });
return true;
};
/**
* app
*/
export const batchRemoveTagsFromApp = async ({
appId,
tagIds,
teamId
}: {
appId: string;
tagIds: string[];
teamId: string;
}) => {
if (!tagIds || tagIds.length === 0) {
return true;
}
await MongoApp.updateOne({ _id: appId, teamId }, { $pull: { tags: { $in: tagIds } } });
return true;
};
/**
* app
*/
export const batchAddAppsToTag = async ({
tagId,
appIds,
teamId
}: {
tagId: string;
appIds: string[];
teamId: string;
}) => {
// 确认标签存在且属于该团队
const tag = await MongoTag.findOne({ _id: tagId, teamId });
if (!tag) {
throw new Error('Tag not found or not authorized');
}
// 如果 appIds 为空数组,则移除该标签的所有应用
if (!appIds || appIds.length === 0) {
await MongoApp.updateMany({ teamId, tags: tagId }, { $pull: { tags: tagId } });
return true;
}
// 确认所有 app 都存在且属于该团队
const apps = await MongoApp.find({ _id: { $in: appIds }, teamId });
if (apps.length !== appIds.length) {
throw new Error('Some apps not found or not authorized');
}
// 先从所有应用中移除该标签
await MongoApp.updateMany({ teamId, tags: tagId }, { $pull: { tags: tagId } });
// 然后为指定的应用添加该标签
await MongoApp.updateMany({ _id: { $in: appIds }, teamId }, { $addToSet: { tags: tagId } });
return true;
};

View File

@ -0,0 +1,37 @@
import { TeamCollectionName } from '@fastgpt/global/support/user/team/constant';
import { getMongoModel, Schema } from '../../../common/mongo';
export const TagCollectionName = 'app_tags';
export type TagSchemaType = {
_id: string;
teamId: string;
name: string;
color: string;
createTime: Date;
};
const TagSchema = new Schema({
teamId: {
type: Schema.Types.ObjectId,
ref: TeamCollectionName,
required: true
},
name: {
type: String,
required: true
},
color: {
type: String,
default: '#3370ff'
},
createTime: {
type: Date,
default: () => new Date()
}
});
// 创建复合索引:按团队和名称确保唯一性
TagSchema.index({ teamId: 1, name: 1 }, { unique: true });
export const MongoTag = getMongoModel<TagSchemaType>(TagCollectionName, TagSchema);

View File

@ -0,0 +1,435 @@
import { MongoTeamGate, gateCollectionName } from './schema';
import { Types } from '../../../../common/mongo';
/**
*
*/
export const createGateConfig = async ({ teamId }: { teamId: string }) => {
const gate = await MongoTeamGate.create({
teamId
});
return gate.toObject();
};
/**
*
*/
export const getGateConfig = async (teamId: string) => {
const gate = await MongoTeamGate.findOne({ teamId }).lean();
return gate;
};
/**
*
*/
export const updateGateConfig = async ({
teamId,
status,
name,
banner,
logo,
tools,
placeholderText
}: {
teamId: string;
status?: boolean;
name?: string;
banner?: string;
logo?: string;
tools?: string[];
placeholderText?: string;
}) => {
const updateData: Record<string, any> = {};
if (status !== undefined) updateData.status = status;
if (name !== undefined) updateData.name = name;
if (banner !== undefined) updateData.banner = banner;
if (logo !== undefined) updateData.logo = logo;
if (tools !== undefined) updateData.tools = tools;
if (placeholderText !== undefined) updateData.placeholderText = placeholderText;
// 使用 upsert 选项,如果不存在则创建
await MongoTeamGate.updateOne({ teamId }, { $set: updateData }, { upsert: true });
return MongoTeamGate.findOne({ teamId }).lean();
};
/**
*
*/
export const deleteGateConfig = async (teamId: string) => {
await MongoTeamGate.deleteOne({ teamId });
return true;
};
/**
*
*/
export const toggleGateStatus = async ({ teamId, status }: { teamId: string; status: boolean }) => {
await MongoTeamGate.updateOne({ teamId }, { $set: { status } }, { upsert: true });
return MongoTeamGate.findOne({ teamId }).lean();
};
/**
*
*/
export const updateGateTools = async ({ teamId, tools }: { teamId: string; tools: string[] }) => {
await MongoTeamGate.updateOne({ teamId }, { $set: { tools } }, { upsert: true });
return MongoTeamGate.findOne({ teamId }).lean();
};
/**
*
*/
export const addGateTool = async ({ teamId, tool }: { teamId: string; tool: string }) => {
await MongoTeamGate.updateOne({ teamId }, { $addToSet: { tools: tool } }, { upsert: true });
return MongoTeamGate.findOne({ teamId }).lean();
};
/**
*
*/
export const removeGateTool = async ({ teamId, tool }: { teamId: string; tool: string }) => {
await MongoTeamGate.updateOne({ teamId }, { $pull: { tools: tool } });
return MongoTeamGate.findOne({ teamId }).lean();
};
/**
*
*/
export const updateFeaturedApps = async ({
teamId,
featuredApps
}: {
teamId: string;
featuredApps: string[];
}) => {
// 将字符串数组转换为 ObjectId 数组
const objectIdArray = featuredApps.map((id) => new Types.ObjectId(id));
await MongoTeamGate.updateOne(
{ teamId },
{ $set: { featuredApps: objectIdArray } },
{ upsert: true }
);
return MongoTeamGate.findOne({ teamId }).lean();
};
/**
*
*/
export const addFeaturedApp = async ({ teamId, appId }: { teamId: string; appId: string }) => {
await MongoTeamGate.updateOne(
{ teamId },
{ $addToSet: { featuredApps: new Types.ObjectId(appId) } },
{ upsert: true }
);
return MongoTeamGate.findOne({ teamId }).lean();
};
/**
*
*/
export const removeFeaturedApp = async ({ teamId, appId }: { teamId: string; appId: string }) => {
await MongoTeamGate.updateOne({ teamId }, { $pull: { featuredApps: new Types.ObjectId(appId) } });
return MongoTeamGate.findOne({ teamId }).lean();
};
/**
*
* @param teamId ID
* @param appId ID
* @param toIndex
*/
export const moveFeatureAppToPosition = async ({
teamId,
appId,
toIndex
}: {
teamId: string;
appId: string;
toIndex: number;
}) => {
const objectId = new Types.ObjectId(appId);
// 获取当前配置
const config = await MongoTeamGate.findOne({ teamId }).lean();
if (!config || !config.featuredApps) {
throw new Error('团队配置不存在');
}
const apps = [...config.featuredApps];
const currentIndex = apps.findIndex((id) => id.toString() === appId);
if (currentIndex === -1) {
throw new Error('应用不在特色应用列表中');
}
// 移动数组元素
const [movedApp] = apps.splice(currentIndex, 1);
apps.splice(toIndex, 0, movedApp);
// 一次性更新
await MongoTeamGate.updateOne({ teamId }, { $set: { featuredApps: apps } });
return MongoTeamGate.findOne({ teamId }).lean();
};
/**
*
* @param teamId ID
* @param orderedTools
*/
export const reorderTools = async ({
teamId,
orderedTools
}: {
teamId: string;
orderedTools: string[];
}) => {
await MongoTeamGate.updateOne({ teamId }, { $set: { tools: orderedTools } });
return MongoTeamGate.findOne({ teamId }).lean();
};
/**
*
*/
export const batchUpdateGateConfigs = async (
configs: {
teamId: string;
status?: boolean;
banner?: string;
logo?: string;
tools?: string[];
placeholderText?: string;
}[]
) => {
const operations = configs.map((config) => {
const { teamId, ...updateData } = config;
return {
updateOne: {
filter: { teamId },
update: { $set: updateData },
upsert: true
}
};
});
if (operations.length === 0) {
return true;
}
await MongoTeamGate.bulkWrite(operations);
return true;
};
/**
*
*/
export const batchUpdateFeaturedApps = async (
updates: {
teamId: string;
featuredApps: string[];
}[]
) => {
const operations = updates.map((update) => {
const { teamId, featuredApps } = update;
// 将字符串数组转换为 ObjectId 数组
const objectIdArray = featuredApps.map((id) => new Types.ObjectId(id));
return {
updateOne: {
filter: { teamId },
update: { $set: { featuredApps: objectIdArray } },
upsert: true
}
};
});
if (operations.length === 0) {
return true;
}
await MongoTeamGate.bulkWrite(operations);
return true;
};
/**
*
*/
export const batchUpdateToolsOrder = async (
updates: {
teamId: string;
tools: string[];
}[]
) => {
const operations = updates.map((update) => {
const { teamId, tools } = update;
return {
updateOne: {
filter: { teamId },
update: { $set: { tools } },
upsert: true
}
};
});
if (operations.length === 0) {
return true;
}
await MongoTeamGate.bulkWrite(operations);
return true;
};
/**
*
* @param teamId ID
* @param appIds ID数组
*/
export const batchDeleteFeaturedApps = async ({
teamId,
appIds
}: {
teamId: string;
appIds: string[];
}) => {
if (!appIds || appIds.length === 0) {
return false;
}
await MongoTeamGate.updateOne(
{ teamId },
{ $pull: { featuredApps: { $in: appIds.map((id) => new Types.ObjectId(id)) } } }
);
return true;
};
/**
*
*/
export const updateQuickApps = async ({
teamId,
quickApps
}: {
teamId: string;
quickApps: string[];
}) => {
await MongoTeamGate.updateOne({ teamId }, { $set: { quickApps } }, { upsert: true });
return MongoTeamGate.findOne({ teamId }).lean();
};
/**
*
*/
export const addQuickApp = async ({ teamId, appId }: { teamId: string; appId: string }) => {
await MongoTeamGate.updateOne(
{ teamId },
{ $addToSet: { quickApps: new Types.ObjectId(appId) } },
{ upsert: true }
);
return MongoTeamGate.findOne({ teamId }).lean();
};
/**
*
*/
export const removeQuickApp = async ({ teamId, appId }: { teamId: string; appId: string }) => {
await MongoTeamGate.updateOne({ teamId }, { $pull: { quickApps: new Types.ObjectId(appId) } });
return MongoTeamGate.findOne({ teamId }).lean();
};
/**
*
* @param teamId ID
* @param appId ID
* @param toIndex
*/
export const moveQuickAppToPosition = async ({
teamId,
appId,
toIndex
}: {
teamId: string;
appId: string;
toIndex: number;
}) => {
const objectId = new Types.ObjectId(appId);
// 获取当前配置
const config = await MongoTeamGate.findOne({ teamId }).lean();
if (!config || !config.quickApps) {
throw new Error('团队配置不存在');
}
const apps = [...config.quickApps];
const currentIndex = apps.findIndex((id) => id.toString() === appId);
if (currentIndex === -1) {
throw new Error('应用不在快速应用列表中');
}
// 移动数组元素
const [movedApp] = apps.splice(currentIndex, 1);
apps.splice(toIndex, 0, movedApp);
// 一次性更新
await MongoTeamGate.updateOne({ teamId }, { $set: { quickApps: apps } });
return MongoTeamGate.findOne({ teamId }).lean();
};
/**
*
*/
export const batchUpdateQuickApps = async (
updates: {
teamId: string;
quickApps: string[];
}[]
) => {
const operations = updates.map((update) => {
const { teamId, quickApps } = update;
// 将字符串数组转换为 ObjectId 数组
const objectIdArray = quickApps.map((id) => new Types.ObjectId(id));
return {
updateOne: {
filter: { teamId },
update: { $set: { quickApps: objectIdArray } },
upsert: true
}
};
});
if (operations.length === 0) {
return true;
}
await MongoTeamGate.bulkWrite(operations);
return true;
};
/**
*
* @param teamId ID
* @param appIds ID数组
*/
export const batchDeleteQuickApps = async ({
teamId,
appIds
}: {
teamId: string;
appIds: string[];
}) => {
if (!appIds || appIds.length === 0) {
return false;
}
await MongoTeamGate.updateOne(
{ teamId },
{ $pull: { quickApps: { $in: appIds.map((id) => new Types.ObjectId(id)) } } }
);
return true;
};

View File

@ -0,0 +1,45 @@
import { TeamCollectionName } from '@fastgpt/global/support/user/team/constant';
import { Schema, getMongoModel } from '../../../../common/mongo';
import type { GateSchemaType } from '@fastgpt/global/support/user/team/gate/type';
export const gateCollectionName = 'team_gate_config';
const GateConfigSchema = new Schema({
teamId: {
type: Schema.Types.ObjectId,
ref: TeamCollectionName
},
status: {
type: Boolean,
default: true
},
name: {
type: String
},
banner: {
type: String
},
logo: {
type: String
},
tools: {
type: [String]
},
placeholderText: {
type: String
},
featuredApps: [
{
type: Schema.Types.ObjectId,
ref: 'apps'
}
],
quickApps: [
{
type: Schema.Types.ObjectId,
ref: 'apps'
}
]
});
export const MongoTeamGate = getMongoModel<GateSchemaType>(gateCollectionName, GateConfigSchema);

View File

@ -17,6 +17,11 @@ const TeamSchema = new Schema({
type: String,
default: '/icon/logo.svg'
},
// todo :banner
banner: {
type: String,
default: '/icon/banner.svg'
},
createTime: {
type: Date,
default: () => Date.now()

View File

@ -474,6 +474,31 @@ export const iconPaths = {
'support/user/userLightSmall': () => import('./icons/support/user/userLightSmall.svg'),
'support/user/usersFill': () => import('./icons/support/user/usersFill.svg'),
'support/user/usersLight': () => import('./icons/support/user/usersLight.svg'),
'support/gate/gateLight': () => import('./icons/support/gate/gateLight.svg'),
'support/gate/chat/sidebar/chatGray': () =>
import('./icons/support/gate/chat/sidebar/chatGray.svg'),
'support/gate/chat/historySlider/new_chat': () =>
import('./icons/support/gate/chat/historySlider/new_chat.svg'),
'support/gate/chat/historySlider/clear-all': () =>
import('./icons/support/gate/chat/historySlider/clear-all.svg'),
'support/gate/chat/historySlider/chevron-right2': () =>
import('./icons/support/gate/chat/historySlider/chevron-right2.svg'),
'support/gate/chat/toolkitLine': () => import('./icons/support/gate/chat/toolkitLine.svg'),
'support/gate/chat/historySlider/chevron-left2': () =>
import('./icons/support/gate/chat/historySlider/chevron-left2.svg'),
'support/gate/chat/sidebar/appGray': () =>
import('./icons/support/gate/chat/sidebar/appGray.svg'),
'support/gate/chat/voiceGray': () => import('./icons/support/gate/chat/voiceGray.svg'),
'support/gate/chat/fileGray': () => import('./icons/support/gate/chat/fileGray.svg'),
'support/gate/chat/paperclip': () => import('./icons/support/gate/chat/paperclip.svg'),
'support/gate/chat/imageGray': () => import('./icons/support/gate/chat/imageGray.svg'),
'support/gate/chat/sidebar/CollapseButton': () =>
import('./icons/support/gate/chat/sidebar/CollapseButton.svg'),
'support/gate/home/savePrimary': () => import('./icons/support/gate/home/savePrimary.svg'),
'support/gate/home/shareLight': () => import('./icons/support/gate/home/shareLight.svg'),
'support/gate/home/sharePrimary': () => import('./icons/support/gate/home/sharePrimary.svg'),
'support/gate/home/upload': () => import('./icons/support/gate/home/upload.svg'),
'support/gate/home/add': () => import('./icons/support/gate/home/add.svg'),
text: () => import('./icons/text.svg'),
union: () => import('./icons/union.svg'),
user: () => import('./icons/user.svg'),

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.6665 3.0263C10.5079 3.01307 10.2763 3.01095 9.84294 3.01095H7.16646C6.45348 3.01095 5.96581 3.01152 5.58819 3.04048C5.21887 3.06881 5.02616 3.12054 4.89105 3.18516C4.57275 3.3374 4.32387 3.57549 4.17134 3.85649C4.11194 3.96593 4.05991 4.12789 4.03072 4.4633C4.00053 4.81012 3.9998 5.26058 3.9998 5.93236V14.0677C3.9998 14.7394 4.00053 15.1899 4.03072 15.5367C4.05991 15.8721 4.11194 16.0341 4.17134 16.1435C4.32387 16.4245 4.57275 16.6626 4.89105 16.8149C5.02616 16.8795 5.21887 16.9312 5.58819 16.9595C5.96581 16.9885 6.45348 16.9891 7.16646 16.9891H12.4998C13.2128 16.9891 13.7005 16.9885 14.0781 16.9595C14.4474 16.9312 14.6401 16.8795 14.7752 16.8149C15.0935 16.6626 15.3424 16.4245 15.4949 16.1435C15.5543 16.0341 15.6063 15.8721 15.6355 15.5367C15.6657 15.1899 15.6665 14.7394 15.6665 14.0677V8.42632C15.6665 8.04949 15.6642 7.84193 15.654 7.70438L12.8076 7.70439C12.5963 7.70441 12.3934 7.70443 12.2221 7.69129C12.0356 7.67698 11.8167 7.64346 11.5952 7.53756C11.2863 7.38982 11.0253 7.14923 10.8582 6.84149C10.736 6.61626 10.6972 6.39184 10.681 6.2055C10.6664 6.03826 10.6664 5.84201 10.6665 5.64647C10.6665 5.63747 10.6665 5.62847 10.6665 5.61947V3.0263ZM11.8068 1.61342C11.6211 1.53551 11.4284 1.47397 11.2311 1.42951C10.8511 1.34388 10.4571 1.34404 9.92517 1.34426C9.89812 1.34427 9.87072 1.34428 9.84294 1.34428L7.13385 1.34428C6.4615 1.34427 5.90966 1.34427 5.46075 1.37869C4.99631 1.41431 4.57159 1.49047 4.17193 1.68162C3.54942 1.97936 3.03339 2.45928 2.70655 3.0614C2.49347 3.45396 2.40926 3.87156 2.37033 4.31878C2.33311 4.74646 2.33312 5.26987 2.33313 5.89642V14.1036C2.33312 14.7301 2.33311 15.2536 2.37033 15.6812C2.40926 16.1285 2.49347 16.5461 2.70655 16.9386C3.03339 17.5407 3.54942 18.0207 4.17193 18.3184C4.57159 18.5096 4.99631 18.5857 5.46075 18.6213C5.90965 18.6558 6.46147 18.6557 7.13382 18.6557H12.5324C13.2048 18.6557 13.7566 18.6558 14.2055 18.6213C14.67 18.5857 15.0947 18.5096 15.4943 18.3184C16.1168 18.0207 16.6329 17.5407 16.9597 16.9386C17.1728 16.5461 17.257 16.1285 17.2959 15.6812C17.3332 15.2536 17.3331 14.7301 17.3331 14.1036V8.42632C17.3331 8.39763 17.3331 8.3693 17.3332 8.3413C17.3335 7.85013 17.3337 7.46273 17.2381 7.08878C17.1885 6.89509 17.1202 6.70705 17.0344 6.52696C17.0287 6.51433 17.0227 6.50187 17.0163 6.4896C16.9608 6.37678 16.8983 6.26719 16.8292 6.16139C16.6188 5.83909 16.3328 5.57093 15.9611 5.22237C15.9405 5.20307 15.9197 5.18353 15.8986 5.16372L13.2417 2.66977C13.2216 2.65089 13.2018 2.63225 13.1822 2.61385C12.8076 2.26189 12.5241 1.99554 12.186 1.80108C12.0761 1.73786 11.9628 1.68091 11.8466 1.63043C11.8335 1.62442 11.8202 1.61875 11.8068 1.61342ZM12.3331 4.10281V5.61947C12.3331 5.82534 12.3337 5.94445 12.339 6.02864C12.3423 6.02893 12.3458 6.02922 12.3495 6.0295C12.4492 6.03715 12.5869 6.03772 12.8331 6.03772H14.3944L12.3331 4.10281Z" fill="#707070"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1,7 @@
<svg width="16" height="20" viewBox="0 0 16 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon/solid/chevron-right2">
<path id="Rectangle 3101"
d="M11.0474 14.2501C11.0474 15.735 9.25219 16.4786 8.20225 15.4286L3.95213 11.1785C3.30126 10.5276 3.30126 9.47236 3.95213 8.82149L8.20225 4.57138C9.25219 3.52143 11.0474 4.26505 11.0474 5.74989L11.0474 14.2501Z"
fill="currentColor" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@ -0,0 +1,7 @@
<svg width="16" height="20" viewBox="0 0 16 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon/solid/chevron-right2">
<path id="Rectangle 3101"
d="M4.95255 5.74989C4.95255 4.26505 6.74778 3.52143 7.79772 4.57138L12.0478 8.82149C12.6987 9.47236 12.6987 10.5276 12.0478 11.1785L7.79772 15.4286C6.74778 16.4786 4.95255 15.735 4.95255 14.2501V5.74989Z"
fill="currentColor" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 436 B

View File

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon/line/clear-all">
<path id="Union" fill-rule="evenodd" clip-rule="evenodd"
d="M7.33331 2.63809C7.33331 2.49081 7.4527 2.37142 7.59997 2.37142H8.39997C8.54725 2.37142 8.66664 2.49081 8.66664 2.63809V4.2C8.66664 4.9732 9.29344 5.6 10.0666 5.6L12.861 5.6C13.0083 5.6 13.1277 5.71939 13.1277 5.86666V6.66666C13.1277 6.81394 13.0083 6.93333 12.861 6.93333L3.13891 6.93333C2.99164 6.93333 2.87224 6.81394 2.87224 6.66666V5.86666C2.87224 5.71939 2.99164 5.6 3.13891 5.6L5.93331 5.6C6.70651 5.6 7.33331 4.97319 7.33331 4.2V2.63809ZM7.59997 1.03809C6.71632 1.03809 5.99997 1.75443 5.99997 2.63809V4.2C5.99997 4.23681 5.97012 4.26666 5.93331 4.26666L3.13891 4.26666C2.25526 4.26666 1.53891 4.98301 1.53891 5.86666V6.66666C1.53891 7.41125 2.04753 8.03705 2.73629 8.21558L1.47841 12.628C1.18709 13.6499 1.9545 14.6667 3.01711 14.6667H13.0318C14.0831 14.6667 14.8487 13.6701 14.5778 12.6543L13.384 8.17923C14.0109 7.96253 14.461 7.36717 14.461 6.66666V5.86666C14.461 4.98301 13.7447 4.26666 12.861 4.26666L10.0666 4.26666C10.0298 4.26666 9.99997 4.23681 9.99997 4.2V2.63809C9.99997 1.75443 9.28363 1.03809 8.39997 1.03809H7.59997ZM4.30936 8.26696H11.8226C11.9434 8.26696 12.0491 8.34817 12.0803 8.46489L13.2895 12.9979C13.3347 13.1672 13.207 13.3333 13.0318 13.3333H10.6269C10.627 13.3291 10.6271 13.3249 10.6271 13.3206L10.6409 11.4144C10.6436 11.0462 10.3472 10.7456 9.97907 10.7429C9.61089 10.7403 9.31026 11.0366 9.30759 11.4047L9.2938 13.311C9.29374 13.3184 9.29381 13.3259 9.294 13.3333H6.87303L6.87324 13.3206L6.88704 11.4144C6.88971 11.0462 6.5934 10.7456 6.22522 10.7429C5.85704 10.7403 5.55641 11.0366 5.55374 11.4047L5.53995 13.311C5.53989 13.3184 5.53996 13.3259 5.54015 13.3333H3.01711C2.84 13.3333 2.71211 13.1639 2.76066 12.9936L4.05291 8.46052C4.08557 8.34596 4.19024 8.26696 4.30936 8.26696Z"
fill="currentColor" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,8 @@
<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.3834 2.86546C11.3834 3.27967 11.0476 3.61546 10.6334 3.61546H6.05212C4.39495 3.61546 3.05139 4.95892 3.05139 6.61546V10.3215C3.05158 11.5943 3.84602 12.6857 4.9696 13.1201L5.19885 13.1977L5.54646 13.3005C5.76638 13.3656 5.94445 13.5277 6.02982 13.7406L6.16492 14.0774C6.29685 14.4057 6.68973 14.54 6.99503 14.3611L8.59462 13.4243C8.70956 13.357 8.84034 13.3215 8.97353 13.3215H12.8014C14.4065 13.3215 15.7178 12.0607 15.7985 10.4761L15.8021 10.3215V7.97703C15.8021 7.56281 16.1379 7.22703 16.5521 7.22703C16.9663 7.22703 17.3021 7.56281 17.3021 7.97703V10.3215C17.3019 12.8066 15.2865 14.8215 12.8014 14.8215H9.38096C9.24764 14.8215 9.11674 14.857 9.00172 14.9245L6.57434 16.3471C6.10009 16.625 5.48906 16.4168 5.28381 15.907L4.90857 14.9729C4.82314 14.7602 4.64369 14.6019 4.42992 14.5193C2.74626 13.8685 1.55158 12.2347 1.55139 10.3215V6.61546C1.55139 4.13017 3.56684 2.11546 6.05212 2.11546H10.6334C11.0476 2.11546 11.3834 2.45124 11.3834 2.86546Z"
fill="#3370FF" />
<path
d="M14.9027 1.52901C15.2541 1.52912 15.5392 1.81403 15.5392 2.16549V3.43844H16.8121C17.1636 3.43851 17.4486 3.72344 17.4486 4.07491C17.4485 4.42632 17.1635 4.71132 16.8121 4.71139H15.5392V5.98434C15.5391 6.33571 15.2541 6.62071 14.9027 6.62081C14.5513 6.62081 14.2663 6.33577 14.2662 5.98434V4.71139H12.9933C12.6418 4.71139 12.3569 4.42637 12.3568 4.07491C12.3568 3.7234 12.6418 3.43844 12.9933 3.43844H14.2662V2.16549C14.2662 1.81397 14.5512 1.52901 14.9027 1.52901Z"
fill="#3370FF" />
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,4 @@
<svg width="18" height="16" viewBox="0 0 18 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.61372 6.52442C6.30407 6.52442 6.86372 5.96478 6.86372 5.27442C6.86372 4.58407 6.30407 4.02442 5.61372 4.02442C4.92336 4.02442 4.36371 4.58407 4.36371 5.27442C4.36371 5.96478 4.92336 6.52442 5.61372 6.52442Z" fill="#707070"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.666656 5.31489C0.666656 3.63474 0.666656 2.79466 0.993637 2.15292C1.28126 1.58843 1.7402 1.12949 2.30468 0.841873C2.94642 0.514893 3.7865 0.514893 5.46666 0.514893H12.5333C14.2135 0.514893 15.0536 0.514893 15.6953 0.841873C16.2598 1.12949 16.7187 1.58843 17.0063 2.15292C17.3333 2.79466 17.3333 3.63473 17.3333 5.31489V10.6852C17.3333 12.3654 17.3333 13.2054 17.0063 13.8472C16.7187 14.4117 16.2598 14.8706 15.6953 15.1582C15.0536 15.4852 14.2135 15.4852 12.5333 15.4852H5.46666C3.7865 15.4852 2.94642 15.4852 2.30468 15.1582C1.7402 14.8706 1.28126 14.4117 0.993637 13.8472C0.666656 13.2054 0.666656 12.3654 0.666656 10.6852V5.31489ZM5.46666 2.18156H12.5333C13.4009 2.18156 13.9514 2.18286 14.368 2.2169C14.7652 2.24935 14.8919 2.30306 14.9386 2.32688C15.1895 2.45472 15.3935 2.65869 15.5213 2.90957C15.5452 2.95633 15.5989 3.08303 15.6313 3.48022C15.6654 3.89686 15.6667 4.44731 15.6667 5.31489V10.6852C15.6667 10.6952 15.6667 10.7052 15.6667 10.7152L11.6244 6.67291C11.2989 6.34747 10.7713 6.34747 10.4459 6.67291L3.36552 13.7533C3.17216 13.7239 3.09546 13.6906 3.06134 13.6732C2.81045 13.5454 2.60648 13.3414 2.47865 13.0905C2.45483 13.0438 2.40111 12.9171 2.36866 12.5199C2.33462 12.1032 2.33332 11.5528 2.33332 10.6852V5.31489C2.33332 4.44731 2.33462 3.89686 2.36866 3.48022C2.40111 3.08303 2.45483 2.95633 2.47865 2.90957C2.60648 2.65869 2.81045 2.45472 3.06134 2.32688C3.10809 2.30306 3.23479 2.24935 3.63198 2.2169C4.04863 2.18286 4.59908 2.18156 5.46666 2.18156ZM11.0351 8.44068L5.65725 13.8185H12.5333C13.4009 13.8185 13.9514 13.8172 14.368 13.7832C14.7652 13.7508 14.8919 13.697 14.9386 13.6732C15.1895 13.5454 15.3935 13.3414 15.5213 13.0905C15.5316 13.0704 15.5474 13.0354 15.5647 12.9703L11.0351 8.44068Z" fill="#707070"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon/line/paperclip">
<path id="Icon (Stroke)" fill-rule="evenodd" clip-rule="evenodd"
d="M13.001 3.12035C12.4123 3.12035 11.8477 3.3542 11.4315 3.77046L4.63705 10.5649C3.94333 11.2586 3.55361 12.1995 3.55361 13.1805C3.55361 14.1616 3.94333 15.1025 4.63705 15.7962C5.33077 16.4899 6.27165 16.8796 7.25271 16.8796C8.23377 16.8796 9.17465 16.4899 9.86837 15.7962L16.6628 9.00179C16.9515 8.71306 17.4196 8.71306 17.7084 9.00179C17.9971 9.29051 17.9971 9.75863 17.7084 10.0474L10.9139 16.8418C9.94292 17.8128 8.62594 18.3583 7.25271 18.3583C5.87948 18.3583 4.5625 17.8128 3.59148 16.8418C2.62046 15.8708 2.07495 14.5538 2.07495 13.1805C2.07495 11.8073 2.62046 10.4903 3.59148 9.51931L10.3859 2.72489C11.0795 2.03133 12.0201 1.64169 13.001 1.64169C13.9818 1.64169 14.9225 2.03133 15.6161 2.72489C16.3096 3.41846 16.6993 4.35913 16.6993 5.33997C16.6993 6.32082 16.3096 7.26149 15.6161 7.95506L8.81425 14.7495C8.39814 15.1656 7.83378 15.3993 7.24532 15.3993C6.65685 15.3993 6.09249 15.1656 5.67638 14.7495C5.26028 14.3334 5.02651 13.769 5.02651 13.1805C5.02651 12.5921 5.26028 12.0277 5.67638 11.6116L11.9536 5.34181C12.2425 5.05325 12.7106 5.05353 12.9991 5.34242C13.2877 5.63132 13.2874 6.09943 12.9985 6.38799L6.72195 12.6572C6.58334 12.796 6.50517 12.9844 6.50517 13.1805C6.50517 13.3768 6.58315 13.5651 6.72195 13.7039C6.86076 13.8427 7.04902 13.9207 7.24532 13.9207C7.44162 13.9207 7.62988 13.8427 7.76868 13.7039L14.5705 6.90949C14.9866 6.49326 15.2206 5.92852 15.2206 5.33997C15.2206 4.75129 14.9868 4.18672 14.5705 3.77046C14.1542 3.3542 13.5897 3.12035 13.001 3.12035Z"
fill="currentColor" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,8 @@
<svg width="18" height="16" viewBox="0 0 18 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.91227 5.23185C10.2356 4.95244 10.724 4.98763 11.0036 5.31079C11.2829 5.63414 11.2479 6.12338 10.9246 6.40291L9.16113 7.92797L10.8774 9.51977C11.192 9.81221 11.2103 10.305 10.9181 10.62C10.6259 10.9347 10.1339 10.9531 9.81868 10.6615L7.54085 8.54972C7.37406 8.39504 7.29205 8.18359 7.29346 7.97273C7.2559 7.71942 7.34339 7.45278 7.55143 7.27286L9.91227 5.23185Z"
fill="currentcolor" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M13.0072 0.108149C15.6489 0.242083 17.7498 2.42669 17.75 5.10164V10.8984L17.7435 11.1555C17.614 13.7122 15.5638 15.7622 13.0072 15.8918L12.75 15.8984H5.25L4.99284 15.8918C2.43607 15.7623 0.385993 13.7123 0.25651 11.1555L0.25 10.8984V5.10164C0.250186 2.42663 2.35104 0.24199 4.99284 0.108149L5.25 0.101639H12.75L13.0072 0.108149ZM5.92546 14.2317H12.75C14.5909 14.2316 16.0833 12.7392 16.0833 10.8984V5.10164C16.0831 3.26092 14.5907 1.76842 12.75 1.76831H5.92546V14.2317ZM4.39225 1.8798C3.02015 2.24419 1.99539 3.4618 1.92074 4.92993L1.91667 5.10164V10.8984C1.91667 12.4427 2.96708 13.7408 4.39225 14.1194V1.8798Z"
fill="currentcolor" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,14 @@
<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0.10022 1.2C0.10022 0.537258 0.637478 0 1.30022 0H6.90022C7.56296 0 8.10022 0.537258 8.10022 1.2V6.8C8.10022 7.46274 7.56296 8 6.90022 8H1.30022C0.637478 8 0.10022 7.46274 0.10022 6.8V1.2ZM2.10022 6V2H6.10022V6H2.10022Z"
fill="#8A95A7" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M10.1002 1.2C10.1002 0.537258 10.6375 0 11.3002 0H16.9002C17.563 0 18.1002 0.537258 18.1002 1.2V6.8C18.1002 7.46274 17.563 8 16.9002 8H11.3002C10.6375 8 10.1002 7.46274 10.1002 6.8V1.2ZM12.1002 6V2H16.1002V6H12.1002Z"
fill="#8A95A7" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.3002 10C10.6375 10 10.1002 10.5373 10.1002 11.2V16.8C10.1002 17.4627 10.6375 18 11.3002 18H16.9002C17.563 18 18.1002 17.4627 18.1002 16.8V11.2C18.1002 10.5373 17.563 10 16.9002 10H11.3002ZM12.1002 12V16H16.1002V12H12.1002Z"
fill="#8A95A7" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0.10022 11.2C0.10022 10.5373 0.637478 10 1.30022 10H6.90022C7.56296 10 8.10022 10.5373 8.10022 11.2V16.8C8.10022 17.4627 7.56296 18 6.90022 18H1.30022C0.637478 18 0.10022 17.4627 0.10022 16.8V11.2ZM2.10022 16V12H6.10022V16H2.10022Z"
fill="#8A95A7" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,18 @@
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon/line/chat">
<g id="Union">
<path
d="M6.18697 8.52243C6.77251 8.52244 7.24715 8.99732 7.24735 9.58281C7.24735 10.1685 6.77263 10.6432 6.18697 10.6432C5.60129 10.6432 5.12659 10.1685 5.12659 9.58281C5.12679 8.99731 5.60142 8.52243 6.18697 8.52243Z"
fill="currentColor" />
<path
d="M10.0004 8.52243C10.586 8.52244 11.0606 8.99732 11.0608 9.58281C11.0608 10.1685 10.5861 10.6432 10.0004 10.6432C9.41477 10.6432 8.94006 10.1685 8.94006 9.58281C8.94027 8.99731 9.4149 8.52243 10.0004 8.52243Z"
fill="currentColor" />
<path
d="M13.8139 8.52243C14.3994 8.52249 14.8741 8.99735 14.8743 9.58281C14.8743 10.1684 14.3995 10.6431 13.8139 10.6432C13.2282 10.6432 12.7535 10.1685 12.7535 9.58281C12.7537 8.99731 13.2284 8.52243 13.8139 8.52243Z"
fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M13.7504 2.5247C16.5117 2.52492 18.7504 4.76342 18.7504 7.5247V11.6425C18.7502 14.4037 16.5109 16.6425 13.7496 16.6425H9.94918C9.80105 16.6425 9.65542 16.6824 9.52763 16.7573L6.83069 18.3377C6.30374 18.6465 5.62482 18.4151 5.39677 17.8486L4.9801 16.811C4.88518 16.5747 4.6854 16.3983 4.44788 16.3064C2.57725 15.5833 1.24985 13.7682 1.24963 11.6425V7.5247C1.24963 4.76328 3.48902 2.5247 6.25045 2.5247H13.7504ZM6.25045 4.19137C4.40914 4.19137 2.9163 5.68411 2.9163 7.5247V11.6425C2.91651 13.0567 3.79923 14.2694 5.04765 14.7521L5.30237 14.8383L5.68892 14.9523C5.93318 15.0246 6.1312 15.2049 6.22603 15.4414L6.37577 15.8157C6.52236 16.1805 6.9586 16.3293 7.29781 16.1307L9.07515 15.0898C9.20286 15.015 9.34872 14.9759 9.4967 14.9759H13.7496C15.5331 14.9759 16.9901 13.5749 17.0797 11.8143L17.0838 11.6425V7.5247C17.0838 5.74165 15.6833 4.28501 13.9222 4.19544L13.7504 4.19137H6.25045Z"
fill="currentColor" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M12.81 4.4538H12.8072C14.1971 4.45385 14.8993 4.45754 15.4374 4.73173C15.9173 4.97621 16.3074 5.36631 16.5518 5.84612C16.8298 6.3916 16.8298 7.10566 16.8298 8.5338V12.42C16.8298 13.8481 16.8298 14.5622 16.5518 15.1077C16.3074 15.5875 15.9173 15.9776 15.4374 16.2221C14.892 16.5 14.1779 16.5 12.7498 16.5H5.24992C3.82179 16.5 3.10772 16.5 2.56225 16.2221C2.08243 15.9776 1.69233 15.5875 1.44786 15.1077C1.16992 14.5622 1.16992 13.8481 1.16992 12.42V8.5338C1.16992 7.10566 1.16992 6.3916 1.44786 5.84612C1.69233 5.36631 2.08243 4.97621 2.56225 4.73173C3.10772 4.4538 3.82179 4.4538 5.24992 4.4538H5.36574V4.19994C5.36574 2.70877 6.57457 1.49994 8.06574 1.49994H10.11C11.6012 1.49994 12.81 2.70877 12.81 4.19994V4.4538ZM8.06574 2.99994H10.11C10.7727 2.99994 11.31 3.5372 11.31 4.19994V4.4538H6.86574V4.19994C6.86574 3.5372 7.403 2.99994 8.06574 2.99994ZM12.7498 5.9538H5.24992C4.5111 5.9538 4.0472 5.95496 3.69724 5.98356C3.36478 6.01072 3.26927 6.05497 3.24323 6.06824C3.04566 6.16891 2.88503 6.32954 2.78437 6.52711C2.7711 6.55314 2.72684 6.64865 2.69968 6.98111C2.68387 7.17461 2.67645 7.40293 2.67297 7.6933H15.3267C15.3232 7.40293 15.3158 7.17461 15.3 6.98111C15.2728 6.64865 15.2286 6.55314 15.2153 6.52711C15.1147 6.32954 14.954 6.16891 14.7565 6.06824C14.7304 6.05497 14.6349 6.01072 14.3025 5.98356C13.9525 5.95496 13.4886 5.9538 12.7498 5.9538ZM15.3298 9.0433H10.9765V11.392C10.9765 11.6405 10.775 11.842 10.5265 11.842H7.47313C7.2246 11.842 7.02313 11.6405 7.02313 11.392V9.0433H2.66992V12.42C2.66992 13.1588 2.67109 13.6227 2.69968 13.9727C2.72684 14.3052 2.7711 14.4007 2.78437 14.4267C2.88503 14.6243 3.04566 14.7849 3.24323 14.8856C3.26927 14.8988 3.36478 14.9431 3.69723 14.9702C4.0472 14.9988 4.5111 15 5.24992 15H12.7498C13.4886 15 13.9525 14.9988 14.3025 14.9702C14.6349 14.9431 14.7304 14.8988 14.7565 14.8856C14.954 14.7849 15.1147 14.6243 15.2153 14.4267C15.2286 14.4007 15.2728 14.3052 15.3 13.9727C15.3286 13.6227 15.3298 13.1588 15.3298 12.42V9.0433ZM9.62651 9.0433H8.37313V10.492H9.62651V9.0433Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.91635 2.02287C8.46897 1.47025 9.21848 1.15979 10 1.15979C10.7815 1.15979 11.531 1.47025 12.0837 2.02287C12.6363 2.57549 12.9467 3.325 12.9467 4.10652V9.99999C12.9467 10.7815 12.6363 11.531 12.0837 12.0836C11.531 12.6363 10.7815 12.9467 10 12.9467C9.21848 12.9467 8.46897 12.6363 7.91635 12.0836C7.36373 11.531 7.05327 10.7815 7.05327 9.99999V4.10652C7.05327 3.325 7.36373 2.57549 7.91635 2.02287ZM10 2.63316C9.60924 2.63316 9.23448 2.78839 8.95817 3.06469C8.68186 3.341 8.52663 3.71576 8.52663 4.10652V9.99999C8.52663 10.3907 8.68186 10.7655 8.95817 11.0418C9.23448 11.3181 9.60924 11.4734 10 11.4734C10.3908 11.4734 10.7655 11.3181 11.0418 11.0418C11.3181 10.7655 11.4734 10.3907 11.4734 9.99999V4.10652C11.4734 3.71576 11.3181 3.341 11.0418 3.06469C10.7655 2.78839 10.3908 2.63316 10 2.63316ZM4.84322 7.78994C5.25008 7.78994 5.5799 8.11976 5.5799 8.52662V9.99999C5.5799 11.1723 6.04559 12.2965 6.87452 13.1255C7.70345 13.9544 8.82772 14.4201 10 14.4201C11.1723 14.4201 12.2966 13.9544 13.1255 13.1255C13.9544 12.2965 14.4201 11.1723 14.4201 9.99999V8.52662C14.4201 8.11976 14.7499 7.78994 15.1568 7.78994C15.5636 7.78994 15.8935 8.11976 15.8935 8.52662V9.99999C15.8935 11.563 15.2725 13.0621 14.1673 14.1673C13.2372 15.0974 12.0281 15.6846 10.7367 15.8472V17.3668H12.9467C13.3536 17.3668 13.6834 17.6966 13.6834 18.1035C13.6834 18.5104 13.3536 18.8402 12.9467 18.8402H7.05327C6.64641 18.8402 6.31659 18.5104 6.31659 18.1035C6.31659 17.6966 6.64641 17.3668 7.05327 17.3668H9.26332V15.8472C7.97191 15.6846 6.76285 15.0974 5.83269 14.1673C4.72745 13.0621 4.10654 11.563 4.10654 9.99999V8.52662C4.10654 8.11976 4.43636 7.78994 4.84322 7.78994Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<!-- 保持原路径不变 -->
<path fill-rule="evenodd" clip-rule="evenodd"
d="M9.57021 1.39896C9.85172 1.32274 10.1484 1.32274 10.4299 1.39896C10.757 1.48751 11.0321 1.70357 11.2481 1.87322C11.2689 1.88955 11.2891 1.90544 11.3088 1.92075L16.8123 6.20128C16.8337 6.21793 16.855 6.23443 16.8761 6.25081C17.1809 6.48733 17.4525 6.6981 17.655 6.97191C17.8327 7.21212 17.9651 7.48272 18.0456 7.77043C18.1374 8.09838 18.137 8.4422 18.1366 8.82801C18.1365 8.85472 18.1365 8.88163 18.1365 8.90875V14.8115C18.1365 15.2387 18.1365 15.6085 18.1116 15.9134C18.0853 16.2355 18.0271 16.5578 17.8688 16.8684C17.6333 17.3306 17.2575 17.7064 16.7954 17.9418C16.4847 18.1001 16.1625 18.1584 15.8403 18.1847C15.5354 18.2096 15.1656 18.2096 14.7385 18.2096H5.26167C4.8345 18.2096 4.4647 18.2096 4.15985 18.1847C3.83769 18.1584 3.51541 18.1001 3.20478 17.9418C2.7426 17.7064 2.36685 17.3306 2.13136 16.8684C1.97308 16.5578 1.91484 16.2355 1.88852 15.9134C1.86362 15.6085 1.86363 15.2387 1.86364 14.8115L1.86364 8.90875C1.86364 8.88163 1.86361 8.85472 1.86358 8.82801C1.86315 8.4422 1.86276 8.09838 1.95456 7.77043C2.03509 7.48272 2.16744 7.21211 2.3451 6.97191C2.54761 6.6981 2.81925 6.48733 3.12405 6.25081C3.14515 6.23443 3.16642 6.21793 3.18782 6.20128L8.69136 1.92075C8.71105 1.90544 8.73129 1.88954 8.75208 1.87322C8.96808 1.70357 9.24317 1.48751 9.57021 1.39896ZM8.39904 16.5429H11.6011V11.3715C11.6011 11.1371 11.6005 11.0071 11.5934 10.9141C11.5005 10.9071 11.3705 10.9065 11.1361 10.9065H8.86404C8.62967 10.9065 8.49969 10.9071 8.40672 10.9141C8.39966 11.0071 8.39904 11.1371 8.39904 11.3715V16.5429ZM13.2678 16.5429L13.2678 11.3451C13.2678 11.1407 13.2678 10.9405 13.2539 10.7706C13.2387 10.5838 13.2026 10.3617 13.0885 10.1379C12.9308 9.82839 12.6792 9.57677 12.3697 9.41907C12.1459 9.30502 11.9238 9.26889 11.7369 9.25362C11.5671 9.23975 11.3668 9.23977 11.1624 9.2398H8.83772C8.63333 9.23977 8.43305 9.23975 8.26321 9.25362C8.07637 9.26889 7.85429 9.30502 7.63045 9.41907C7.32096 9.57676 7.06934 9.82839 6.91165 10.1379C6.79759 10.3617 6.76146 10.5838 6.7462 10.7706C6.73232 10.9405 6.73235 11.1407 6.73237 11.3451L6.73237 16.5429H5.29363C4.82543 16.5429 4.52439 16.5422 4.29557 16.5236C4.07648 16.5057 3.99795 16.4754 3.96143 16.4568C3.81286 16.3811 3.69207 16.2603 3.61637 16.1118C3.59776 16.0753 3.56756 15.9967 3.54965 15.7776C3.53096 15.5488 3.53031 15.2478 3.53031 14.7796V8.90875C3.53031 8.39549 3.53755 8.29823 3.55954 8.21968C3.58542 8.12719 3.62797 8.0402 3.68508 7.96299C3.73358 7.89741 3.80592 7.83198 4.21106 7.51687L9.71459 3.23634C9.86132 3.12222 9.94061 3.06115 10.0001 3.02088C10.0595 3.06115 10.1388 3.12222 10.2856 3.23634L15.7891 7.51687C16.1942 7.83198 16.2666 7.89741 16.3151 7.96299C16.3722 8.0402 16.4147 8.12719 16.4406 8.21968C16.4626 8.29823 16.4698 8.39549 16.4698 8.90875V14.7796C16.4698 15.2478 16.4692 15.5488 16.4505 15.7776C16.4326 15.9967 16.4024 16.0753 16.3838 16.1118C16.3081 16.2603 16.1873 16.3811 16.0387 16.4568C16.0022 16.4754 15.9237 16.5057 15.7046 16.5236C15.4758 16.5422 15.1747 16.5429 14.7065 16.5429H13.2678Z" />
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5 3.33301C10.9603 3.33301 11.3334 3.7061 11.3334 4.16634V9.16634H16.3334C16.7936 9.16634 17.1667 9.53944 17.1667 9.99967C17.1667 10.4599 16.7936 10.833 16.3334 10.833H11.3334V15.833C11.3334 16.2932 10.9603 16.6663 10.5 16.6663C10.0398 16.6663 9.66671 16.2932 9.66671 15.833V10.833H4.66671C4.20647 10.833 3.83337 10.4599 3.83337 9.99967C3.83337 9.53944 4.20647 9.16634 4.66671 9.16634H9.66671V4.16634C9.66671 3.7061 10.0398 3.33301 10.5 3.33301Z" fill="#3370FF"/>
</svg>

After

Width:  |  Height:  |  Size: 619 B

View File

@ -0,0 +1,12 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_19520_1493)">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M6.99991 2.05339C4.26775 2.05339 2.0529 4.26824 2.0529 7.0004C2.0529 9.73256 4.26775 11.9474 6.99991 11.9474C9.73207 11.9474 11.9469 9.73256 11.9469 7.0004C11.9469 4.26824 9.73207 2.05339 6.99991 2.05339ZM0.88623 7.0004C0.88623 3.62391 3.62342 0.886719 6.99991 0.886719C10.3764 0.886719 13.1136 3.62391 13.1136 7.0004C13.1136 10.3769 10.3764 13.1141 6.99991 13.1141C3.62342 13.1141 0.88623 10.3769 0.88623 7.0004ZM6.41657 4.78826C6.41657 4.46609 6.67774 4.20493 6.99991 4.20493H7.00544C7.3276 4.20493 7.58877 4.46609 7.58877 4.78826C7.58877 5.11042 7.3276 5.37159 7.00544 5.37159H6.99991C6.67774 5.37159 6.41657 5.11042 6.41657 4.78826ZM6.99991 6.41706C7.32207 6.41706 7.58324 6.67823 7.58324 7.0004V9.21253C7.58324 9.5347 7.32207 9.79587 6.99991 9.79587C6.67774 9.79587 6.41657 9.5347 6.41657 9.21253V7.0004C6.41657 6.67823 6.67774 6.41706 6.99991 6.41706Z"
fill="#3370FF" />
</g>
<defs>
<clipPath id="clip0_19520_1493">
<rect width="14" height="14" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,5 @@
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.5538 3.5208C11.4875 3.5049 11.4085 3.50008 11.0059 3.50008H6V5.30008C6 5.52247 6.00058 5.64416 6.00773 5.7316C6.00801 5.73508 6.0083 5.73838 6.00859 5.74149C6.0117 5.74178 6.01499 5.74206 6.01848 5.74235C6.10592 5.74949 6.22761 5.75008 6.45 5.75008H11.55C11.7724 5.75008 11.8941 5.74949 11.9815 5.74235C11.985 5.74206 11.9883 5.74178 11.9914 5.74149C11.9917 5.73838 11.992 5.73508 11.9923 5.7316C11.9994 5.64416 12 5.52247 12 5.30008V3.81211C11.8588 3.67386 11.8143 3.63743 11.7706 3.6106C11.7035 3.56947 11.6303 3.53917 11.5538 3.5208ZM13.2802 2.96965L13.2333 2.92269C13.2184 2.90784 13.2037 2.8931 13.1891 2.87848C12.9735 2.66249 12.7835 2.47206 12.5543 2.33164C12.353 2.20827 12.1335 2.11736 11.9039 2.06224C11.6426 1.9995 11.3735 1.99975 11.0684 2.00004C11.0477 2.00006 11.0269 2.00008 11.0059 2.00008L5.81903 2.00008C5.63224 2.00007 5.45554 2.00007 5.28855 2.00105C5.27578 2.0004 5.26293 2.00008 5.25 2.00008C5.23345 2.00008 5.21702 2.00061 5.20074 2.00167C4.86462 2.00446 4.5692 2.01214 4.31113 2.03322C3.88956 2.06767 3.50203 2.14159 3.13803 2.32706C2.57354 2.61468 2.1146 3.07362 1.82698 3.6381C1.64151 4.00211 1.56759 4.38963 1.53315 4.81121C1.49998 5.2171 1.49999 5.71537 1.5 6.3191V12.681C1.49999 13.2848 1.49998 13.783 1.53315 14.1889C1.56759 14.6105 1.64151 14.998 1.82698 15.362C2.1146 15.9265 2.57354 16.3855 3.13803 16.6731C3.50203 16.8586 3.88956 16.9325 4.31113 16.9669C4.5692 16.988 4.86462 16.9957 5.20074 16.9985C5.21702 16.9995 5.23345 17.0001 5.25 17.0001C5.26293 17.0001 5.27578 16.9997 5.28855 16.9991C5.45553 17.0001 5.63222 17.0001 5.81901 17.0001H12.181C12.3678 17.0001 12.5445 17.0001 12.7115 16.9991C12.7242 16.9997 12.7371 17.0001 12.75 17.0001C12.7666 17.0001 12.783 16.9995 12.7993 16.9985C13.1354 16.9957 13.4308 16.988 13.6889 16.9669C14.1104 16.9325 14.498 16.8586 14.862 16.6731C15.4265 16.3855 15.8854 15.9265 16.173 15.362C16.3585 14.998 16.4324 14.6105 16.4669 14.1889C16.5 13.7831 16.5 13.2848 16.5 12.6811V7.49419C16.5 7.47319 16.5 7.45236 16.5 7.4317C16.5003 7.12653 16.5006 6.85748 16.4378 6.59614C16.3827 6.36656 16.2918 6.14709 16.1684 5.94577C16.028 5.71662 15.8376 5.52655 15.6216 5.31096C15.607 5.29637 15.5922 5.28165 15.5774 5.2668L13.2802 2.96965C13.2803 2.96971 13.2802 2.96958 13.2802 2.96965ZM13.5 5.31074V5.32399C13.5 5.51328 13.5 5.69763 13.4873 5.85375C13.4733 6.02519 13.4402 6.22749 13.3365 6.43106C13.1927 6.71331 12.9632 6.94278 12.681 7.08659C12.4774 7.19031 12.2751 7.22336 12.1037 7.23737C11.9476 7.25012 11.7632 7.2501 11.5739 7.25008L6.45 7.25008C6.44202 7.25008 6.43404 7.25008 6.42608 7.25008C6.23679 7.2501 6.05245 7.25012 5.89633 7.23737C5.72488 7.22336 5.52258 7.19031 5.31902 7.08659C5.03677 6.94278 4.8073 6.7133 4.66349 6.43106C4.55977 6.22749 4.52672 6.02519 4.51271 5.85375C4.49995 5.69763 4.49998 5.51329 4.5 5.324C4.5 5.31603 4.5 5.30806 4.5 5.30008V3.52322C4.47733 3.52479 4.4551 3.52646 4.43328 3.52824C4.10447 3.5551 3.93631 3.6038 3.81902 3.66357C3.53677 3.80738 3.3073 4.03685 3.16349 4.31909C3.10372 4.43639 3.05503 4.60454 3.02816 4.93335C3.00058 5.27092 3 5.70764 3 6.35008V12.6501C3 13.2925 3.00058 13.7292 3.02816 14.0668C3.05503 14.3956 3.10372 14.5638 3.16349 14.6811C3.3073 14.9633 3.53677 15.1928 3.81902 15.3366C3.93631 15.3964 4.10447 15.445 4.43328 15.4719C4.4551 15.4737 4.47733 15.4754 4.5 15.4769L4.5 11.4262C4.49998 11.2369 4.49995 11.0525 4.51271 10.8964C4.52672 10.725 4.55977 10.5227 4.66349 10.3191C4.8073 10.0368 5.03677 9.80738 5.31902 9.66357C5.52258 9.55984 5.72488 9.52679 5.89633 9.51278C6.05245 9.50003 6.2368 9.50005 6.42609 9.50007H11.5739C11.7632 9.50005 11.9475 9.50003 12.1037 9.51278C12.2751 9.52679 12.4774 9.55984 12.681 9.66357C12.9632 9.80738 13.1927 10.0368 13.3365 10.3191C13.4402 10.5227 13.4733 10.725 13.4873 10.8964C13.5 11.0525 13.5 11.2369 13.5 11.4262L13.5 15.4769C13.5227 15.4754 13.5449 15.4737 13.5667 15.4719C13.8955 15.445 14.0637 15.3964 14.181 15.3366C14.4632 15.1928 14.6927 14.9633 14.8365 14.6811C14.8963 14.5638 14.945 14.3956 14.9718 14.0668C14.9994 13.7292 15 13.2925 15 12.6501V7.49419C15 7.0916 14.9952 7.01255 14.9793 6.94631C14.9609 6.86979 14.9306 6.79663 14.8895 6.72952C14.8539 6.67144 14.8014 6.61213 14.5167 6.32746L13.5 5.31074ZM12 15.5001V11.4501C12 11.2277 11.9994 11.106 11.9923 11.0186C11.992 11.0151 11.9917 11.0118 11.9914 11.0087C11.9883 11.0084 11.985 11.0081 11.9815 11.0078C11.8941 11.0007 11.7724 11.0001 11.55 11.0001H6.45C6.22761 11.0001 6.10592 11.0007 6.01848 11.0078C6.01499 11.0081 6.0117 11.0084 6.00859 11.0087C6.0083 11.0118 6.00801 11.0151 6.00773 11.0186C6.00058 11.106 6 11.2277 6 11.4501V15.5001H12Z"
fill="#3370FF" />
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -0,0 +1,5 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M6.72916 1.85097L6.69716 1.85085C5.98981 1.84827 5.41301 1.84617 4.94539 1.88309C4.46228 1.92122 4.0275 2.00464 3.62407 2.21292C3.01781 2.52591 2.5263 3.01564 2.21111 3.62075C2.00154 4.02311 1.91644 4.45656 1.8765 4.93842C1.83788 5.40451 1.83788 5.97916 1.83789 6.68347V11.3274C1.83788 12.0263 1.83788 12.5969 1.87635 13.0602C1.91616 13.5396 2.001 13.9708 2.20943 14.3721C2.52252 14.9748 3.01395 15.4662 3.61667 15.7793C4.01791 15.9877 4.44916 16.0726 4.92851 16.1124C5.39183 16.1509 5.96247 16.1508 6.66137 16.1508H11.3384C12.0373 16.1508 12.6079 16.1509 13.0712 16.1124C13.5506 16.0726 13.9818 15.9877 14.3831 15.7793C14.9858 15.4662 15.4772 14.9748 15.7903 14.3721C15.9987 13.9708 16.0836 13.5396 16.1234 13.0602C16.1619 12.5969 16.1619 12.0263 16.1619 11.3274V9.50554C16.1619 9.09133 15.8261 8.75554 15.4119 8.75554C14.9976 8.75554 14.6619 9.09133 14.6619 9.50554V11.2954C14.6619 12.0341 14.6613 12.5422 14.6285 12.9361C14.5966 13.321 14.5379 13.5291 14.4592 13.6806C14.2884 14.0094 14.0204 14.2774 13.6916 14.4482C13.5402 14.5269 13.332 14.5856 12.9471 14.6175C12.5532 14.6502 12.045 14.6508 11.3064 14.6508H6.69339C5.9547 14.6508 5.44655 14.6502 5.05265 14.6175C4.66773 14.5856 4.45958 14.5269 4.30814 14.4482C3.97938 14.2774 3.71132 14.0094 3.54055 13.6806C3.46188 13.5292 3.40317 13.321 3.37121 12.9361C3.33849 12.5422 3.33789 12.034 3.33789 11.2953V6.71554C3.33789 5.97142 3.33849 5.45912 3.37138 5.06231C3.40354 4.67419 3.46264 4.46502 3.54147 4.31369C3.7137 3.98302 3.98088 3.71681 4.31217 3.54578C4.46347 3.46767 4.67333 3.40923 5.06343 3.37843C5.46198 3.34697 5.97664 3.34825 6.72372 3.35096C7.34592 3.35322 7.97166 3.35468 8.54751 3.35468C8.96173 3.35468 9.29751 3.0189 9.29751 2.60468C9.29751 2.19047 8.96173 1.85468 8.54751 1.85468C7.97404 1.85468 7.35021 1.85322 6.72916 1.85097ZM8.26254 8.66553C7.96964 8.95843 7.96964 9.4333 8.26254 9.7262C8.55543 10.0191 9.0303 10.0191 9.3232 9.72619L14.6619 4.38754V6.5054C14.6619 6.91962 14.9976 7.2554 15.4119 7.2554C15.8261 7.2554 16.1619 6.91962 16.1619 6.5054V2.6698C16.1622 2.66102 16.1623 2.6522 16.1623 2.64334C16.1623 2.22913 15.8265 1.89334 15.4123 1.89334C15.4122 1.89334 15.4124 1.89334 15.4123 1.89334H11.5503C11.136 1.89334 10.8003 2.22913 10.8003 2.64334C10.8003 3.05756 11.136 3.39334 11.5503 3.39334H13.5347L8.26254 8.66553Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M5.47674 0.0561965L5.44118 0.0560671C4.65524 0.053204 4.01435 0.0508692 3.49477 0.0918841C2.95798 0.134258 2.47489 0.226947 2.02664 0.458364C1.35301 0.806127 0.806891 1.35027 0.456684 2.02263C0.223824 2.46969 0.129267 2.9513 0.0848958 3.48669C0.0419755 4.00458 0.0419831 4.64308 0.0419925 5.42564V10.5855C0.041983 11.3621 0.0419753 11.9961 0.0847264 12.5109C0.128958 13.0435 0.223226 13.5227 0.454811 13.9685C0.802688 14.6382 1.34873 15.1842 2.01842 15.5321C2.46424 15.7637 2.9434 15.858 3.47601 15.9022C3.99081 15.945 4.62486 15.945 5.40142 15.9449H10.5981C11.3746 15.945 12.0087 15.945 12.5235 15.9022C13.0561 15.858 13.5353 15.7637 13.9811 15.5321C14.6508 15.1842 15.1968 14.6382 15.5447 13.9685C15.7763 13.5227 15.8705 13.0435 15.9148 12.5109C15.9575 11.9962 15.9575 11.3621 15.9575 10.5856V8.56128C15.9575 8.10104 15.5844 7.72795 15.1242 7.72795C14.6639 7.72795 14.2908 8.10104 14.2908 8.56128V10.55C14.2908 11.3708 14.2902 11.9354 14.2538 12.373C14.2183 12.8007 14.1531 13.032 14.0657 13.2002C13.8759 13.5655 13.5781 13.8634 13.2128 14.0531C13.0445 14.1405 12.8132 14.2057 12.3856 14.2413C11.9479 14.2776 11.3833 14.2783 10.5625 14.2783H5.43699C4.61622 14.2783 4.05161 14.2776 3.61395 14.2413C3.18626 14.2057 2.95498 14.1405 2.78671 14.0531C2.42142 13.8634 2.12358 13.5655 1.93383 13.2002C1.84642 13.032 1.78119 12.8007 1.74568 12.373C1.70933 11.9353 1.70866 11.3707 1.70866 10.5499V5.46128C1.70866 4.63448 1.70933 4.06526 1.74587 3.62435C1.78161 3.19311 1.84727 2.9607 1.93485 2.79256C2.12623 2.42514 2.42309 2.12936 2.79119 1.93932C2.95931 1.85253 3.19248 1.7876 3.62592 1.75338C4.06876 1.71843 4.6406 1.71984 5.47069 1.72285C6.16203 1.72536 6.85729 1.72699 7.49713 1.72699C7.95737 1.72699 8.33046 1.3539 8.33046 0.893659C8.33046 0.433422 7.95737 0.0603261 7.49713 0.0603261C6.85993 0.0603261 6.1668 0.0587019 5.47674 0.0561965ZM7.18049 7.62794C6.85505 7.95338 6.85505 8.48101 7.18049 8.80645C7.50592 9.13189 8.03356 9.13189 8.359 8.80645L14.2908 2.87461V5.22779C14.2908 5.68803 14.6639 6.06113 15.1242 6.06113C15.5844 6.06113 15.9575 5.68803 15.9575 5.22779V0.966006C15.9578 0.95625 15.958 0.946452 15.958 0.936614C15.958 0.476377 15.5849 0.103281 15.1247 0.103281C15.1246 0.103281 15.1248 0.103281 15.1247 0.103281H10.8335C10.3733 0.103281 10.0002 0.476377 10.0002 0.936614C10.0002 1.39685 10.3733 1.76995 10.8335 1.76995H13.0385L7.18049 7.62794Z"
fill="#3370FF" />
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,7 @@
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon/line/export">
<path id="Icon (Stroke)" fill-rule="evenodd" clip-rule="evenodd"
d="M11.2928 2.58684C11.6834 2.19631 12.3165 2.19631 12.707 2.58684L16.707 6.58684C17.0976 6.97736 17.0976 7.61053 16.707 8.00105C16.3165 8.39158 15.6834 8.39158 15.2928 8.00105L12.9999 5.70816V15.2939C12.9999 15.8462 12.5522 16.2939 11.9999 16.2939C11.4477 16.2939 10.9999 15.8462 10.9999 15.2939V5.70816L8.70705 8.00105C8.31652 8.39158 7.68336 8.39158 7.29283 8.00105C6.90231 7.61053 6.90231 6.97736 7.29283 6.58684L11.2928 2.58684ZM2.99994 11.2939C3.55222 11.2939 3.99994 11.7417 3.99994 12.2939V16.4939C3.99994 17.3505 4.00072 17.9328 4.03749 18.3829C4.07331 18.8213 4.13824 19.0455 4.21793 19.2019C4.40967 19.5783 4.71564 19.8842 5.09196 20.076C5.24836 20.1556 5.47256 20.2206 5.91098 20.2564C6.36107 20.2932 6.94336 20.2939 7.79994 20.2939H16.1999C17.0565 20.2939 17.6388 20.2932 18.0889 20.2564C18.5273 20.2206 18.7515 20.1556 18.9079 20.076C19.2842 19.8842 19.5902 19.5783 19.782 19.2019C19.8616 19.0455 19.9266 18.8213 19.9624 18.3829C19.9992 17.9328 19.9999 17.3505 19.9999 16.4939V12.2939C19.9999 11.7417 20.4477 11.2939 20.9999 11.2939C21.5522 11.2939 21.9999 11.7417 21.9999 12.2939V16.5353C22 17.3402 22 18.0046 21.9557 18.5458C21.9098 19.1079 21.8113 19.6246 21.564 20.1099C21.1805 20.8626 20.5686 21.4745 19.8159 21.858C19.3306 22.1053 18.8139 22.2038 18.2518 22.2498C17.7106 22.294 17.0462 22.294 16.2413 22.2939H7.75862C6.95366 22.294 6.2893 22.294 5.74811 22.2498C5.18602 22.2038 4.66931 22.1053 4.18398 21.858C3.43133 21.4745 2.81941 20.8626 2.43591 20.1099C2.18862 19.6246 2.09006 19.1079 2.04413 18.5458C1.99992 18.0046 1.99993 17.3402 1.99994 16.5352L1.99994 12.2939C1.99994 11.7417 2.44766 11.2939 2.99994 11.2939Z"
fill="#3370FF" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,289 @@
import type { ForwardedRef } from 'react';
import React, {
useRef,
forwardRef,
useMemo,
useEffect,
useImperativeHandle,
useState
} from 'react';
import {
Menu,
MenuList,
MenuItem,
Button,
useDisclosure,
MenuButton,
Box,
Flex,
Input,
Tag,
HStack
} from '@chakra-ui/react';
import type { ButtonProps, MenuItemProps } from '@chakra-ui/react';
import MyIcon from '../Icon';
import { useRequest2 } from '../../../hooks/useRequest';
import MyDivider from '../MyDivider';
import type { useScrollPagination } from '../../../hooks/useScrollPagination';
import Avatar from '../Avatar';
/** Props
* value: 选中的值
* placeholder: 占位符
* list: 列表数据
* isLoading: 是否加载中
* ScrollData: 分页滚动数据控制器 [useScrollPagination]
* */
export type GateSelectProps<T = any> = Omit<ButtonProps, 'onChange'> & {
value?: T;
placeholder?: string;
isSearch?: boolean;
list: {
alias?: string;
icon?: string;
iconSize?: string;
label: string | React.ReactNode;
description?: string;
value: T;
showBorder?: boolean;
tagColor?: string;
tagText?: string;
}[];
isLoading?: boolean;
onChange?: (val: T) => any | Promise<any>;
ScrollData?: ReturnType<typeof useScrollPagination>['ScrollData'];
};
const GateSelect = <T = any,>(
{
placeholder,
value,
isSearch = false,
width = '100%',
list = [],
onChange,
isLoading = false,
ScrollData,
...props
}: GateSelectProps<T>,
ref: ForwardedRef<{
focus: () => void;
}>
) => {
const ButtonRef = useRef<HTMLButtonElement>(null);
const MenuListRef = useRef<HTMLDivElement>(null);
const SelectedItemRef = useRef<HTMLDivElement>(null);
const SearchInputRef = useRef<HTMLInputElement>(null);
const menuItemStyles: MenuItemProps = {
borderRadius: 'sm',
py: 2,
display: 'flex',
alignItems: 'center',
_hover: {
backgroundColor: 'myGray.100'
},
_notLast: {
mb: 1
}
};
const { isOpen, onOpen, onClose } = useDisclosure();
const selectItem = useMemo(() => list.find((item) => item.value === value), [list, value]);
const [search, setSearch] = useState('');
const filterList = useMemo(() => {
if (!isSearch || !search) {
return list;
}
return list.filter((item) => {
const text = `${item.label?.toString()}${item.alias}${item.value}`;
const regx = new RegExp(search, 'gi');
return regx.test(text);
});
}, [list, search, isSearch]);
useImperativeHandle(ref, () => ({
focus() {
onOpen();
}
}));
useEffect(() => {
if (isOpen && MenuListRef.current && SelectedItemRef.current) {
const menu = MenuListRef.current;
const selectedItem = SelectedItemRef.current;
menu.scrollTop = selectedItem.offsetTop - menu.offsetTop - 100;
if (isSearch) {
setSearch('');
}
}
}, [isSearch, isOpen]);
const { runAsync: onclickChange, loading } = useRequest2((val: T) => onChange?.(val));
const ListRender = useMemo(() => {
return (
<>
{filterList.map((item, i) => (
<Box key={i}>
<MenuItem
{...menuItemStyles}
{...(value === item.value
? {
ref: SelectedItemRef,
color: 'primary.700',
bg: 'myGray.100',
fontWeight: '600'
}
: {
color: 'myGray.900'
})}
onClick={() => {
if (value !== item.value) {
onclickChange(item.value);
}
}}
whiteSpace={'pre-wrap'}
fontSize={'sm'}
display={'block'}
mb={0.5}
>
<Flex alignItems={'center'} justifyContent="space-between" width="100%">
<Flex alignItems={'center'}>
{item.icon && (
<Avatar mr={2} src={item.icon as any} w={item.iconSize ?? '1rem'} />
)}
{item.label}
</Flex>
{item.tagText && (
<Tag size="sm" colorScheme={item.tagColor || 'gray'} ml={2}>
{item.tagText}
</Tag>
)}
</Flex>
{item.description && (
<Box color={'myGray.500'} fontSize={'xs'}>
{item.description}
</Box>
)}
</MenuItem>
{item.showBorder && <MyDivider my={2} />}
</Box>
))}
</>
);
}, [filterList, value]);
const isSelecting = loading || isLoading;
return (
<Box>
<Menu
autoSelect={false}
isOpen={isOpen && !isSelecting}
onOpen={onOpen}
onClose={onClose}
strategy={'fixed'}
>
<MenuButton
as={Button}
ref={ButtonRef}
width={width}
px={3}
rightIcon={<MyIcon name={'core/chat/chevronDown'} w={4} color={'myGray.500'} />}
variant={'whitePrimaryOutline'}
size={'md'}
fontSize={'sm'}
textAlign={'left'}
h={'auto'}
whiteSpace={'pre-wrap'}
wordBreak={'break-word'}
_active={{
transform: 'none'
}}
_hover={{
borderRadius: '10px',
border: '0.5px solid var(--Gray-Iron-250, #E0E0E0)',
background: 'var(--Gray-Iron-150, #F3F3F3)'
}}
{...(isOpen
? {
borderColor: 'primary.600',
color: 'primary.700'
}
: {})}
{...props}
>
<Flex alignItems={'center'} justifyContent="space-between" width="100%">
<Flex alignItems={'center'}>
{isSelecting && <MyIcon mr={2} name={'common/loading'} w={'1rem'} />}
{isSearch && isOpen ? (
<Input
ref={SearchInputRef}
autoFocus
variant={'unstyled'}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={
selectItem?.alias ||
(typeof selectItem?.label === 'string' ? selectItem?.label : placeholder)
}
size={'sm'}
w={'100%'}
color={'myGray.700'}
onBlur={() => {
setTimeout(() => {
SearchInputRef?.current?.focus();
}, 0);
}}
/>
) : (
<Flex alignItems="center">
{selectItem?.icon && (
<Avatar mr={2} src={selectItem.icon as any} w={selectItem.iconSize ?? '1rem'} />
)}
{selectItem?.alias || selectItem?.label || placeholder}
</Flex>
)}
</Flex>
{selectItem?.tagText && (
<Tag size="sm" colorScheme={selectItem.tagColor || 'gray'} ml={2}>
{selectItem.tagText}
</Tag>
)}
</Flex>
</MenuButton>
<MenuList
ref={MenuListRef}
className={props.className}
w={(() => {
const w = ButtonRef.current?.clientWidth;
if (w) {
return `${w}px !important`;
}
return Array.isArray(width)
? width.map((item) => `${item} !important`)
: `${width} !important`;
})()}
px={'6px'}
py={'6px'}
border={'1px solid #fff'}
boxShadow={
'0px 2px 4px rgba(161, 167, 179, 0.25), 0px 0px 1px rgba(121, 141, 159, 0.25);'
}
zIndex={99}
maxH={'40vh'}
overflowY={'auto'}
>
{ScrollData ? <ScrollData>{ListRender}</ScrollData> : ListRender}
</MenuList>
</Menu>
</Box>
);
};
export default forwardRef(GateSelect) as <T>(
props: GateSelectProps<T> & { ref?: React.Ref<HTMLSelectElement> }
) => JSX.Element;

View File

@ -1,4 +1,4 @@
import React, { forwardRef } from 'react';
import React, { forwardRef, useRef, useState, useEffect } from 'react';
import { Flex, Box, type BoxProps, HStack } from '@chakra-ui/react';
import MyIcon from '../Icon';
@ -26,8 +26,41 @@ const FillRowTabs = ({
iconGap = 2,
...props
}: Props) => {
const tabsRef = useRef<HTMLDivElement>(null);
const itemsRef = useRef<Map<any, HTMLDivElement>>(new Map());
const [sliderStyle, setSliderStyle] = useState({
width: 0,
left: 0,
opacity: 0
});
useEffect(() => {
const updateSlider = () => {
const activeItem = itemsRef.current.get(value);
if (activeItem && tabsRef.current) {
const tabsRect = tabsRef.current.getBoundingClientRect();
const itemRect = activeItem.getBoundingClientRect();
setSliderStyle({
width: itemRect.width,
left: itemRect.left - tabsRect.left,
opacity: 1
});
}
};
updateSlider();
window.addEventListener('resize', updateSlider);
return () => {
window.removeEventListener('resize', updateSlider);
};
}, [value]);
return (
<Box
ref={tabsRef}
position="relative"
display={'inline-flex'}
px={'3px'}
py={'3px'}
@ -40,9 +73,29 @@ const FillRowTabs = ({
fontWeight={'medium'}
{...props}
>
{/* 滑动背景元素 */}
<Box
position="absolute"
height="calc(100% - 6px)"
top="3px"
borderRadius={'xs'}
bg="white"
boxShadow="1.5"
transition="all 0.14s ease-in-out"
pointerEvents="none"
style={{
width: `${sliderStyle.width}px`,
left: `${sliderStyle.left}px`,
opacity: sliderStyle.opacity
}}
/>
{list.map((item) => (
<HStack
key={item.value}
ref={(el) => {
if (el) itemsRef.current.set(item.value, el);
}}
flex={'1 0 0'}
alignItems={'center'}
justifyContent={'center'}
@ -53,19 +106,14 @@ const FillRowTabs = ({
userSelect={'none'}
whiteSpace={'noWrap'}
gap={iconGap}
{...(value === item.value
? {
bg: 'white',
boxShadow: '1.5',
color: 'primary.600'
}
: {
color: 'myGray.500',
_hover: {
color: 'primary.600'
},
onClick: () => onChange(item.value)
})}
zIndex={1}
position="relative"
transition="color 0.25s ease"
onClick={() => onChange(item.value)}
color={value === item.value ? 'primary.600' : 'myGray.500'}
_hover={{
color: 'primary.600'
}}
>
{item.icon && <MyIcon name={item.icon as any} w={iconSize} />}
<Box fontSize={labelSize}>{item.label}</Box>

View File

@ -4,6 +4,9 @@
"api_key": "API key",
"bills_and_invoices": "Bills",
"channel": "Channel",
"config_app": "Featured Applications",
"config_copyright": "Application configuration",
"config_home": "Home page configuration",
"config_model": "Model configuration",
"confirm_logout": "Confirm to log out?",
"create_channel": "Add new channel",
@ -11,7 +14,12 @@
"custom_model": "custom model",
"default_model": "Default model",
"default_model_config": "Default model configuration",
"gateway.cname_tip": "Please go to your domain name service provider, such as adding the domain name, and parsing the CNAME to Ixjgiwggswmb.sealoshzh.site. After the resolution takes effect, you can bind the custom domain name.",
"gateway.save_config": "save",
"gateway.share": "share",
"gateways": "Gate Management",
"logout": "Sign out",
"logs": "Homepage log",
"model.active": "Active",
"model.alias": "Alias",
"model.alias_tip": "The name of the model displayed in the system is convenient for users to understand.",

View File

@ -0,0 +1,30 @@
{
"Gate": "Gate",
"Gate List": "Gate List",
"Gate app avatar updated": "Gate app icon update",
"Gate app created successfully": "The gate application was created successfully",
"No Gates Available": "No Gates Available",
"Operation failed": "Operation failed",
"available_tools": "Available tools",
"confirm_delete_gate": "Confirm deletion of the gate",
"deep_thinking": "Deep thinking",
"delete_gate": "Delete the gate",
"dialog_prompt_text": "Dialog prompt text",
"disabled": "closure",
"enabled": "Enable",
"example": "Schematic diagram",
"file_upload": "File upload",
"gate_list": "Portal list",
"gate_logo": "LOGO preview",
"image_upload": "Image upload",
"no_gate_available": "No portal available",
"no_gate_to_delete": "There is no gate to delete",
"slogan": "slogan",
"status": "state",
"suggestion_ratio_1_1": "Suggested ratio 1:1",
"suggestion_ratio_4_1": "Suggested ratio 4:1",
"team_name": "Team name",
"upload": "Upload",
"voice_input": "Voice input",
"web_search": "Search online"
}

View File

@ -48,6 +48,7 @@
"create_by_template": "By template",
"create_copy_success": "Duplicate Created Successfully",
"create_empty_app": "Create Default App",
"create_empty_gate": "Create a blank gate",
"create_empty_plugin": "Create Default Plugin",
"create_empty_workflow": "Create Default Workflow",
"cron.every_day": "Run Daily",
@ -150,6 +151,7 @@
"team_tags_set": "Team tags",
"temperature": "Temperature",
"temperature_tip": "Range 0~10. \nThe larger the value, the more divergent the models answer is; the smaller the value, the more rigorous the answer.",
"template.gate": "Gate",
"template.hard_strict": "Strict Q&A template",
"template.hard_strict_des": "Based on the question and answer template, stricter requirements are imposed on the model's answers.",
"template.qa_template": "Q&A template",
@ -183,6 +185,8 @@
"tts_browser": "Browser's own (free)",
"tts_close": "Close",
"type.All": "All",
"type.Create gate": "Create a gate",
"type.Create gate tip": "The gate should not be created here",
"type.Create http plugin tip": "Batch create plugins through OpenAPI Schema, compatible with GPTs format.",
"type.Create mcp tools tip": "Automatically parse and batch create callable MCP tools by entering the MCP address",
"type.Create one plugin tip": "Customizable input and output workflows, usually used to encapsulate reusable workflows.",
@ -191,6 +195,7 @@
"type.Create simple bot tip": "Create a simple AI app by filling out a form, suitable for beginners.",
"type.Create workflow bot": "Create Workflow",
"type.Create workflow tip": "Build complex multi-turn dialogue AI applications through low-code methods, recommended for advanced users.",
"type.Gate": "Gate",
"type.Http plugin": "HTTP Plugin",
"type.Import from json": "Import JSON",
"type.Import from json tip": "Create applications directly through JSON configuration files",

View File

@ -7,6 +7,7 @@
"chat.quote.No Data": "The file cannot be found",
"chat.quote.deleted": "This data has been deleted ~",
"chat.waiting_for_response": "Please wait for the conversation to complete",
"chat_gate_app": "Portal homepage",
"chat_history": "Conversation History",
"chat_input_guide_lexicon_is_empty": "Lexicon not configured yet",
"chat_test_app": "Debug-{{name}}",

View File

@ -13,8 +13,10 @@
"Confirm": "Confirm",
"Continue_Adding": "Continue adding",
"Copy": "Copy",
"Create Success": "Created successfully",
"Creating": "Creating",
"Delete": "Delete",
"Delete Success": "Delete successfully",
"Detail": "Detail",
"Documents": "Documents",
"Done": "Done",
@ -44,12 +46,14 @@
"Folder": "Folder",
"FullScreen": "FullScreen",
"FullScreenLight": "FullScreenLight",
"Gate.service.is.unavailable": "The Gate is not available",
"Import": "Import",
"Input": "Input",
"Instructions": "Instruction",
"Intro": "Introduction",
"Loading": "Loading...",
"Login": "Login",
"Manage tags": "Management Tags",
"More": "More",
"Move": "Move",
"Name": "Name",
@ -74,22 +78,29 @@
"Run": "Run",
"Running": "Running",
"Save": "Save",
"Save Failed": "Saving failed",
"Save Success": "Save successfully",
"Save_and_exit": "Save and Exit",
"Search": "Search",
"Select tags": "Select a tag",
"Select_all": "Select all",
"Setting": "Setting",
"Status": "Status",
"Submit": "Submit",
"Success": "Success",
"Tag already added": "The tag has been added",
"Tags": "Label",
"Team": "Team",
"UnKnow": "Unknown",
"Unlimited": "Unlimited",
"Update": "Update",
"Update Success": "Update successfully",
"Username": "Username",
"Waiting": "Waiting",
"Warning": "Warning",
"Website": "Website",
"action_confirm": "Confirm",
"add_app": "Added apps",
"add_new": "add_new",
"add_new_param": "Add new param",
"add_success": "Added Successfully",
@ -135,10 +146,14 @@
"code_error.error_code.504": "Gateway Timeout",
"code_error.error_code[429]": "Requests are too frequent",
"code_error.error_message.403": "Credential Error",
"code_error.error_message.405": "methodNotAllowed",
"code_error.error_message.510": "Insufficient Account Balance",
"code_error.error_message.511": "Unauthorized to Operate This Model",
"code_error.error_message.513": "Unauthorized to Read This File",
"code_error.error_message.514": "Invalid API Key",
"code_error.error_message[405]": "Method not allowed",
"code_error.error_message[422]": "Params illegal",
"code_error.error_message[500]": "System Error",
"code_error.openapi_error.api_key_not_exist": "API Key Does Not Exist",
"code_error.openapi_error.exceed_limit": "Up to 10 API Keys",
"code_error.openapi_error.un_auth": "Unauthorized to Operate This API Key",
@ -829,10 +844,13 @@
"folder.open_dataset": "Open Dataset",
"folder_description": "Folder Description",
"free": "Free",
"gate.copyright": "The content is generated by third-party AI and is for reference only. The authenticity, accuracy and legality of the information are the responsibility of the provider.",
"gate.placeholder": "You can ask me any questions",
"get_QR_failed": "Failed to Get QR Code",
"get_app_failed": "Failed to Retrieve App",
"get_laf_failed": "Failed to Retrieve Laf Function List",
"has_verification": "Verified, Click to Unbind",
"have_a_try": "Give it a try",
"have_done": "Completed",
"import_failed": "Import Failed",
"import_success": "Imported Successfully",
@ -911,11 +929,14 @@
"next_step": "Next",
"no": "No",
"no_child_folder": "No Subdirectories, Place Here",
"no_data_available": "No valid data",
"no_intro": "No Introduction Available",
"no_laf_env": "System Not Configured with Laf Environment",
"no_matching_apps_found": "No matching app found",
"no_more_data": "No More Data",
"no_pay_way": "There is no suitable payment channel in the system",
"no_select_data": "No Data Available",
"no_selected_apps": "No choice of applications yet",
"not_model_config": "No related model configured",
"not_open": "Not Open",
"not_permission": "The current subscription package does not support team operation logs",
@ -996,6 +1017,7 @@
"read_quote": "View citations",
"redo_tip": "Redo ctrl shift z",
"redo_tip_mac": "Redo ⌘ shift z",
"reorder_failed": "Sorting failed",
"request_end": "All Loaded",
"request_error": "request_error",
"request_more": "Click to Load More",
@ -1004,11 +1026,13 @@
"resume_failed": "Resume Failed",
"root_folder": "Root Folder",
"save_failed": "save_failed",
"save_success": "Saved Successfully",
"save_success": "Save successfully",
"scan_code": "Scan the QR code to pay",
"select_file_failed": "File Selection Failed",
"select_reference_variable": "Select Reference Variable",
"select_tag": "Filter tags",
"select_template": "Select Template",
"selected": "Selected",
"set_avatar": "Click to set_avatar",
"share_link": "Share Link",
"speech_error_tip": "Speech to Text Failed",
@ -1199,7 +1223,9 @@
"system.Help Document": "Help Document",
"system_help_chatbot": "Help Chatbot",
"tag_list": "Tag List",
"tag_manage": "Tag management",
"team_tag": "Team Tag",
"team_tags_set": "Team Tags",
"templateTags.Image_generation": "Image generation",
"templateTags.Office_services": "Office Services",
"templateTags.Roleplay": "role play",
@ -1207,8 +1233,7 @@
"templateTags.Writing": "Writing",
"template_market": "Template Market",
"textarea_variable_picker_tip": "Enter \"/\" to select a variable",
"ui.textarea.Magnifying": "Magnifying",
"un_used": "Unused",
"ui.textarea.Magnifying": "enlarge",
"unauth_token": "The certificate has expired, please log in again",
"undo_tip": "Undo ctrl z",
"undo_tip_mac": "Undo ⌘ z ",

View File

@ -4,6 +4,9 @@
"api_key": "API 密钥",
"bills_and_invoices": "账单与发票",
"channel": "模型渠道",
"config_app": "精选应用",
"config_copyright": "版权信息",
"config_home": "门户配置",
"config_model": "模型配置",
"confirm_logout": "确认退出登录?",
"create_channel": "新增渠道",
@ -11,7 +14,12 @@
"custom_model": "自定义模型",
"default_model": "预设模型",
"default_model_config": "默认模型配置",
"gateway.cname_tip": "请到您的域名服务商处比如添加该域名的、CNAME 解析到 Ixjgiwggswmb.sealoshzh.site解析生效后即可绑定自定义域名。",
"gateway.save_config": "保存",
"gateway.share": "分享",
"gateways": "门户管理",
"logout": "登出",
"logs": "首页日志",
"model.active": "启用",
"model.alias": "别名",
"model.alias_tip": "模型在系统中展示的名字,方便用户理解",

View File

@ -0,0 +1,31 @@
{
"Gate": "门户",
"Gate List": "门户列表",
"Gate app avatar updated": "门户应用图标更新",
"Gate app created successfully": "门户应用创建成功",
"No Gates Available": "暂无可用门户",
"Operation failed": "操作失败",
"available_tools": "可用工具",
"confirm_delete_gate": "确认删除门户",
"deep_thinking": "深度思考",
"delete_gate": "删除门户",
"dialog_prompt_text": "对话框提示文字",
"disabled": "关闭",
"enabled": "启用",
"example": "示意图",
"file_upload": "文件上传",
"gate_list": "门户列表",
"gate_logo": "LOGO预览",
"gate_name": "门户名称",
"image_upload": "图片上传",
"no_gate_available": "没有可用门户",
"no_gate_to_delete": "没有可以删除的门户了",
"slogan": "标语",
"status": "状态",
"suggestion_ratio_1_1": "建议比例 1:1",
"suggestion_ratio_4_1": "建议比例 4:1",
"team_name": "团队名",
"upload": "上传",
"voice_input": "语音输入",
"web_search": "联网搜索"
}

View File

@ -48,6 +48,7 @@
"create_by_template": "从模板创建",
"create_copy_success": "创建副本成功",
"create_empty_app": "创建空白应用",
"create_empty_gate": "创建空白门户",
"create_empty_plugin": "创建空白插件",
"create_empty_workflow": "创建空白工作流",
"cron.every_day": "每天执行",
@ -150,6 +151,7 @@
"team_tags_set": "团队标签",
"temperature": "温度",
"temperature_tip": "范围 010。值越大代表模型回答越发散值越小代表回答越严谨。",
"template.gate": "门户",
"template.hard_strict": "严格问答模板",
"template.hard_strict_des": "在问答模板基础上,对模型的回答做更严格的要求。",
"template.qa_template": "问答模板",
@ -183,6 +185,8 @@
"tts_browser": "浏览器自带(免费)",
"tts_close": "关闭",
"type.All": "全部",
"type.Create gate": "创建门户",
"type.Create gate tip": "门户不该在这里被创建",
"type.Create http plugin tip": "通过 OpenAPI Schema 批量创建插件,兼容 GPTs 格式",
"type.Create mcp tools tip": "通过输入 MCP 地址,自动解析并批量创建可调用的 MCP 工具",
"type.Create one plugin tip": "可以自定义输入和输出的工作流,通常用于封装重复使用的工作流",
@ -191,6 +195,7 @@
"type.Create simple bot tip": "通过填表单形式,创建简单的 AI 应用,适合新手",
"type.Create workflow bot": "创建工作流",
"type.Create workflow tip": "通过低代码的方式,构建逻辑复杂的多轮对话 AI 应用,推荐高级玩家使用",
"type.Gate": "门户",
"type.Http plugin": "HTTP 插件",
"type.Import from json": "导入 JSON 配置",
"type.Import from json tip": "通过 JSON 配置文件,直接创建应用",

View File

@ -7,6 +7,7 @@
"chat.quote.No Data": "找不到该文件",
"chat.quote.deleted": "该数据已被删除~",
"chat.waiting_for_response": "请等待对话完成",
"chat_gate_app": "门户首页",
"chat_history": "聊天记录",
"chat_input_guide_lexicon_is_empty": "还没有配置词库",
"chat_test_app": "调试-{{name}}",

View File

@ -13,8 +13,11 @@
"Confirm": "确认",
"Continue_Adding": "继续添加",
"Copy": "复制",
"Create Success": "创建成功",
"Creating": "创建中",
"Delete": "删除",
"Delete Failed": "删除失败",
"Delete Success": "删除成功",
"Detail": "详情",
"Documents": "文档",
"Done": "完成",
@ -44,12 +47,14 @@
"Folder": "文件夹",
"FullScreen": "全屏",
"FullScreenLight": "全屏预览",
"Gate.service.is.unavailable": "门户不可用",
"Import": "导入",
"Input": "输入",
"Instructions": "使用说明",
"Intro": "介绍",
"Loading": "加载中...",
"Login": "登录",
"Manage tags": "管理标签",
"More": "更多",
"Move": "移动",
"Name": "名称",
@ -74,22 +79,29 @@
"Run": "运行",
"Running": "运行中",
"Save": "保存",
"Save Failed": "保存失败",
"Save Success": "保存成功",
"Save_and_exit": "保存并退出",
"Search": "搜索",
"Select tags": "选择标签",
"Select_all": "全选",
"Setting": "设置",
"Status": "状态",
"Submit": "提交",
"Success": "成功",
"Tag already added": "标签已经添加过了",
"Tags": "标签",
"Team": "团队",
"UnKnow": "未知",
"Unlimited": "无限制",
"Update": "更新",
"Update Success": "更新成功",
"Username": "用户名",
"Waiting": "等待中",
"Warning": "警告",
"Website": "网站",
"action_confirm": "操作确认",
"add_app": "新增应用",
"add_new": "新增",
"add_new_param": "新增参数",
"add_success": "添加成功",
@ -135,6 +147,9 @@
"code_error.error_code.503": "服务器暂时过载或正在维护",
"code_error.error_code.504": "网关超时",
"code_error.error_message.403": "凭证错误",
"code_error.error_message.405": "方式不允许",
"code_error.error_message.422": "Params非法",
"code_error.error_message.500": "系统错误",
"code_error.error_message.510": "账户余额不足",
"code_error.error_message.511": "没有权限操作此模型",
"code_error.error_message.513": "没有权限读取该文件",
@ -829,10 +844,13 @@
"folder.open_dataset": "打开知识库",
"folder_description": "文件夹描述",
"free": "免费",
"gate.copyright": "内容由第三方 AI 生成,仅供参考,信息真实性、准确性、合法性由提供者负责",
"gate.placeholder": "你可以问我任何问题",
"get_QR_failed": "获取二维码失败",
"get_app_failed": "获取应用失败",
"get_laf_failed": "获取Laf函数列表失败",
"has_verification": "已验证,点击取消绑定",
"have_a_try": "试一试",
"have_done": "已完成",
"import_failed": "导入失败",
"import_success": "导入成功",
@ -911,11 +929,14 @@
"next_step": "下一步",
"no": "否",
"no_child_folder": "没有子目录了,就放这里吧",
"no_data_available": "无有效数据",
"no_intro": "暂无介绍",
"no_laf_env": "系统未配置Laf环境",
"no_matching_apps_found": "没有找到匹配的应用",
"no_more_data": "没有更多了~",
"no_pay_way": "系统无合适的支付渠道",
"no_select_data": "没有可选值",
"no_selected_apps": "暂无选择的应用",
"not_model_config": "未配置相关模型",
"not_open": "未开启",
"not_permission": "当前订阅套餐不支持团队操作日志",
@ -996,6 +1017,7 @@
"read_quote": "查看引用",
"redo_tip": "恢复 ctrl shift z",
"redo_tip_mac": "恢复 ⌘ shift z",
"reorder_failed": "排序失败",
"request_end": "已加载全部",
"request_error": "请求异常",
"request_more": "点击加载更多",
@ -1008,7 +1030,9 @@
"scan_code": "扫码支付",
"select_file_failed": "选择文件异常",
"select_reference_variable": "选择引用变量",
"select_tag": "筛选标签",
"select_template": "选择模板",
"selected": "已选择",
"set_avatar": "点击设置头像",
"share_link": "分享链接",
"speech_error_tip": "语音转文字失败",
@ -1199,7 +1223,9 @@
"system.Help Document": "帮助文档",
"system_help_chatbot": "机器人助手",
"tag_list": "标签列表",
"tag_manage": "标签管理",
"team_tag": "团队标签",
"team_tags_set": "团队标签",
"templateTags.Image_generation": "图片生成",
"templateTags.Office_services": "办公服务",
"templateTags.Roleplay": "角色扮演",
@ -1207,6 +1233,7 @@
"templateTags.Writing": "文本创作",
"template_market": "模板市场",
"textarea_variable_picker_tip": "输入\"/\"可选择变量",
"tool_select": "工具选择",
"ui.textarea.Magnifying": "放大",
"un_used": "未使用",
"unauth_token": "凭证已过期,请重新登录",

View File

@ -4,6 +4,9 @@
"api_key": "API 金鑰",
"bills_and_invoices": "帳單與發票",
"channel": "模型管道",
"config_app": "精選應用",
"config_copyright": "應用配置",
"config_home": "首頁配置",
"config_model": "模型設定",
"confirm_logout": "確認登出登入?",
"create_channel": "新增頻道",
@ -11,7 +14,12 @@
"custom_model": "自訂模型",
"default_model": "預設模型",
"default_model_config": "預設模型設定",
"gateway.cname_tip": "請到您的域名服務商處比如添加該域名的、CNAME 解析到 Ixjgiwggswmb.sealoshzh.site解析生效後即可綁定自定義域名。",
"gateway.save_config": "保存",
"gateway.share": "分享",
"gateways": "門戶管理",
"logout": "登出",
"logs": "首頁日誌",
"model.active": "啟用",
"model.alias": "別名",
"model.alias_tip": "模型在系統中展示的名字,方便使用者理解",

View File

@ -0,0 +1,29 @@
{
"Gate": "門戶",
"Gate List": "門戶列表",
"Gate app avatar updated": "門戶應用圖標更新",
"Gate app created successfully": "門戶應用創建成功",
"No Gates Available": "暫無可用門戶",
"Operation failed": "操作失敗",
"available_tools": "可用工具",
"confirm_delete_gate": "確認刪除門戶",
"deep_thinking": "深度思考",
"delete_gate": "刪除門戶",
"dialog_prompt_text": "對話框提示文字",
"disabled": "關閉",
"enabled": "啟用",
"example": "示意圖",
"file_upload": "文件上傳",
"gate_list": "門戶列表",
"gate_logo": "LOGO預覽",
"image_upload": "圖片上傳",
"no_gate_available": "沒有可用門戶",
"no_gate_to_delete": "沒有可以刪除的門戶了",
"slogan": "標語",
"status": "狀態",
"suggestion_ratio_1_1": "建議比例 1:1",
"suggestion_ratio_4_1": "建議比例 4:1",
"team_name": "團隊名",
"voice_input": "語音輸入",
"web_search": "聯網搜索"
}

View File

@ -48,6 +48,7 @@
"create_by_template": "從範本建立",
"create_copy_success": "建立副本成功",
"create_empty_app": "建立空白應用程式",
"create_empty_gate": "創建空白門戶",
"create_empty_plugin": "建立空白外掛",
"create_empty_workflow": "建立空白工作流程",
"cron.every_day": "每天執行",
@ -150,6 +151,7 @@
"team_tags_set": "團隊標籤",
"temperature": "溫度",
"temperature_tip": "範圍 010。\n值越大代表模型回答越發散值越小代表回答越嚴謹。",
"template.gate": "門戶",
"template.hard_strict": "嚴格問答範本",
"template.hard_strict_des": "在問答範本基礎上,對模型的回答做出更嚴格的要求。",
"template.qa_template": "問答範本",
@ -183,6 +185,8 @@
"tts_browser": "瀏覽器自帶 (免費)",
"tts_close": "關閉",
"type.All": "全部",
"type.Create gate": "創建門戶",
"type.Create gate tip": "門戶不該在這裡被創建",
"type.Create http plugin tip": "透過 OpenAPI Schema 批次建立外掛,相容 GPTs 格式",
"type.Create mcp tools tip": "通過輸入 MCP 地址,自動解析並批量創建可調用的 MCP 工具",
"type.Create one plugin tip": "可以自訂輸入和輸出的工作流程,通常用於封裝重複使用的工作流程",
@ -191,6 +195,7 @@
"type.Create simple bot tip": "透過填寫表單的方式,建立簡單的 AI 應用程式,適合新手",
"type.Create workflow bot": "建立工作流程",
"type.Create workflow tip": "透過低程式碼的方式,建立邏輯複雜的多輪對話 AI 應用程式,建議進階使用者使用",
"type.Gate": "門戶",
"type.Http plugin": "HTTP 外掛",
"type.Import from json": "匯入 JSON 設定",
"type.Import from json tip": "透過 JSON 設定文件,直接建立應用",

View File

@ -7,6 +7,7 @@
"chat.quote.No Data": "找不到該文件",
"chat.quote.deleted": "該資料已被刪除~",
"chat.waiting_for_response": "請等待對話完成",
"chat_gate_app": "門戶首頁",
"chat_history": "對話紀錄",
"chat_input_guide_lexicon_is_empty": "尚未設定詞彙庫",
"chat_test_app": "除錯-{{name}}",

View File

@ -13,8 +13,10 @@
"Confirm": "確認",
"Continue_Adding": "繼續新增",
"Copy": "複製",
"Create Success": "創建成功",
"Creating": "建立中",
"Delete": "刪除",
"Delete Success": "刪除成功",
"Detail": "詳細資料",
"Documents": "文件",
"Done": "完成",
@ -44,12 +46,14 @@
"Folder": "資料夾",
"FullScreen": "全屏",
"FullScreenLight": "全屏預覽",
"Gate.service.is.unavailable": "門戶不可用",
"Import": "匯入",
"Input": "輸入",
"Instructions": "使用說明",
"Intro": "介紹",
"Loading": "載入中...",
"Login": "登入",
"Manage tags": "管理標籤",
"More": "更多",
"Move": "移動",
"Name": "名稱",
@ -74,22 +78,28 @@
"Run": "執行",
"Running": "執行中",
"Save": "儲存",
"Save Failed": "保存失敗",
"Save Success": "保存成功",
"Save_and_exit": "儲存並離開",
"Search": "搜尋",
"Select tags": "選擇標籤",
"Select_all": "全選",
"Setting": "設定",
"Status": "狀態",
"Submit": "送出",
"Success": "成功",
"Tag already added": "標籤已經添加過了",
"Team": "團隊",
"UnKnow": "未知",
"Unlimited": "無限制",
"Update": "更新",
"Update Success": "更新成功",
"Username": "使用者名稱",
"Waiting": "等待中",
"Warning": "警告",
"Website": "網站",
"action_confirm": "確認",
"add_app": "新增應用",
"add_new": "新增",
"add_new_param": "新增參數",
"add_success": "新增成功",
@ -139,6 +149,9 @@
"code_error.error_message.511": "無權操作此模型",
"code_error.error_message.513": "無權讀取此檔案",
"code_error.error_message.514": "API 金鑰無效",
"code_error.error_message[405]": "方式不允許",
"code_error.error_message[422]": "Params非法",
"code_error.error_message[500]": "系統錯誤",
"code_error.openapi_error.api_key_not_exist": "API 金鑰不存在",
"code_error.openapi_error.exceed_limit": "最多 10 組 API 金鑰",
"code_error.openapi_error.un_auth": "無權操作此 API 金鑰",
@ -829,10 +842,13 @@
"folder.open_dataset": "開啟知識庫",
"folder_description": "資料夾描述",
"free": "免費",
"gate.copyright": "內容由第三方 AI 生成,僅供參考,信息真實性、準確性、合法性由提供者負責",
"gate.placeholder": "你可以問我任何問題",
"get_QR_failed": "取得 QR Code 失敗",
"get_app_failed": "取得應用程式失敗",
"get_laf_failed": "取得 LAF 函式清單失敗",
"has_verification": "已驗證,點選解除綁定",
"have_a_try": "試一試",
"have_done": "已完成",
"import_failed": "匯入失敗",
"import_success": "匯入成功",
@ -911,11 +927,14 @@
"next_step": "下一步",
"no": "否",
"no_child_folder": "無子目錄,放置在此",
"no_data_available": "無有效數據",
"no_intro": "暫無介紹",
"no_laf_env": "系統未設定 LAF 環境",
"no_matching_apps_found": "沒有找到匹配的應用",
"no_more_data": "沒有更多資料了",
"no_pay_way": "系統無合適的支付渠道",
"no_select_data": "沒有可選擇的資料",
"no_selected_apps": "暫無選擇的應用",
"not_model_config": "未設定相關模型",
"not_open": "未開啟",
"not_permission": "當前訂閱套餐不支持團隊操作日誌",
@ -1004,11 +1023,13 @@
"resume_failed": "恢復失敗",
"root_folder": "根目錄",
"save_failed": "儲存失敗",
"save_success": "存成功",
"save_success": "存成功",
"scan_code": "掃碼支付",
"select_file_failed": "選擇檔案失敗",
"select_reference_variable": "選擇引用變數",
"select_tag": "篩選標籤",
"select_template": "選擇範本",
"selected": "已選擇",
"set_avatar": "點選設定頭像",
"share_link": "分享連結",
"speech_error_tip": "語音轉文字失敗",
@ -1199,7 +1220,9 @@
"system.Help Document": "說明文件",
"system_help_chatbot": "機器人助手",
"tag_list": "標籤列表",
"tag_manage": "標籤管理",
"team_tag": "團隊標籤",
"team_tags_set": "團隊標籤",
"templateTags.Image_generation": "圖片生成",
"templateTags.Office_services": "辦公服務",
"templateTags.Roleplay": "角色扮演",
@ -1208,7 +1231,6 @@
"template_market": "模板市場",
"textarea_variable_picker_tip": "輸入「/」以選擇變數",
"ui.textarea.Magnifying": "放大",
"un_used": "未使用",
"unauth_token": "憑證已過期,請重新登入",
"undo_tip": "復原 ctrl z",
"undo_tip_mac": "復原 ⌘ z ",

View File

@ -34,6 +34,7 @@ export interface I18nNamespaces {
account_info: typeof account_info;
account_usage: typeof account_usage;
account_bill: typeof account_bill;
account_gate: typeof account_gate;
account_apikey: typeof account_apikey;
account_setting: typeof account_setting;
account_inform: typeof account_inform;
@ -71,6 +72,7 @@ declare module 'i18next' {
'account_info',
'account_usage',
'account_bill',
'account_gate',
'account_apikey',
'account_setting',
'account_inform',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More