@lobehub/chat 0.63.3 → 0.64.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/locales/en_US/common.json +7 -3
  3. package/locales/en_US/error.json +15 -1
  4. package/locales/en_US/plugin.json +79 -1
  5. package/locales/en_US/setting.json +4 -1
  6. package/locales/zh_CN/common.json +7 -3
  7. package/locales/zh_CN/error.json +14 -0
  8. package/locales/zh_CN/plugin.json +79 -20
  9. package/locales/zh_CN/setting.json +3 -0
  10. package/package.json +1 -1
  11. package/src/components/EmojiPicker/index.tsx +13 -5
  12. package/src/features/AgentSetting/AgentMeta/index.tsx +1 -1
  13. package/src/features/AgentSetting/AgentPlugin/LocalPluginItem.tsx +72 -0
  14. package/src/features/AgentSetting/AgentPlugin/MarketList.tsx +160 -0
  15. package/src/features/AgentSetting/AgentPlugin/PluginSettings.tsx +76 -0
  16. package/src/features/AgentSetting/AgentPlugin/index.tsx +8 -143
  17. package/src/features/PluginDevModal/ManifestForm.tsx +96 -0
  18. package/src/features/PluginDevModal/MetaForm.tsx +90 -0
  19. package/src/features/PluginDevModal/PluginPreview.tsx +36 -0
  20. package/src/features/PluginDevModal/index.tsx +153 -0
  21. package/src/locales/default/error.ts +15 -23
  22. package/src/locales/default/plugin.ts +79 -3
  23. package/src/locales/default/setting.ts +3 -0
  24. package/src/locales/resources/en_US.ts +2 -0
  25. package/src/pages/chat/features/Conversation/ChatList/Error/index.tsx +2 -1
  26. package/src/pages/chat/features/Conversation/ChatList/Plugins/FunctionCall.tsx +7 -4
  27. package/src/pages/chat/features/Conversation/ChatList/Plugins/PluginMessage.tsx +27 -33
  28. package/src/services/plugin.ts +6 -3
  29. package/src/store/plugin/action.ts +23 -2
  30. package/src/store/plugin/initialState.ts +17 -1
  31. package/src/store/plugin/reducers/devPluginList.ts +56 -0
  32. package/src/store/plugin/selectors.ts +9 -2
  33. package/src/store/plugin/store.ts +5 -1
