@lobehub/lobehub 2.0.0-next.270 → 2.0.0-next.272

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.
@@ -0,0 +1,549 @@
1
+ 'use client';
2
+
3
+ import { KLAVIS_SERVER_TYPES, type KlavisServerType } from '@lobechat/const';
4
+ import { Avatar, Button, Flexbox, Icon, type ItemType, Segmented } from '@lobehub/ui';
5
+ import { createStaticStyles, cssVar } from 'antd-style';
6
+ import isEqual from 'fast-deep-equal';
7
+ import { ArrowRight, PlusIcon, Store, ToyBrick } from 'lucide-react';
8
+ import React, { Suspense, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
+ import { useTranslation } from 'react-i18next';
10
+
11
+ import PluginAvatar from '@/components/Plugins/PluginAvatar';
12
+ import KlavisServerItem from '@/features/ChatInput/ActionBar/Tools/KlavisServerItem';
13
+ import ToolItem from '@/features/ChatInput/ActionBar/Tools/ToolItem';
14
+ import ActionDropdown from '@/features/ChatInput/ActionBar/components/ActionDropdown';
15
+ import PluginStore from '@/features/PluginStore';
16
+ import { useCheckPluginsIsInstalled } from '@/hooks/useCheckPluginsIsInstalled';
17
+ import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
18
+ import { useAgentStore } from '@/store/agent';
19
+ import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/selectors';
20
+ import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
21
+ import { useToolStore } from '@/store/tool';
22
+ import {
23
+ builtinToolSelectors,
24
+ klavisStoreSelectors,
25
+ pluginSelectors,
26
+ } from '@/store/tool/selectors';
27
+ import { type LobeToolMetaWithAvailability } from '@/store/tool/slices/builtin/selectors';
28
+
29
+ import PluginTag from './PluginTag';
30
+
31
+ const WEB_BROWSING_IDENTIFIER = 'lobe-web-browsing';
32
+
33
+ type TabType = 'all' | 'installed';
34
+
35
+ const prefixCls = 'ant';
36
+
37
+ const styles = createStaticStyles(({ css }) => ({
38
+ dropdown: css`
39
+ overflow: hidden;
40
+
41
+ width: 100%;
42
+ border: 1px solid ${cssVar.colorBorderSecondary};
43
+ border-radius: ${cssVar.borderRadiusLG};
44
+
45
+ background: ${cssVar.colorBgElevated};
46
+ box-shadow: ${cssVar.boxShadowSecondary};
47
+
48
+ .${prefixCls}-dropdown-menu {
49
+ border-radius: 0 !important;
50
+ background: transparent !important;
51
+ box-shadow: none !important;
52
+ }
53
+ `,
54
+ header: css`
55
+ padding: ${cssVar.paddingXS};
56
+ border-block-end: 1px solid ${cssVar.colorBorderSecondary};
57
+ background: transparent;
58
+ `,
59
+ icon: css`
60
+ flex: none;
61
+ width: 18px;
62
+ height: 18px;
63
+ margin-inline-end: ${cssVar.marginXS};
64
+ `,
65
+ scroller: css`
66
+ overflow: hidden auto;
67
+ `,
68
+ }));
69
+
70
+ /**
71
+ * Klavis 服务器图标组件
72
+ * 对于 string 类型的 icon,使用 Image 组件渲染
73
+ * 对于 IconType 类型的 icon,使用 Icon 组件渲染,并根据主题设置填充色
74
+ */
75
+ const KlavisIcon = memo<Pick<KlavisServerType, 'icon' | 'label'>>(({ icon, label }) => {
76
+ if (typeof icon === 'string') {
77
+ return <img alt={label} className={styles.icon} height={18} src={icon} width={18} />;
78
+ }
79
+
80
+ // 使用主题色填充,在深色模式下自动适应
81
+ return <Icon className={styles.icon} fill={cssVar.colorText} icon={icon} size={18} />;
82
+ });
83
+
84
+ export interface AgentToolProps {
85
+ /**
86
+ * Whether to filter tools by availableInWeb property
87
+ * @default false
88
+ */
89
+ filterAvailableInWeb?: boolean;
90
+ /**
91
+ * Whether to show web browsing toggle functionality
92
+ * @default false
93
+ */
94
+ showWebBrowsing?: boolean;
95
+ /**
96
+ * Whether to use allMetaList (includes hidden tools) or metaList
97
+ * @default false
98
+ */
99
+ useAllMetaList?: boolean;
100
+ }
101
+
102
+ const AgentTool = memo<AgentToolProps>(
103
+ ({ showWebBrowsing = false, filterAvailableInWeb = false, useAllMetaList = false }) => {
104
+ const { t } = useTranslation('setting');
105
+ const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
106
+
107
+ // Plugin state management
108
+ const plugins = config?.plugins || [];
109
+
110
+ const toggleAgentPlugin = useAgentStore((s) => s.toggleAgentPlugin);
111
+ const updateAgentChatConfig = useAgentStore((s) => s.updateAgentChatConfig);
112
+ const installedPluginList = useToolStore(pluginSelectors.installedPluginMetaList, isEqual);
113
+
114
+ // Use appropriate builtin list based on prop
115
+ const builtinList = useToolStore(
116
+ useAllMetaList ? builtinToolSelectors.allMetaList : builtinToolSelectors.metaList,
117
+ isEqual,
118
+ );
119
+
120
+ // Web browsing uses searchMode instead of plugins array
121
+ const isSearchEnabled = useAgentStore(agentChatConfigSelectors.isAgentEnableSearch);
122
+
123
+ // Klavis 相关状态
124
+ const allKlavisServers = useToolStore(klavisStoreSelectors.getServers, isEqual);
125
+ const isKlavisEnabledInEnv = useServerConfigStore(serverConfigSelectors.enableKlavis);
126
+
127
+ // Plugin store modal state
128
+ const [modalOpen, setModalOpen] = useState(false);
129
+ const [updating, setUpdating] = useState(false);
130
+
131
+ // Tab state for dual-column layout
132
+ const [activeTab, setActiveTab] = useState<TabType | null>(null);
133
+ const isInitializedRef = useRef(false);
134
+
135
+ // Fetch plugins
136
+ const [useFetchPluginStore, useFetchUserKlavisServers] = useToolStore((s) => [
137
+ s.useFetchPluginStore,
138
+ s.useFetchUserKlavisServers,
139
+ ]);
140
+ useFetchPluginStore();
141
+ useFetchInstalledPlugins();
142
+ useCheckPluginsIsInstalled(plugins);
143
+
144
+ // 使用 SWR 加载用户的 Klavis 集成(从数据库)
145
+ useFetchUserKlavisServers(isKlavisEnabledInEnv);
146
+
147
+ // Toggle web browsing via searchMode
148
+ const toggleWebBrowsing = useCallback(async () => {
149
+ const nextMode = isSearchEnabled ? 'off' : 'auto';
150
+ await updateAgentChatConfig({ searchMode: nextMode });
151
+ }, [isSearchEnabled, updateAgentChatConfig]);
152
+
153
+ // Check if a tool is enabled (handles web browsing specially)
154
+ const isToolEnabled = useCallback(
155
+ (identifier: string) => {
156
+ if (showWebBrowsing && identifier === WEB_BROWSING_IDENTIFIER) {
157
+ return isSearchEnabled;
158
+ }
159
+ return plugins.includes(identifier);
160
+ },
161
+ [plugins, isSearchEnabled, showWebBrowsing],
162
+ );
163
+
164
+ // Toggle a tool (handles web browsing specially)
165
+ const handleToggleTool = useCallback(
166
+ async (identifier: string) => {
167
+ if (showWebBrowsing && identifier === WEB_BROWSING_IDENTIFIER) {
168
+ await toggleWebBrowsing();
169
+ } else {
170
+ await toggleAgentPlugin(identifier);
171
+ }
172
+ },
173
+ [toggleWebBrowsing, toggleAgentPlugin, showWebBrowsing],
174
+ );
175
+
176
+ // Set default tab based on installed plugins (only on first load)
177
+ useEffect(() => {
178
+ if (!isInitializedRef.current && plugins.length >= 0) {
179
+ isInitializedRef.current = true;
180
+ setActiveTab(plugins.length > 0 ? 'installed' : 'all');
181
+ }
182
+ }, [plugins.length]);
183
+
184
+ // 根据 identifier 获取已连接的服务器
185
+ const getServerByName = (identifier: string) => {
186
+ return allKlavisServers.find((server) => server.identifier === identifier);
187
+ };
188
+
189
+ // 获取所有 Klavis 服务器类型的 identifier 集合(用于过滤 builtinList)
190
+ const allKlavisTypeIdentifiers = useMemo(
191
+ () => new Set(KLAVIS_SERVER_TYPES.map((type) => type.identifier)),
192
+ [],
193
+ );
194
+
195
+ // 过滤掉 builtinList 中的 klavis 工具(它们会单独显示在 Klavis 区域)
196
+ // 根据配置,可选地过滤掉 availableInWeb: false 的工具(如 LocalSystem 仅桌面版可用)
197
+ const filteredBuiltinList = useMemo(() => {
198
+ // Cast to LobeToolMetaWithAvailability for type safety when filterAvailableInWeb is used
199
+ type ListType = typeof builtinList;
200
+ let list: ListType = builtinList;
201
+
202
+ // Filter by availableInWeb if requested (only makes sense when using allMetaList)
203
+ if (filterAvailableInWeb && useAllMetaList) {
204
+ list = (list as LobeToolMetaWithAvailability[]).filter(
205
+ (item) => item.availableInWeb,
206
+ ) as ListType;
207
+ }
208
+
209
+ // Filter out Klavis tools if Klavis is enabled
210
+ if (isKlavisEnabledInEnv) {
211
+ list = list.filter((item) => !allKlavisTypeIdentifiers.has(item.identifier));
212
+ }
213
+
214
+ return list;
215
+ }, [
216
+ builtinList,
217
+ allKlavisTypeIdentifiers,
218
+ isKlavisEnabledInEnv,
219
+ filterAvailableInWeb,
220
+ useAllMetaList,
221
+ ]);
222
+
223
+ // Klavis 服务器列表项
224
+ const klavisServerItems = useMemo(
225
+ () =>
226
+ isKlavisEnabledInEnv
227
+ ? KLAVIS_SERVER_TYPES.map((type) => ({
228
+ icon: <KlavisIcon icon={type.icon} label={type.label} />,
229
+ key: type.identifier,
230
+ label: (
231
+ <KlavisServerItem
232
+ identifier={type.identifier}
233
+ label={type.label}
234
+ server={getServerByName(type.identifier)}
235
+ serverName={type.serverName}
236
+ />
237
+ ),
238
+ }))
239
+ : [],
240
+ [isKlavisEnabledInEnv, allKlavisServers],
241
+ );
242
+
243
+ // Handle plugin remove via Tag close
244
+ const handleRemovePlugin =
245
+ (
246
+ pluginId: string | { enabled: boolean; identifier: string; settings: Record<string, any> },
247
+ ) =>
248
+ async (e: React.MouseEvent) => {
249
+ e.preventDefault();
250
+ e.stopPropagation();
251
+ const identifier = typeof pluginId === 'string' ? pluginId : pluginId?.identifier;
252
+ if (showWebBrowsing && identifier === WEB_BROWSING_IDENTIFIER) {
253
+ await updateAgentChatConfig({ searchMode: 'off' });
254
+ } else {
255
+ toggleAgentPlugin(identifier, false);
256
+ }
257
+ };
258
+
259
+ // Build dropdown menu items (adapted from useControls)
260
+ const enablePluginCount = plugins.filter(
261
+ (id) => !builtinList.some((b) => b.identifier === id),
262
+ ).length;
263
+
264
+ // 合并 builtin 工具和 Klavis 服务器
265
+ const builtinItems = useMemo(
266
+ () => [
267
+ // 原有的 builtin 工具
268
+ ...filteredBuiltinList.map((item) => ({
269
+ icon: <Avatar avatar={item.meta.avatar} size={20} style={{ flex: 'none' }} />,
270
+ key: item.identifier,
271
+ label: (
272
+ <ToolItem
273
+ checked={isToolEnabled(item.identifier)}
274
+ id={item.identifier}
275
+ label={item.meta?.title}
276
+ onUpdate={async () => {
277
+ setUpdating(true);
278
+ await handleToggleTool(item.identifier);
279
+ setUpdating(false);
280
+ }}
281
+ />
282
+ ),
283
+ })),
284
+ // Klavis 服务器
285
+ ...klavisServerItems,
286
+ ],
287
+ [filteredBuiltinList, klavisServerItems, isToolEnabled, handleToggleTool],
288
+ );
289
+
290
+ // Plugin items for dropdown
291
+ const pluginItems = useMemo(
292
+ () =>
293
+ installedPluginList.map((item) => ({
294
+ icon: item?.avatar ? (
295
+ <PluginAvatar avatar={item.avatar} size={20} />
296
+ ) : (
297
+ <Icon icon={ToyBrick} size={20} />
298
+ ),
299
+ key: item.identifier,
300
+ label: (
301
+ <ToolItem
302
+ checked={plugins.includes(item.identifier)}
303
+ id={item.identifier}
304
+ label={item.title}
305
+ onUpdate={async () => {
306
+ setUpdating(true);
307
+ await toggleAgentPlugin(item.identifier);
308
+ setUpdating(false);
309
+ }}
310
+ />
311
+ ),
312
+ })),
313
+ [installedPluginList, plugins, toggleAgentPlugin],
314
+ );
315
+
316
+ // All tab items (市场 tab)
317
+ const allTabItems: ItemType[] = useMemo(
318
+ () => [
319
+ {
320
+ children: builtinItems,
321
+ key: 'builtins',
322
+ label: t('tools.builtins.groupName'),
323
+ type: 'group',
324
+ },
325
+ {
326
+ children: pluginItems,
327
+ key: 'plugins',
328
+ label: (
329
+ <Flexbox align={'center'} gap={40} horizontal justify={'space-between'}>
330
+ {t('tools.plugins.groupName')}
331
+ {enablePluginCount === 0 ? null : (
332
+ <div style={{ fontSize: 12, marginInlineEnd: 4 }}>
333
+ {t('tools.plugins.enabled', { num: enablePluginCount })}
334
+ </div>
335
+ )}
336
+ </Flexbox>
337
+ ),
338
+ type: 'group',
339
+ },
340
+ {
341
+ type: 'divider',
342
+ },
343
+ {
344
+ extra: <Icon icon={ArrowRight} />,
345
+ icon: Store,
346
+ key: 'plugin-store',
347
+ label: t('tools.plugins.store'),
348
+ onClick: () => {
349
+ setModalOpen(true);
350
+ },
351
+ },
352
+ ],
353
+ [builtinItems, pluginItems, enablePluginCount, t],
354
+ );
355
+
356
+ // Installed tab items - 只显示已启用的
357
+ const installedTabItems: ItemType[] = useMemo(() => {
358
+ const items: ItemType[] = [];
359
+
360
+ // 已启用的 builtin 工具
361
+ const enabledBuiltinItems = filteredBuiltinList
362
+ .filter((item) => isToolEnabled(item.identifier))
363
+ .map((item) => ({
364
+ icon: <Avatar avatar={item.meta.avatar} size={20} style={{ flex: 'none' }} />,
365
+ key: item.identifier,
366
+ label: (
367
+ <ToolItem
368
+ checked={true}
369
+ id={item.identifier}
370
+ label={item.meta?.title}
371
+ onUpdate={async () => {
372
+ setUpdating(true);
373
+ await handleToggleTool(item.identifier);
374
+ setUpdating(false);
375
+ }}
376
+ />
377
+ ),
378
+ }));
379
+
380
+ // 已连接且已启用的 Klavis 服务器
381
+ const connectedKlavisItems = klavisServerItems.filter((item) =>
382
+ plugins.includes(item.key as string),
383
+ );
384
+
385
+ // 合并 builtin 和 Klavis
386
+ const allBuiltinItems = [...enabledBuiltinItems, ...connectedKlavisItems];
387
+
388
+ if (allBuiltinItems.length > 0) {
389
+ items.push({
390
+ children: allBuiltinItems,
391
+ key: 'installed-builtins',
392
+ label: t('tools.builtins.groupName'),
393
+ type: 'group',
394
+ });
395
+ }
396
+
397
+ // 已启用的插件
398
+ const installedPlugins = installedPluginList
399
+ .filter((item) => plugins.includes(item.identifier))
400
+ .map((item) => ({
401
+ icon: item?.avatar ? (
402
+ <PluginAvatar avatar={item.avatar} size={20} />
403
+ ) : (
404
+ <Icon icon={ToyBrick} size={20} />
405
+ ),
406
+ key: item.identifier,
407
+ label: (
408
+ <ToolItem
409
+ checked={true}
410
+ id={item.identifier}
411
+ label={item.title}
412
+ onUpdate={async () => {
413
+ setUpdating(true);
414
+ await toggleAgentPlugin(item.identifier);
415
+ setUpdating(false);
416
+ }}
417
+ />
418
+ ),
419
+ }));
420
+
421
+ if (installedPlugins.length > 0) {
422
+ items.push({
423
+ children: installedPlugins,
424
+ key: 'installed-plugins',
425
+ label: t('tools.plugins.groupName'),
426
+ type: 'group',
427
+ });
428
+ }
429
+
430
+ return items;
431
+ }, [
432
+ filteredBuiltinList,
433
+ klavisServerItems,
434
+ installedPluginList,
435
+ plugins,
436
+ isToolEnabled,
437
+ handleToggleTool,
438
+ toggleAgentPlugin,
439
+ t,
440
+ ]);
441
+
442
+ // Use effective tab for display (default to all while initializing)
443
+ const effectiveTab = activeTab ?? 'all';
444
+ const currentItems = effectiveTab === 'all' ? allTabItems : installedTabItems;
445
+
446
+ const button = (
447
+ <Button
448
+ icon={PlusIcon}
449
+ loading={updating}
450
+ size={'small'}
451
+ style={{ color: cssVar.colorTextSecondary }}
452
+ type={'text'}
453
+ >
454
+ {t('tools.add', { defaultValue: 'Add' })}
455
+ </Button>
456
+ );
457
+
458
+ // Combine plugins and web browsing for display
459
+ const allEnabledTools = useMemo(() => {
460
+ const tools = [...plugins];
461
+ // Add web browsing if enabled (it's not in plugins array)
462
+ if (showWebBrowsing && isSearchEnabled && !tools.includes(WEB_BROWSING_IDENTIFIER)) {
463
+ tools.unshift(WEB_BROWSING_IDENTIFIER);
464
+ }
465
+ return tools;
466
+ }, [plugins, isSearchEnabled, showWebBrowsing]);
467
+
468
+ return (
469
+ <>
470
+ {/* Plugin Selector and Tags */}
471
+ <Flexbox align="center" gap={8} horizontal wrap={'wrap'}>
472
+ {/* Second Row: Selected Plugins as Tags */}
473
+ {allEnabledTools.map((pluginId) => {
474
+ return (
475
+ <PluginTag
476
+ key={pluginId}
477
+ onRemove={handleRemovePlugin(pluginId)}
478
+ pluginId={pluginId}
479
+ showDesktopOnlyLabel={filterAvailableInWeb}
480
+ useAllMetaList={useAllMetaList}
481
+ />
482
+ );
483
+ })}
484
+ {/* Plugin Selector Dropdown - Using Action component pattern */}
485
+
486
+ <Suspense fallback={button}>
487
+ <ActionDropdown
488
+ maxHeight={500}
489
+ maxWidth={400}
490
+ menu={{
491
+ items: currentItems,
492
+ style: {
493
+ // let only the custom scroller scroll
494
+ maxHeight: 'unset',
495
+ overflowY: 'visible',
496
+ },
497
+ }}
498
+ minHeight={isKlavisEnabledInEnv ? 500 : undefined}
499
+ minWidth={400}
500
+ placement={'bottomLeft'}
501
+ popupRender={(menu) => (
502
+ <div className={styles.dropdown}>
503
+ {/* stopPropagation prevents dropdown's onClick from calling preventDefault on Segmented */}
504
+ <div className={styles.header} onClick={(e) => e.stopPropagation()}>
505
+ <Segmented
506
+ block
507
+ onChange={(v) => setActiveTab(v as TabType)}
508
+ options={[
509
+ {
510
+ label: t('tools.tabs.all', { defaultValue: 'All' }),
511
+ value: 'all',
512
+ },
513
+ {
514
+ label: t('tools.tabs.installed', { defaultValue: 'Installed' }),
515
+ value: 'installed',
516
+ },
517
+ ]}
518
+ size="small"
519
+ value={effectiveTab}
520
+ />
521
+ </div>
522
+ <div
523
+ className={styles.scroller}
524
+ style={{
525
+ maxHeight: 500,
526
+ minHeight: isKlavisEnabledInEnv ? 500 : undefined,
527
+ }}
528
+ >
529
+ {menu}
530
+ </div>
531
+ </div>
532
+ )}
533
+ trigger={['click']}
534
+ >
535
+ {button}
536
+ </ActionDropdown>
537
+ </Suspense>
538
+ </Flexbox>
539
+
540
+ {/* PluginStore Modal - rendered outside Flexbox to avoid event interference */}
541
+ {modalOpen && <PluginStore open={modalOpen} setOpen={setModalOpen} />}
542
+ </>
543
+ );
544
+ },
545
+ );
546
+
547
+ AgentTool.displayName = 'AgentTool';
548
+
549
+ export default AgentTool;