add: 新增插件系统

This commit is contained in:
lrhh123
2024-06-29 03:17:46 +08:00
parent 890772dff5
commit 84f7565c2c
17 changed files with 1597 additions and 76 deletions
+1
View File
@@ -111,6 +111,7 @@
"electron-log": "^4.4.8",
"electron-store": "^8.1.0",
"electron-updater": "^6.1.4",
"emoji-picker-react": "^4.10.0",
"exceljs": "^4.4.0",
"express": "^4.19.2",
"express-async-handler": "^1.2.0",
+17
View File
@@ -59,6 +59,9 @@ dependencies:
electron-updater:
specifier: ^6.1.4
version: 6.1.8
emoji-picker-react:
specifier: ^4.10.0
version: 4.10.0(react@18.3.0)
exceljs:
specifier: ^4.4.0
version: 4.4.0
@@ -7252,6 +7255,16 @@ packages:
engines: {node: '>=12'}
dev: true
/emoji-picker-react@4.10.0(react@18.3.0):
resolution: {integrity: sha512-EfvOsGbyweMNcJ1F99XUv+XPdfkpa2NRAYkhwdIeYS6DWeISu3kHWX+iwvFLUVAc533aWbsGpETbxwbhzsiMnw==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
dependencies:
flairup: 0.0.39
react: 18.3.0
dev: false
/emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -8292,6 +8305,10 @@ packages:
path-exists: 4.0.0
dev: true
/flairup@0.0.39:
resolution: {integrity: sha512-UVPkzZmZeBWBx1+Ovo++kYKk9Wi32Jxt+c7HsxnEY80ExwFV54w+NyquFziqMLS0BnGVE43yGD4OvIwaAm/WiQ==}
dev: false
/flat-cache@3.2.0:
resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
engines: {node: ^10.12.0 || >=12.0.0}
+82
View File
@@ -538,6 +538,88 @@ class BKServer {
}
});
this.app.get('/api/v1/plugin/list', async (req, res) => {
const plugins = await this.configController.getAllCustomPlugins();
const results = plugins.map((plugin) => {
return {
id: plugin.id,
code: plugin.code,
title: plugin.title,
description: plugin.description,
icon: plugin.icon,
source: plugin.source,
author: plugin.author,
type: plugin.type,
tags: JSON.parse(plugin.tags || '[]'),
};
});
res.json({
success: true,
data: results,
});
});
this.app.get('/api/v1/plugin/detail', async (req, res) => {
const { id } = req.query;
const plugin = await this.configController.getPluginConfig(Number(id));
if (!plugin) {
res.json({
success: false,
data: null,
});
return;
}
const tags = JSON.parse(plugin.tags || '[]');
res.json({
success: true,
data: {
id: plugin.id,
code: plugin.code,
title: plugin.title,
description: plugin.description,
icon: plugin.icon,
source: plugin.source,
author: plugin.author,
type: plugin.type,
tags,
},
});
});
this.app.post('/api/v1/plugin/create', async (req, res) => {
const { code, source, author, description, icon, tags, title } = req.body;
await this.configController.createCustomPlugin({
code,
source,
author,
description,
icon,
tags: JSON.stringify(tags),
title,
});
res.json({ success: true });
});
this.app.post('/api/v1/plugin/update', async (req, res) => {
const { id, code, description, icon, tags, title } = req.body;
await this.configController.updateCustomPlugin({
pluginId: id,
code,
description,
icon,
tags: JSON.stringify(tags),
title,
});
res.json({ success: true });
});
this.app.post('/api/v1/plugin/delete', async (req, res) => {
const { id } = req.body;
await this.configController.deleteCustomPlugin(id);
res.json({ success: true });
});
// Health check endpoint
// TODO: 后续需要根据通过 WS 去检查后端服务是否健康
this.app.get('/api/v1/base/health', async (req, res) => {
+102 -30
View File
@@ -74,16 +74,6 @@ export class ConfigController {
return config;
}
/**
* 取得插件配置
* @param pluginId
* @returns
*/
public async getPluginConfig(pluginId: number): Promise<Plugin | null> {
const plugin = await Plugin.findByPk(pluginId);
return plugin;
}
/**
* 激活或关闭配置
* @param
@@ -134,6 +124,106 @@ export class ConfigController {
}
}
/**
* 取得自定义插件
* @param
* @returns
*/
public async getAllCustomPlugins(): Promise<Plugin[]> {
const plugins = await Plugin.findAll();
return plugins;
}
/**
* 取得插件配置
* @param pluginId
* @returns
*/
public async getPluginConfig(pluginId: number): Promise<Plugin | null> {
const plugin = await Plugin.findByPk(pluginId);
return plugin;
}
/**
* 新增自定义插件
* @param
* @returns
*/
public async createCustomPlugin({
source,
author,
description,
icon,
tags,
title,
code,
}: {
source?: string;
author?: string;
description?: string;
icon?: string;
tags?: string;
title: string;
code: string;
}) {
const plugin = await Plugin.create({
source: source || 'custom',
author,
description,
icon,
tags,
type: 'plugin',
title,
code,
});
return plugin;
}
/**
* 删除自定义插件
* @param
* @returns
*/
public async deleteCustomPlugin(pluginId: number) {
const plugin = await Plugin.findByPk(pluginId);
if (plugin) {
await plugin.destroy();
}
}
/**
* 更新自定义插件
* @param
* @returns
*/
public async updateCustomPlugin({
pluginId,
code,
description,
icon,
tags,
title,
}: {
pluginId: number;
code: string;
description: string;
icon: string;
tags: string;
title: string;
}) {
const plugin = await Plugin.findByPk(pluginId);
if (plugin) {
await plugin.update({
code,
description,
icon,
tags,
title,
});
}
}
/**
* 检查配置是否激活
* @param
@@ -254,18 +344,11 @@ export class ConfigController {
}
if (type === 'plugin') {
let pluginCode = '';
if (config?.plugin_id) {
const plugin = await Plugin.findByPk(config?.plugin_id);
pluginCode = plugin?.code || '';
}
return {
appId: config?.platform_id || '',
instanceId: config?.instance_id || '',
usePlugin: config?.use_plugin || false,
pluginCode,
pluginId: config?.plugin_id || 0,
};
}
@@ -364,18 +447,7 @@ export class ConfigController {
let pluginId = null;
const config = cfg as PluginConfig;
if (dbConfig.use_plugin) {
let plugin = await Plugin.findByPk(dbConfig.plugin_id);
if (!plugin) {
plugin = await Plugin.create({
code: config.pluginCode,
});
} else {
await plugin.update({
code: config.pluginCode,
});
}
pluginId = plugin.id;
pluginId = config.pluginId;
}
await dbConfig.update({
+1 -1
View File
@@ -91,7 +91,7 @@ export async function checkAndAddFields(sequelize: Sequelize) {
.addColumn('n_config', 'truncate_word_key', {
type: DataTypes.STRING,
allowNull: false,
defaultValue: '...',
defaultValue: '',
});
}
}
+122 -9
View File
@@ -3,19 +3,92 @@ import { DataTypes, Model, Sequelize } from 'sequelize';
export class Plugin extends Model {
declare id: number;
declare name: string;
declare code: string;
declare platform: string;
declare platform_id: string;
declare instance_id: string; // 可能是作用于单个实例的插件
declare created_at: Date;
declare version: string;
declare source: string; // 自定义插件、官方内置插件、第三方插件
declare author: string; // 插件作者
declare description: string; // 插件描述
declare icon: string; // 插件图标
declare tags: string; // 插件标签
declare type: string; // 插件类型
declare title: string; // 插件标题
}
export async function checkAndAddFields(sequelize: Sequelize) {
const tableDescription = await Plugin.describe();
// @ts-ignore
if (!tableDescription.source) {
await sequelize.getQueryInterface().addColumn('plugins', 'source', {
type: DataTypes.STRING,
allowNull: true,
defaultValue: 'custom',
});
}
// @ts-ignore
if (!tableDescription.author) {
await sequelize.getQueryInterface().addColumn('plugins', 'author', {
type: DataTypes.STRING,
allowNull: true,
defaultValue: 'unknown',
});
}
// @ts-ignore
if (!tableDescription.description) {
await sequelize.getQueryInterface().addColumn('plugins', 'description', {
type: DataTypes.STRING,
allowNull: true,
defaultValue: '插件的描述信息~',
});
}
// @ts-ignore
if (!tableDescription.icon) {
await sequelize.getQueryInterface().addColumn('plugins', 'icon', {
type: DataTypes.STRING,
allowNull: true,
defaultValue: '😀',
});
}
// @ts-ignore
if (!tableDescription.tags) {
await sequelize.getQueryInterface().addColumn('plugins', 'tags', {
type: DataTypes.TEXT,
allowNull: true,
defaultValue: JSON.stringify([]),
});
}
// @ts-ignore
if (!tableDescription.type) {
await sequelize.getQueryInterface().addColumn('plugins', 'type', {
type: DataTypes.STRING,
allowNull: true,
defaultValue: 'plugin',
});
}
// @ts-ignore
if (!tableDescription.title) {
await sequelize.getQueryInterface().addColumn('plugins', 'title', {
type: DataTypes.STRING,
allowNull: true,
defaultValue: '插件标题',
});
}
}
export function initPlugin(sequelize: Sequelize) {
@@ -34,14 +107,17 @@ export function initPlugin(sequelize: Sequelize) {
type: DataTypes.TEXT,
allowNull: true,
},
// @Deprecated
platform: {
type: DataTypes.STRING(255),
allowNull: true,
},
// @Deprecated
platform_id: {
type: DataTypes.STRING(255),
allowNull: true,
},
// @Deprecated
instance_id: {
type: DataTypes.STRING(255),
allowNull: true,
@@ -52,9 +128,44 @@ export function initPlugin(sequelize: Sequelize) {
},
version: {
type: DataTypes.STRING(255),
defaultValue: '1.0.0',
defaultValue: '1.1.0',
allowNull: true,
},
source: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: 'custom',
},
author: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: 'unknown',
},
description: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: '插件的描述信息~',
},
icon: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: '😀',
},
tags: {
type: DataTypes.TEXT,
allowNull: true,
defaultValue: JSON.stringify([]),
},
type: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: 'plugin',
},
title: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: '插件标题',
},
},
{
sequelize,
@@ -63,4 +174,6 @@ export function initPlugin(sequelize: Sequelize) {
timestamps: false,
},
);
checkAndAddFields(sequelize);
}
+1 -1
View File
@@ -70,7 +70,7 @@ export interface PluginConfig {
appId: string;
instanceId: string;
usePlugin: boolean;
pluginCode: string;
pluginId: number;
}
export interface DriverConfig {
@@ -12,6 +12,7 @@ import {
DriverConfig,
Message,
Session,
Plugin,
MessageModel,
} from './platform';
import { GET, POST } from '../common/api/request';
@@ -327,3 +328,29 @@ export async function exportMessageExcel() {
const data = await GET('/api/v1/message/excel');
return data;
}
export async function getCustomPluginList() {
const data = await GET<{
data: Plugin[];
}>('/api/v1/plugin/list');
return data;
}
export async function getCustomPluginDetail(id: number) {
const data = await GET<{
data: Plugin;
}>('/api/v1/plugin/detail', { id });
return data;
}
export async function addCustomPlugin(plugin: Plugin) {
await POST('/api/v1/plugin/create', plugin);
}
export async function updateCustomPlugin(plugin: Plugin) {
await POST('/api/v1/plugin/update', plugin);
}
export async function deleteCustomPlugin(id: number) {
await POST('/api/v1/plugin/delete', { id });
}
+12 -1
View File
@@ -44,6 +44,17 @@ export interface LogBody {
message: string;
}
export interface Plugin {
id?: number;
code?: string;
type: string;
title: string;
author?: string;
description: string;
tags: string[];
icon?: string;
}
export interface GenericConfig {
appId: string;
instanceId: string;
@@ -76,7 +87,7 @@ export interface PluginConfig {
appId: string;
instanceId: string;
usePlugin: boolean;
pluginCode: string;
pluginId: number;
}
export interface DriverConfig {
+26 -32
View File
@@ -19,10 +19,12 @@ import {
Button,
} from '@chakra-ui/react';
import { loader } from '@monaco-editor/react';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import GeneralSettings from './components/Settings/GeneralSettings';
import LLMSettings from './components/Settings/LLMSettings';
import PluginSettings from './components/Settings/PluginSettings';
import PluginPage from './pages/Plugin';
import PluginEditorPage from './pages/PluginEditor';
import AboutPage from './components/About';
import { trackPageView } from '../common/services/analytics';
import {
@@ -199,18 +201,6 @@ const App = () => {
{settings.appId || settings.instanceId ? '' : '全局'}
</Tab>
{
// {!settings.appId && (
// <Tab
// _selected={{ bg: 'gray.200' }}
// _hover={{ bg: 'gray.300' }}
// textAlign="left"
// >
// 使用激活码
// </Tab>
// )}
}
{!settings.appId && (
<Tab
_selected={{ bg: 'gray.200' }}
@@ -243,26 +233,30 @@ const App = () => {
/>
</TabPanel>
<TabPanel>
<Heading as="h3" size="md" mb={4}>
{settings.appId ? '' : '全局'}
</Heading>
<PluginSettings
appId={settings.appId}
instanceId={settings.instanceId}
/>
<Router>
<Routes>
<Route
path="/settings.html"
element={
<PluginPage
appId={settings.appId}
instanceId={settings.instanceId}
/>
}
/>
<Route
path="/settings.html/editor"
element={
<PluginEditorPage
appId={settings.appId}
instanceId={settings.instanceId}
/>
}
/>
</Routes>
</Router>
</TabPanel>
{
// {!settings.appId && (
// <>
// <TabPanel>
// <Heading as="h3" size="md" mb={4}>
// 账户设置
// </Heading>
// <AccountSettings />
// </TabPanel>
// </>
// )}
}
{!settings.appId && (
<>
<TabPanel>
@@ -32,7 +32,7 @@ import {
import {
PluginConfig,
LogBody,
} from '../../../common/services/platform/platform.d';
} from '../../../common/services/platform/platform';
import MessageModal from '../MessageModal';
import { useSystemStore } from '../../stores/useSystemStore';
@@ -152,7 +152,7 @@ const PluginSettings = ({
});
}
},
[code, config], // 更新依赖项
[code, config, appId, instanceId, toast], // 更新依赖项
);
const handleDefaultCode = () => {
@@ -0,0 +1,144 @@
import React, { useState } from 'react';
import {
Box,
Text,
Divider,
Tag,
Flex,
IconButton,
Checkbox,
} from '@chakra-ui/react';
import { FaBook, FaPlus } from 'react-icons/fa';
import { Plugin } from '../../../common/services/platform/platform';
const PluginCard = ({
plugin,
isActive,
onActivate,
onEdit,
}: {
plugin: Plugin;
isActive: boolean;
onActivate: () => void;
onEdit?: () => void;
}) => {
const [selected, setSelected] = useState(false);
const handleMouseEnter = () => setSelected(true);
const handleMouseLeave = () => setSelected(false);
return (
<Box
position="relative"
borderWidth="1px"
borderRadius="lg"
overflow="hidden"
p={4}
bg={selected ? 'white' : 'gray.50'}
boxShadow={selected ? 'lg' : 'none'}
borderColor={selected ? 'blue.500' : 'gray.200'}
borderStyle="solid"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={onEdit}
>
{plugin.type === 'plugin' && (
<Flex position="absolute" top={2} right={2}>
<Checkbox isChecked={isActive} onChange={onActivate} />
</Flex>
)}
{isActive && plugin.type === 'plugin' && (
<Box
position="absolute"
top={0}
right={0}
bg="green.500"
color="white"
px={2}
py={1}
borderBottomLeftRadius="md"
>
</Box>
)}
{plugin.type === 'guide' && (
<Flex
direction="column"
align="flex-start"
justify="center"
height="100%"
>
<Flex align="flex-start">
<Text fontSize="4xl">{plugin.icon}</Text>
<Text
fontWeight="bold"
textAlign="left"
whiteSpace="pre-line"
mt={2}
>
{plugin.title}
</Text>
</Flex>
<Divider my={4} />
<Flex align="center">
<FaBook />
<Text ml={2}></Text>
</Flex>
</Flex>
)}
{plugin.type === 'custom' && (
<Flex
direction="column"
align="flex-start"
justify="center"
height="100%"
>
<Flex align="center">
<IconButton
icon={<FaPlus />}
aria-label="Add Custom Tool"
variant="outline"
mr={2}
/>
<Text fontWeight="bold"></Text>
</Flex>
<Divider my={4} />
<Flex align="center">
<FaBook />
<Text ml={2}></Text>
</Flex>
</Flex>
)}
{plugin.type === 'plugin' && (
<>
<Flex>
<Text fontSize="4xl">{plugin.icon}</Text>
<Box ml={4}>
<Text fontWeight="bold" textAlign="left">
{plugin.title}
</Text>
<Text color="gray.500" textAlign="left">
{plugin.author}
</Text>
</Box>
</Flex>
<Text mt={4} textAlign="left">
{plugin.description}
</Text>
<Flex mt={4} wrap="wrap">
{plugin.tags.map((tag, idx) => (
<Tag key={idx} mr={2} mt={2}>
{tag}
</Tag>
))}
</Flex>
</>
)}
</Box>
);
};
export default PluginCard;
@@ -0,0 +1,188 @@
import React, { useState, useEffect } from 'react';
import {
ChakraProvider,
Grid,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Skeleton,
Stack,
} from '@chakra-ui/react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Plugin } from '../../../common/services/platform/platform';
import { getCustomPluginList as getLocalPluginList } from '../../../common/services/platform/controller';
import PluginCard from './PluginCard';
const systemPlugins = [
{
type: 'guide',
title: '我有兴趣为懒人客服\n贡献工具',
description: '',
tags: [],
icon: '📘',
},
{
type: 'plugin',
title: '系统插件名称',
author: '系统作者名',
description: '这是一个系统插件的描述。',
tags: ['Tag1', 'Tag2', 'Tag3'],
icon: '😀',
},
// 其他系统插件数据...
];
const userPlugins = [
{
type: 'plugin',
title: '用户插件名称',
author: '用户作者名',
description: '这是一个用户插件的描述。',
tags: ['Tag1', 'Tag2', 'Tag3'],
icon: '😀',
},
// 其他用户插件数据...
];
type PluginPageProps = {
appId?: string;
instanceId?: string;
};
const PluginPage = ({ appId, instanceId }: PluginPageProps) => {
const [tabIndex, setTabIndex] = useState(0);
const [activePlugin, setActivePlugin] = useState<number | null>(null);
const [customPlugins, setCustomPlugins] = useState<Plugin[]>([]);
const navigate = useNavigate();
const {
data: localPluginData,
isLoading: isLocalLoading,
// refetch: refetchLocalPluginList,
} = useQuery(
['localPlugins'],
() => {
return getLocalPluginList();
},
{
retry: () => {
return true;
},
retryDelay: () => {
return 1000;
},
},
);
useEffect(() => {
if (localPluginData) {
console.log(localPluginData);
setCustomPlugins([
{
type: 'custom',
title: '创建自定义工具',
description: '',
tags: [],
},
...localPluginData.data,
]);
}
}, [localPluginData]);
if (isLocalLoading) {
return (
<Stack>
<Skeleton height="20px" />
<Skeleton height="20px" />
<Skeleton height="20px" />
</Stack>
);
}
const handleActivate = (index: number) => {
setActivePlugin(activePlugin === index ? null : index);
};
const handleEdit = (plugin: Plugin) => {
console.log('edit', plugin);
if (plugin.type === 'custom' || plugin.type === 'plugin') {
navigate(
'/settings.html/editor',
plugin.id
? {
state: { pluginId: plugin.id },
}
: {},
);
}
};
return (
<ChakraProvider>
<Tabs index={tabIndex} onChange={(index) => setTabIndex(index)}>
<TabList>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
</TabList>
<TabPanels>
<TabPanel>
<Grid
templateColumns="repeat(auto-fill, minmax(250px, 1fr))"
gap={6}
p={4}
>
{systemPlugins.map((plugin, index) => (
<PluginCard
key={index}
plugin={plugin}
isActive={activePlugin === index}
onActivate={() => handleActivate(index)}
/>
))}
</Grid>
</TabPanel>
<TabPanel>
<Grid
templateColumns="repeat(auto-fill, minmax(250px, 1fr))"
gap={6}
p={4}
>
{userPlugins.map((plugin, index) => (
<PluginCard
key={index}
plugin={plugin}
isActive={activePlugin === index}
onActivate={() => handleActivate(index)}
/>
))}
</Grid>
</TabPanel>
<TabPanel>
<Grid
templateColumns="repeat(auto-fill, minmax(250px, 1fr))"
gap={6}
p={4}
>
{customPlugins.map((plugin, index) => (
<PluginCard
key={index}
plugin={plugin}
isActive={activePlugin === index}
onActivate={() => handleActivate(index)}
onEdit={() => handleEdit(plugin)}
/>
))}
</Grid>
</TabPanel>
</TabPanels>
</Tabs>
</ChakraProvider>
);
};
export default PluginPage;
@@ -0,0 +1,102 @@
import React, { useState } from 'react';
import {
VStack,
Text,
Divider,
Input,
Textarea,
Tag,
TagLabel,
TagCloseButton,
HStack,
Button,
Box,
} from '@chakra-ui/react';
import EmojiPicker, { EmojiClickData, EmojiStyle } from 'emoji-picker-react';
import { Plugin } from '../../../common/services/platform/platform';
type PluginBasicInfoProps = {
plugin: Plugin;
handleUpdateConfig: (config: Partial<Plugin>) => void;
};
const PluginBasicInfo: React.FC<PluginBasicInfoProps> = ({
plugin,
handleUpdateConfig,
}) => {
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
const handleTagRemove = (tagToRemove: string) => {
const updatedTags = plugin.tags.filter((tag) => tag !== tagToRemove);
handleUpdateConfig({ tags: updatedTags });
};
const handleTagAdd = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && e.currentTarget.value.trim()) {
handleUpdateConfig({
tags: [...plugin.tags, e.currentTarget.value.trim()],
});
e.currentTarget.value = '';
}
};
const onEmojiClick = (emojiObject: EmojiClickData) => {
handleUpdateConfig({ icon: emojiObject.emoji });
setIsEmojiPickerOpen(false);
};
return (
<VStack spacing="4" align="start" width="100%">
<Text fontSize="1xl" fontWeight="bold">
</Text>
<Divider />
{plugin.author && <Text>: {plugin.author}</Text>}
{plugin.type && <Text>: {plugin.type}</Text>}
<HStack>
<Input
placeholder="插件标题"
value={plugin.title}
onChange={(e) => handleUpdateConfig({ title: e.target.value })}
/>
<Button onClick={() => setIsEmojiPickerOpen(!isEmojiPickerOpen)}>
{plugin.icon || '😀'}
</Button>
{isEmojiPickerOpen && (
<Box position="absolute" zIndex="1">
<EmojiPicker
onEmojiClick={onEmojiClick}
emojiStyle={EmojiStyle.NATIVE}
/>
</Box>
)}
</HStack>
<Textarea
placeholder="描述"
value={plugin.description}
onChange={(e) => handleUpdateConfig({ description: e.target.value })}
/>
<VStack align="start" width="100%">
<Text>:</Text>
<HStack wrap="wrap">
{plugin.tags.map((tag, idx) => (
<Tag
key={idx}
size="md"
borderRadius="full"
variant="solid"
colorScheme="teal"
>
<TagLabel>{tag}</TagLabel>
<TagCloseButton onClick={() => handleTagRemove(tag)} />
</Tag>
))}
</HStack>
<Input placeholder="按 Enter 添加标签" onKeyDown={handleTagAdd} />
</VStack>
</VStack>
);
};
export default PluginBasicInfo;
@@ -0,0 +1,133 @@
import React, { useRef } from 'react';
import {
Box,
VStack,
Text,
Button,
Divider,
HStack,
useDisclosure,
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogBody,
AlertDialogFooter,
} from '@chakra-ui/react';
import { FiSave } from 'react-icons/fi';
import { RepeatIcon } from '@chakra-ui/icons';
import Editor, { Monaco } from '@monaco-editor/react';
import {
PluginExampleCode,
PluginExtraLib,
} from '../../../common/utils/constants';
type PluginEditorProps = {
code?: string;
setCode: (code?: string) => void;
handleSaveCode: (code?: string) => void;
};
// 子组件:插件编辑页
const PluginEditor = ({ code, setCode, handleSaveCode }: PluginEditorProps) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const cancelRef = useRef<any>();
const handleEditorWillMount = (monaco: Monaco) => {
monaco.languages.registerCompletionItemProvider('javascript', {
// @ts-ignore
provideCompletionItems: () => {
const suggestions = [
{
label: 'require',
kind: monaco.languages.CompletionItemKind.Function,
insertText: 'require()',
documentation: '引入模块',
},
{
label: 'console',
kind: monaco.languages.CompletionItemKind.Function,
insertText: 'console.log()',
documentation: '打印日志',
},
];
return { suggestions };
},
});
monaco.languages.typescript.javascriptDefaults.addExtraLib(
PluginExtraLib,
'ts:filename/types.d.ts',
);
};
return (
<VStack spacing="4" align="start" width="100%">
<Text fontSize="1xl" fontWeight="bold">
</Text>
<Divider />
<HStack>
<Button
leftIcon={<FiSave />}
onClick={() => handleSaveCode()}
colorScheme="teal"
size="sm"
>
</Button>
<Button
leftIcon={<RepeatIcon />}
onClick={onOpen}
colorScheme="red"
size="sm"
>
</Button>
</HStack>
<Box width="100%" height="400px">
<Editor
height="100%"
defaultLanguage="javascript"
value={code}
onChange={(value) => setCode(value)}
beforeMount={handleEditorWillMount}
theme="vs-dark"
/>
</Box>
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
</AlertDialogHeader>
<AlertDialogBody>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose}>
</Button>
<Button
colorScheme="red"
onClick={() => {
setCode(PluginExampleCode);
handleSaveCode(PluginExampleCode);
onClose();
}}
ml={3}
>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</VStack>
);
};
export default PluginEditor;
@@ -0,0 +1,311 @@
import React, { useState } from 'react';
import {
Button,
FormControl,
FormLabel,
Input,
Select,
Box,
VStack,
HStack,
Table,
Thead,
Tbody,
Th,
Tr,
Td,
Divider,
Text,
Flex,
useToast,
} from '@chakra-ui/react';
import { RepeatIcon } from '@chakra-ui/icons';
import { FiPlay } from 'react-icons/fi';
import { checkPluginAvailability } from '../../../common/services/platform/controller';
import { useSystemStore } from '../../stores/useSystemStore';
import {
Message,
RoleType,
MessageType,
LogBody,
} from '../../../common/services/platform/platform';
import {
ContextKeys,
MockCtx,
MockMessages,
} from '../../../common/utils/constants';
const PluginTestPage = ({ code }: { code?: string }) => {
const toast = useToast();
const [consoleLogs, setConsoleLogs] = useState<LogBody[]>([]);
const [newMessage, setNewMessage] = useState<Message>({
sender: '',
content: '',
role: 'SELF',
type: 'TEXT',
});
const [selectedContextKey, setSelectedContextKey] = useState<string>(
ContextKeys[0],
);
const [contextValue, setContextValue] = useState<string>('');
const { context, setContext, addMessage, messages, removeMessage } =
useSystemStore();
const handleAddMessage = () => {
addMessage(newMessage);
setNewMessage({ sender: '', content: '', role: 'SELF', type: 'TEXT' });
};
const handleSetContext = () => {
setContext(selectedContextKey, contextValue);
setContextValue('');
};
const handleCheckPlugin = async () => {
try {
const resp = await checkPluginAvailability({
code: code || '',
ctx: context,
messages,
});
setConsoleLogs(resp.consoleOutput || []);
if (resp.status) {
toast({
title: '插件测试通过',
position: 'top',
description: resp.message,
status: 'success',
duration: 5000,
isClosable: true,
});
} else {
toast({
title: '插件测试失败',
position: 'top',
description: resp.error,
status: 'error',
duration: 5000,
isClosable: true,
});
}
} catch (error) {
console.error(error);
toast({
title: '检查插件失败',
description: error instanceof Error ? error.message : '未知错误',
status: 'error',
duration: 5000,
isClosable: true,
});
}
};
const handleSetDefault = () => {
// MockCtx 是一个 Map 对象
// eslint-disable-next-line no-restricted-syntax
for (const [key, value] of MockCtx) {
setContext(key, value);
}
// 先清空所有消息
for (let i = messages.length - 1; i >= 0; i -= 1) {
removeMessage(i);
}
// eslint-disable-next-line no-restricted-syntax
for (const msg of MockMessages) {
// @ts-ignore
addMessage(msg);
}
};
return (
<VStack spacing="4" align="start" width="100%">
<HStack>
<Button
leftIcon={<FiPlay />}
onClick={handleCheckPlugin}
colorScheme="green"
>
</Button>
<Button
leftIcon={<RepeatIcon />}
onClick={handleSetDefault}
colorScheme="blue"
>
</Button>
</HStack>
<Flex width="100%" justifyContent="space-between">
<Box width="48%">
<FormControl>
<FormLabel></FormLabel>
<Select
value={selectedContextKey}
onChange={(e) => setSelectedContextKey(e.target.value)}
>
{ContextKeys.map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</Select>
</FormControl>
<FormControl mt={4}>
<FormLabel></FormLabel>
<Input
value={contextValue}
onChange={(e) => setContextValue(e.target.value)}
/>
</FormControl>
<Button
mt={4}
onClick={handleSetContext}
colorScheme="blue"
size="sm"
>
</Button>
<Divider my={4} />
<Table variant="simple">
<Thead>
<Tr>
<Th></Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{Object.entries(context).map(([key, value], index) => (
<Tr key={index}>
<Td>{key}</Td>
<Td>{value}</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
<Box width="48%">
<FormControl>
<FormLabel></FormLabel>
<Input
value={newMessage.sender}
onChange={(e) =>
setNewMessage({ ...newMessage, sender: e.target.value })
}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel></FormLabel>
<Input
value={newMessage.content}
onChange={(e) =>
setNewMessage({ ...newMessage, content: e.target.value })
}
/>
</FormControl>
<HStack mt={4}>
<FormControl>
<FormLabel>Role</FormLabel>
<Select
value={newMessage.role}
onChange={(e) =>
setNewMessage({
...newMessage,
role: e.target.value as RoleType,
})
}
>
<option value="SELF"></option>
<option value="OTHER"></option>
<option value="SYSTEM"></option>
</Select>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Select
value={newMessage.type}
onChange={(e) =>
setNewMessage({
...newMessage,
type: e.target.value as MessageType,
})
}
>
<option value="TEXT"></option>
<option value="IMAGE"></option>
<option value="VIDEO"></option>
<option value="FILE"></option>
</Select>
</FormControl>
</HStack>
<Button
mt={4}
onClick={handleAddMessage}
colorScheme="blue"
size="sm"
>
</Button>
<Divider my={4} />
<Box>
{messages.map((msg, index) => (
<HStack
key={index}
justify="space-between"
mb={2}
bg={msg.role === 'SELF' ? 'blue.100' : 'gray.100'}
p={2}
borderRadius="md"
>
<Box>
<Text fontSize="sm" fontWeight="bold">
{msg.sender}
</Text>
<Text fontSize="sm">{msg.content}</Text>
<Text fontSize="xs" color="gray.500">
({msg.role} - {msg.type})
</Text>
</Box>
<Button
size="sm"
colorScheme="red"
onClick={() => removeMessage(index)}
>
</Button>
</HStack>
))}
</Box>
</Box>
</Flex>
<Divider />
<Box width="100%">
<HStack justify="space-between">
<Text fontWeight="bold"></Text>
<Button
onClick={() => setConsoleLogs([])}
colorScheme="red"
size="sm"
>
</Button>
</HStack>
{consoleLogs && consoleLogs.length > 0 && (
<Box height="200px" overflowY="auto" mt={2}>
{consoleLogs.map((log, index) => (
<Text key={index}>
{log.level} {log.time} {log.message}
</Text>
))}
</Box>
)}
</Box>
</VStack>
);
};
export default PluginTestPage;
@@ -0,0 +1,326 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
VStack,
Button,
useToast,
HStack,
Stack,
Skeleton,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Box,
useDisclosure,
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogBody,
AlertDialogFooter,
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useLocation } from 'react-router-dom';
import {
FiChevronLeft,
FiShare2,
FiTrash2,
FiPlusCircle,
} from 'react-icons/fi';
import { PluginExampleCode } from '../../../common/utils/constants';
import {
getCustomPluginDetail,
addCustomPlugin,
updateCustomPlugin,
deleteCustomPlugin,
} from '../../../common/services/platform/controller';
import { Plugin } from '../../../common/services/platform/platform';
import PluginTestPage from './PluginTestPage';
import PluginBasicInfo from './PluginBasicInfo';
import PluginEditorCom from './PluginEditor';
type PluginEditorProps = {
appId?: string;
instanceId?: string;
};
const PluginEditor = ({ appId, instanceId }: PluginEditorProps) => {
const [code, setCode] = useState<string | undefined>(PluginExampleCode);
const { isOpen, onOpen, onClose } = useDisclosure();
const cancelRef = useRef<any>();
const toast = useToast();
const navigate = useNavigate();
const location = useLocation();
// 获取 navigate 传递的状态参数
const { pluginId } = location.state || {};
const { data, isLoading } = useQuery(['pluginDetail', pluginId], async () => {
try {
if (!pluginId) {
return null;
}
const resp = await getCustomPluginDetail(pluginId);
return resp;
} catch (error) {
const errormsg =
error instanceof Error ? error.message : JSON.stringify(error);
toast({
title: '获取插件失败',
description: errormsg,
status: 'error',
duration: 5000,
isClosable: true,
});
return null;
}
});
const [plugin, setPlugin] = useState<Plugin>({
title: '新建插件',
description: '这是一个自定义插件~',
code: PluginExampleCode,
icon: '😀',
tags: [],
type: 'plugin',
});
useEffect(() => {
if (data) {
const obj = data.data as Plugin;
setPlugin(obj);
setCode(obj.code || PluginExampleCode);
}
}, [data]);
const handleAddNewPlugin = async () => {
try {
await addCustomPlugin({
...plugin,
code: code || PluginExampleCode,
});
toast({
title: '新增插件成功',
position: 'top',
description: '插件已添加',
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error) {
const errormsg =
error instanceof Error ? error.message : JSON.stringify(error);
toast({
title: '新增插件失败',
position: 'top',
description: errormsg,
status: 'error',
duration: 5000,
isClosable: true,
});
}
};
const handleDeletePlugin = async () => {
if (!plugin || !plugin.id) return;
try {
await deleteCustomPlugin(plugin.id);
toast({
title: '删除插件成功',
position: 'top',
description: '插件已删除',
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error) {
const errormsg =
error instanceof Error ? error.message : JSON.stringify(error);
toast({
title: '删除插件失败',
position: 'top',
description: errormsg,
status: 'error',
duration: 5000,
isClosable: true,
});
}
};
const handleUpdateConfig = async (newConfig: Partial<Plugin>) => {
if (!plugin) return;
const updatedConfig = { ...plugin, ...newConfig };
setPlugin(updatedConfig);
try {
await updateCustomPlugin(updatedConfig);
} catch (error) {
const errormsg =
error instanceof Error ? error.message : JSON.stringify(error);
toast({
title: '更新插件失败',
position: 'top',
description: errormsg,
status: 'error',
duration: 5000,
isClosable: true,
});
}
};
const handleSaveCode = useCallback(
async (inCode?: string) => {
if (!plugin) return;
try {
await updateCustomPlugin({
...plugin,
code: inCode || code || PluginExampleCode,
});
toast({
title: '代码已保存',
position: 'top',
description: '插件已更新',
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error) {
const errormsg =
error instanceof Error ? error.message : JSON.stringify(error);
toast({
title: '更新插件失败',
position: 'top',
description: errormsg,
status: 'error',
duration: 5000,
isClosable: true,
});
}
},
[code, plugin, toast],
);
console.log('location 0002', location);
if (pluginId && (isLoading || !data || !plugin)) {
return (
<Stack>
<Skeleton height="20px" />
<Skeleton height="20px" />
<Skeleton height="20px" />
</Stack>
);
}
console.log('location 0003', location);
return (
<VStack align="start" spacing="4" minHeight="100vh" position="relative">
<Box position="fixed" top="10px" right="10px" zIndex={10}>
<Button
leftIcon={<FiChevronLeft />}
colorScheme="teal"
onClick={() => navigate('/settings.html')}
>
</Button>
</Box>
<Tabs width="70vw" flex="1">
<TabList>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
</TabList>
<TabPanels>
<TabPanel>
<PluginBasicInfo
plugin={plugin}
handleUpdateConfig={handleUpdateConfig}
/>
</TabPanel>
<TabPanel>
<PluginEditorCom
code={code}
setCode={setCode}
handleSaveCode={handleSaveCode}
/>
</TabPanel>
<TabPanel>
<PluginTestPage code={code} />
</TabPanel>
</TabPanels>
</Tabs>
<Box h={'30px'} />
<HStack
spacing="4"
position="fixed"
bottom="0"
width="100%"
bg="white"
p="4"
boxShadow="md"
>
<Button
leftIcon={<FiShare2 />}
colorScheme="purple"
onClick={() => {
/* 发布插件到社区逻辑 */
}}
>
</Button>
{pluginId ? (
<Button leftIcon={<FiTrash2 />} colorScheme="red" onClick={onOpen}>
</Button>
) : (
<Button
leftIcon={<FiPlusCircle />}
colorScheme="blue"
onClick={handleAddNewPlugin}
>
</Button>
)}
</HStack>
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
</AlertDialogHeader>
<AlertDialogBody>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose}>
</Button>
<Button
colorScheme="red"
onClick={async () => {
await handleDeletePlugin();
onClose();
navigate('/settings.html');
}}
ml={3}
>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</VStack>
);
};
export default PluginEditor;