@@ -0,0 +1,160 @@
1
+ import { Avatar, Form, Icon, Tooltip } from '@lobehub/ui';
2
+ import { Button, Skeleton, Switch, Tag } from 'antd';
3
+ import { createStyles } from 'antd-style';
4
+ import isEqual from 'fast-deep-equal';
5
+ import { LucideBlocks, LucideStore } from 'lucide-react';
6
+ import { memo, useState } from 'react';
7
+ import { useTranslation } from 'react-i18next';
8
+ import { Flexbox } from 'react-layout-kit';
9
+ import DevModal from 'src/features/PluginDevModal';
10
+
11
+ import { FORM_STYLE } from '@/const/layoutTokens';
12
+ import { pluginHelpers, usePluginStore } from '@/store/plugin';
13
+
14
+ import { useStore } from '../store';
15
+ import LocalPluginItem from './LocalPluginItem';
16
+
17
+ const useStyles = createStyles(({ css }) => ({
18
+ avatar: css`
19
+ .ant-skeleton-header {
20
+ padding-right: 0;
21
+ }
22
+ `,
23
+ label: css`
24
+ li {
25
+ height: 14px !important;
26
+ }
27
+
28
+ li + li {
29
+ margin-block-start: 12px !important;
30
+ }
31
+ `,
32
+ }));
33
+
34
+ const MarketList = memo(() => {
35
+ const { t } = useTranslation('setting');
36
+ const { styles } = useStyles();
37
+
38
+ const [showModal, setModal] = useState(false);
39
+
40
+ // useEffect(() => {
41
+ // setModal(true);
42
+ // }, []);
43
+
44
+ const updateConfig = useStore((s) => s.toggleAgentPlugin);
45
+ const [plugins, hasPlugin] = useStore((s) => [s.config.plugins || [], !!s.config.plugins]);
46
+
47
+ const [useFetchPluginList, fetchPluginManifest, saveToDevList, updateNewDevPlugin] =
48
+ usePluginStore((s) => [
49
+ s.useFetchPluginList,
50
+ s.fetchPluginManifest,
51
+ s.saveToDevList,
52
+ s.updateNewDevPlugin,
53
+ ]);
54
+ const pluginManifestLoading = usePluginStore((s) => s.pluginManifestLoading, isEqual);
55
+ const pluginList = usePluginStore((s) => s.pluginList, isEqual);
56
+ const devPluginList = usePluginStore((s) => s.devPluginList, isEqual);
57
+
58
+ useFetchPluginList();
59
+
60
+ const loadingItem = {
61
+ avatar: (
62
+ <Skeleton
63
+ active
64
+ avatar={{ size: 40 }}
65
+ className={styles.avatar}
66
+ paragraph={false}
67
+ title={false}
68
+ />
69
+ ),
70
+ label: (
71
+ <Skeleton
72
+ active
73
+ avatar={false}
74
+ paragraph={{ className: styles.label, style: { marginBottom: 0 } }}
75
+ style={{ width: 300 }}
76
+ title={false}
77
+ />
78
+ ),
79
+ };
80
+
81
+ const loadingList = [loadingItem, loadingItem, loadingItem];
82
+
83
+ const isEmpty = pluginList.length === 0;
84
+
85
+ const list = pluginList.map(({ identifier, meta }) => ({
86
+ avatar: <Avatar avatar={meta.avatar} />,
87
+ children: (
88
+ <Switch
89
+ checked={
90
+ // 如果在加载中,说明激活了
91
+ pluginManifestLoading[identifier] || !hasPlugin ? false : plugins.includes(identifier)
92
+ }
93
+ loading={pluginManifestLoading[identifier]}
94
+ onChange={(checked) => {
95
+ updateConfig(identifier);
96
+ if (checked) {
97
+ fetchPluginManifest(identifier);
98
+ }
99
+ }}
100
+ />
101
+ ),
102
+ desc: pluginHelpers.getPluginDesc(meta),
103
+ label: pluginHelpers.getPluginTitle(meta),
104
+ minWidth: undefined,
105
+ tag: identifier,
106
+ }));
107
+
108
+ const devList = devPluginList.map(({ identifier, meta }) => ({
109
+ avatar: <Avatar avatar={pluginHelpers.getPluginAvatar(meta) || '🧩'} />,
110
+ children: <LocalPluginItem id={identifier} />,
111
+ desc: pluginHelpers.getPluginDesc(meta),
112
+ label: (
113
+ <Flexbox align={'center'} gap={8} horizontal>
114
+ {pluginHelpers.getPluginTitle(meta)}
115
+ <Tag bordered={false} color={'gold'}>
116
+ {t('list.item.local.title', { ns: 'plugin' })}
117
+ </Tag>
118
+ </Flexbox>
119
+ ),
120
+ minWidth: undefined,
121
+ tag: identifier,
122
+ }));
123
+
124
+ return (
125
+ <>
126
+ <DevModal
127
+ onOpenChange={setModal}
128
+ onSave={saveToDevList}
129
+ onValueChange={updateNewDevPlugin}
130
+ open={showModal}
131
+ />
132
+ <Form
133
+ items={[
134
+ {
135
+ children: isEmpty ? loadingList : [...devList, ...list],
136
+ extra: (
137
+ <Tooltip title={t('settingPlugin.addTooltip')}>
138
+ <Button
139
+ icon={<Icon icon={LucideBlocks} />}
140
+ onClick={(e) => {
141
+ e.stopPropagation();
142
+ setModal(true);
143
+ }}
144
+ size={'small'}
145
+ >
146
+ {t('settingPlugin.add')}
147
+ </Button>
148
+ </Tooltip>
149
+ ),
150
+ icon: LucideStore,
151
+ title: t('settingPlugin.title'),
152
+ },
153
+ ]}
154
+ {...FORM_STYLE}
155
+ />{' '}
156
+ </>
157
+ );
158
+ });
159
+
160
+ export default MarketList;
@@ -0,0 +1,76 @@
1
+ import { Form, ItemGroup, Markdown } from '@lobehub/ui';
2
+ import { createStyles } from 'antd-style';
3
+ import isEqual from 'fast-deep-equal';
4
+ import { ToyBrick } from 'lucide-react';
5
+ import { memo } from 'react';
6
+ import { useTranslation } from 'react-i18next';
7
+
8
+ import { FORM_STYLE } from '@/const/layoutTokens';
9
+ import { transformPluginSettings } from '@/features/PluginSettings';
10
+ import PluginSettingRender from '@/features/PluginSettings/PluginSettingRender';
11
+ import { pluginHelpers, pluginSelectors, usePluginStore } from '@/store/plugin';
12
+
13
+ import { useStore } from '../store';
14
+
15
+ const useStyles = createStyles(({ css, token }) => ({
16
+ md: css`
17
+ p {
18
+ color: ${token.colorTextDescription};
19
+ }
20
+ `,
21
+ }));
22
+
23
+ const PluginSettings = memo(() => {
24
+ const { t } = useTranslation('setting');
25
+ const { styles } = useStyles();
26
+
27
+ const [plugins] = useStore((s) => [s.config.plugins || [], !!s.config.plugins]);
28
+
29
+ const [useFetchPluginList, updatePluginSettings] = usePluginStore((s) => [
30
+ s.useFetchPluginList,
31
+ s.updatePluginSettings,
32
+ ]);
33
+ const pluginList = usePluginStore(pluginSelectors.pluginList);
34
+
35
+ const pluginManifestMap = usePluginStore((s) => s.pluginManifestMap, isEqual);
36
+ const pluginsSettings = usePluginStore((s) => s.pluginsSettings, isEqual);
37
+ useFetchPluginList();
38
+
39
+ const isEmpty = pluginList.length === 0;
40
+
41
+ const pluginsConfig = isEmpty
42
+ ? []
43
+ : (plugins
44
+ .map((identifier) => {
45
+ const item = pluginList.find((i) => i.identifier === identifier);
46
+
47
+ if (!item) return null;
48
+ const manifest = pluginManifestMap[identifier];
49
+
50
+ if (!manifest?.settings) return null;
51
+
52
+ return {
53
+ children: transformPluginSettings(manifest.settings).map((item) => ({
54
+ ...item,
55
+ children: (
56
+ <PluginSettingRender
57
+ defaultValue={pluginsSettings[identifier]?.[item.name]}
58
+ format={item.format}
59
+ onChange={(value) => {
60
+ updatePluginSettings(identifier, { [item.name]: value });
61
+ }}
62
+ type={item.type as any}
63
+ />
64
+ ),
65
+ desc: item.desc && <Markdown className={styles.md}>{item.desc}</Markdown>,
66
+ })),
67
+ icon: ToyBrick,
68
+ title: t('settingPlugin.config', { id: pluginHelpers.getPluginTitle(item.meta) }),
69
+ };
70
+ })
71
+ .filter(Boolean) as unknown as ItemGroup[]);
72
+
73
+ return <Form items={pluginsConfig} {...FORM_STYLE} />;
74
+ });
75
+
76
+ export default PluginSettings;
@@ -1,148 +1,13 @@
1
- import { Avatar, Form, ItemGroup, Markdown } from '@lobehub/ui';
2
- import { Skeleton, Switch } from 'antd';
3
- import { createStyles } from 'antd-style';
4
- import isEqual from 'fast-deep-equal';
5
- import { LucideStore, ToyBrick } from 'lucide-react';
6
1
  import { memo } from 'react';
7
- import { useTranslation } from 'react-i18next';
8
2
 
9
- import { FORM_STYLE } from '@/const/layoutTokens';
10
- import { transformPluginSettings } from '@/features/PluginSettings';
11
- import PluginSettingRender from '@/features/PluginSettings/PluginSettingRender';
12
- import { pluginHelpers, usePluginStore } from '@/store/plugin';
3
+ import MarketList from './MarketList';
4
+ import PluginSettings from './PluginSettings';
13
5
 
14
- import { useStore } from '../store';
15
-
16
- const useStyles = createStyles(({ css, token }) => ({
17
- avatar: css`
18
- .ant-skeleton-header {
19
- padding-right: 0;
20
- }
21
- `,
22
- label: css`
23
- li {
24
- height: 14px !important;
25
- }
26
-
27
- li + li {
28
- margin-block-start: 12px !important;
29
- }
30
- `,
31
- md: css`
32
- p {
33
- color: ${token.colorTextDescription};
34
- }
35
- `,
36
- }));
37
-
38
- const AgentPlugin = memo(() => {
39
- const { t } = useTranslation('setting');
40
- const { styles } = useStyles();
41
-
42
- const updateConfig = useStore((s) => s.toggleAgentPlugin);
43
- const [plugins, hasPlugin] = useStore((s) => [s.config.plugins || [], !!s.config.plugins]);
44
-
45
- const [useFetchPluginList, fetchPluginManifest, updatePluginSettings] = usePluginStore((s) => [
46
- s.useFetchPluginList,
47
- s.fetchPluginManifest,
48
- s.updatePluginSettings,
49
- ]);
50
- const pluginManifestLoading = usePluginStore((s) => s.pluginManifestLoading, isEqual);
51
- const pluginManifestMap = usePluginStore((s) => s.pluginManifestMap, isEqual);
52
- const pluginsSettings = usePluginStore((s) => s.pluginsSettings, isEqual);
53
- const { data } = useFetchPluginList();
54
-
55
- const loadingItem = {
56
- avatar: (
57
- <Skeleton
58
- active
59
- avatar={{ size: 40 }}
60
- className={styles.avatar}
61
- paragraph={false}
62
- title={false}
63
- />
64
- ),
65
- label: (
66
- <Skeleton
67
- active
68
- avatar={false}
69
- paragraph={{ className: styles.label, style: { marginBottom: 0 } }}
70
- style={{ width: 300 }}
71
- title={false}
72
- />
73
- ),
74
- };
75
-
76
- const children = !data
77
- ? [loadingItem, loadingItem, loadingItem]
78
- : data.plugins.map(({ identifier, meta }) => ({
79
- avatar: <Avatar avatar={meta.avatar} />,
80
- children: (
81
- <Switch
82
- checked={
83
- // 如果在加载中,说明激活了
84
- pluginManifestLoading[identifier] || !hasPlugin ? false : plugins.includes(identifier)
85
- }
86
- loading={pluginManifestLoading[identifier]}
87
- onChange={(checked) => {
88
- updateConfig(identifier);
89
- if (checked) {
90
- fetchPluginManifest(identifier);
91
- }
92
- }}
93
- />
94
- ),
95
- desc: pluginHelpers.getPluginDesc(meta),
96
- label: pluginHelpers.getPluginTitle(meta),
97
- minWidth: undefined,
98
- tag: identifier,
99
- }));
100
-
101
- const pluginsConfig = !data
102
- ? []
103
- : (plugins
104
- .map((identifier) => {
105
- const item = data.plugins.find((i) => i.identifier === identifier);
106
-
107
- if (!item) return null;
108
- const manifest = pluginManifestMap[identifier];
109
-
110
- if (!manifest?.settings) return null;
111
-
112
- return {
113
- children: transformPluginSettings(manifest.settings).map((item) => ({
114
- ...item,
115
- children: (
116
- <PluginSettingRender
117
- defaultValue={pluginsSettings[identifier]?.[item.name]}
118
- format={item.format}
119
- onChange={(value) => {
120
- updatePluginSettings(identifier, { [item.name]: value });
121
- }}
122
- type={item.type as any}
123
- />
124
- ),
125
- desc: item.desc && <Markdown className={styles.md}>{item.desc}</Markdown>,
126
- })),
127
- icon: ToyBrick,
128
- title: t('settingPlugin.config', { id: pluginHelpers.getPluginTitle(item.meta) }),
129
- };
130
- })
131
- .filter(Boolean) as unknown as ItemGroup[]);
132
-
133
- return (
134
- <Form
135
- items={[
136
- {
137
- children,
138
- icon: LucideStore,
139
- title: t('settingPlugin.title'),
140
- },
141
- ...pluginsConfig,
142
- ]}
143
- {...FORM_STYLE}
144
- />
145
- );
146
- });
6
+ const AgentPlugin = memo(() => (
7
+ <>
8
+ <MarketList />
9
+ <PluginSettings />
10
+ </>
11
+ ));
147
12
 
148
13
  export default AgentPlugin;
@@ -0,0 +1,96 @@
1
+ import { pluginManifestSchema } from '@lobehub/chat-plugin-sdk';
2
+ import { Form, FormItemProps, Input, Tooltip } from '@lobehub/ui';
3
+ import { FormInstance, Radio } from 'antd';
4
+ import { memo } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+
7
+ const ManifestForm = memo<{ form: FormInstance; mode: 'url' | 'local' }>(({ form, mode }) => {
8
+ const { t } = useTranslation('plugin');
9
+
10
+ const isUrl = mode === 'url';
11
+
12
+ const configItem: FormItemProps[] = isUrl
13
+ ? [
14
+ {
15
+ children: <Input placeholder={'http://localhost/manifest.json'} />,
16
+ desc: t('dev.meta.manifest.desc'),
17
+ hasFeedback: true,
18
+ label: t('dev.meta.manifest.label'),
19
+ name: 'manifest',
20
+ required: true,
21
+ rules: [
22
+ { required: true },
23
+ {
24
+ message: t('dev.meta.manifest.urlError'),
25
+ pattern: /^https?:\/\/.*/,
26
+ },
27
+ {
28
+ message: t('dev.meta.manifest.invalid'),
29
+ validator: async (_, value) => {
30
+ const res = await fetch(value);
31
+ if (!res.ok) return true;
32
+
33
+ const json = await res.json();
34
+ pluginManifestSchema.parse(json);
35
+ },
36
+ },
37
+ ],
38
+ },
39
+ ]
40
+ : // TODO: 后续做成本地配置模式
41
+ [
42
+ {
43
+ children: <Input placeholder={'searchEngine'} />,
44
+ desc: t('dev.meta.identifier.desc'),
45
+ label: t('dev.meta.identifier.label'),
46
+ name: 'name',
47
+ required: true,
48
+ },
49
+
50
+ {
51
+ children: <Input placeholder={t('dev.meta.description.placeholder')} />,
52
+ desc: t('dev.meta.description.desc'),
53
+ label: t('dev.meta.description.label'),
54
+ name: 'description',
55
+ required: true,
56
+ },
57
+ {
58
+ children: <Input placeholder={'searchEngine'} />,
59
+ desc: t('dev.meta.identifier.desc'),
60
+ label: t('dev.meta.identifier.label'),
61
+ name: 'identifier',
62
+ required: true,
63
+ },
64
+ ];
65
+
66
+ return (
67
+ <Form
68
+ form={form}
69
+ items={[
70
+ {
71
+ children: configItem,
72
+ extra: (
73
+ <Radio.Group
74
+ onChange={(v) => {
75
+ form.setFieldValue('manifestMode', v.target.value);
76
+ }}
77
+ size={'small'}
78
+ value={mode}
79
+ >
80
+ <Radio.Button value={'url'}>{t('dev.manifest.mode.url')}</Radio.Button>
81
+ <Tooltip title={t('dev.manifest.mode.local-tooltip')}>
82
+ <Radio.Button disabled value={'local'}>
83
+ {t('dev.manifest.mode.local')}
84
+ </Radio.Button>
85
+ </Tooltip>
86
+ </Radio.Group>
87
+ ),
88
+ title: t('dev.tabs.manifest'),
89
+ },
90
+ ]}
91
+ layout={isUrl ? 'vertical' : undefined}
92
+ />
93
+ );
94
+ });
95
+
96
+ export default ManifestForm;
@@ -0,0 +1,90 @@
1
+ import { Form, FormItemProps, Input } from '@lobehub/ui';
2
+ import { FormInstance } from 'antd';
3
+ import { memo, useEffect } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+
6
+ import EmojiPicker from '@/components/EmojiPicker';
7
+ import { pluginSelectors, usePluginStore } from '@/store/plugin';
8
+
9
+ const MetaForm = memo<{ form: FormInstance }>(({ form }) => {
10
+ const { t } = useTranslation('plugin');
11
+ const [plugins] = usePluginStore((s) => [pluginSelectors.pluginList(s).map((i) => i.identifier)]);
12
+
13
+ useEffect(() => {
14
+ if (usePluginStore.getState().newDevPlugin) {
15
+ form.setFieldsValue(usePluginStore.getState().newDevPlugin);
16
+ }
17
+ }, []);
18
+
19
+ const configItem: FormItemProps[] = [
20
+ {
21
+ children: <Input placeholder={'searchEngine'} />,
22
+ desc: t('dev.meta.identifier.desc'),
23
+ label: t('dev.meta.identifier.label'),
24
+ name: 'identifier',
25
+ rules: [
26
+ { required: true },
27
+ {
28
+ message: t('dev.meta.identifier.pattenErrorMessage'),
29
+ pattern: /^[\w-]+$/,
30
+ },
31
+ {
32
+ message: t('dev.meta.identifier.errorDuplicate'),
33
+ validator: async (_, value) => {
34
+ if (plugins.includes(value)) {
35
+ throw new Error('Duplicate');
36
+ }
37
+ },
38
+ },
39
+ ],
40
+ },
41
+ {
42
+ children: <Input placeholder={t('dev.meta.title.placeholder')} />,
43
+ desc: t('dev.meta.title.desc'),
44
+ label: t('dev.meta.title.label'),
45
+ name: ['meta', 'title'],
46
+ rules: [{ required: true }],
47
+ },
48
+ {
49
+ children: <Input placeholder={t('dev.meta.description.placeholder')} />,
50
+ desc: t('dev.meta.description.desc'),
51
+ label: t('dev.meta.description.label'),
52
+ name: ['meta', 'description'],
53
+ },
54
+ {
55
+ children: <Input placeholder={'LobeHub'} />,
56
+ desc: t('dev.meta.author.desc'),
57
+ label: t('dev.meta.author.label'),
58
+ name: 'author',
59
+ },
60
+ {
61
+ children: <Input placeholder={'https://www.lobehub.com'} />,
62
+ desc: t('dev.meta.homepage.desc'),
63
+ label: t('dev.meta.homepage.label'),
64
+ name: 'homepage',
65
+ },
66
+ {
67
+ children: <EmojiPicker defaultAvatar={'🧩'} />,
68
+ desc: t('dev.meta.avatar.desc'),
69
+ label: t('dev.meta.avatar.label'),
70
+ name: ['meta', 'avatar'],
71
+ },
72
+ ];
73
+
74
+ return (
75
+ <Form
76
+ form={form}
77
+ items={[
78
+ {
79
+ children: configItem,
80
+ title: t('dev.metaConfig'),
81
+ },
82
+ ]}
83
+ validateMessages={{
84
+ required: () => t('dev.meta.formFieldRequired'),
85
+ }}
86
+ />
87
+ );
88
+ });
89
+
90
+ export default MetaForm;
@@ -0,0 +1,36 @@
1
+ import { Avatar, Form } from '@lobehub/ui';
2
+ import { Card, Switch, Tag } from 'antd';
3
+ import { memo } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { Flexbox } from 'react-layout-kit';
6
+
7
+ import { pluginHelpers, usePluginStore } from '@/store/plugin';
8
+
9
+ const PluginPreview = memo(() => {
10
+ const { t } = useTranslation('plugin');
11
+ const meta = usePluginStore((s) => s.newDevPlugin);
12
+
13
+ const items = {
14
+ avatar: <Avatar avatar={pluginHelpers.getPluginAvatar(meta?.meta) || '🧩'} />,
15
+ children: <Switch disabled />,
16
+ desc: pluginHelpers.getPluginDesc(meta?.meta),
17
+ label: (
18
+ <Flexbox align={'center'} gap={8} horizontal>
19
+ {pluginHelpers.getPluginTitle(meta?.meta) ?? t('dev.preview.title')}
20
+ <Tag bordered={false} color={'gold'}>
21
+ {t('list.item.local.title', { ns: 'plugin' })}
22
+ </Tag>
23
+ </Flexbox>
24
+ ),
25
+ minWidth: undefined,
26
+ tag: !!meta?.identifier ? meta?.identifier : 'id',
27
+ };
28
+
29
+ return (
30
+ <Card size={'small'} title={t('dev.preview.card')}>
31
+ <Form.Item {...items} colon={false} style={{ marginBottom: 0 }} />
32
+ </Card>
33
+ );
34
+ });
35
+
36
+ export default PluginPreview;