mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
⚡ perf: optimize first-screen rendering (#11718)
* ♻️ refactor: migrate SkillStore and IntegrationDetailModal to imperative API - Refactor SkillStore to use createModal imperative API instead of declarative Modal - Refactor IntegrationDetailModal to use createModal with IntegrationDetailContent - Remove open/setOpen state management from all calling components - Add modal-imperative.mdc rule for modal best practices - Reduce code complexity and improve maintainability * 🐛 fix: keep modal open during OAuth flow until connection completes Close modal only after isConnected becomes true, not immediately after handleConnect returns. This ensures useSkillConnect listeners stay alive to detect OAuth completion via postMessage/polling. * 🔧 chore: update dependencies and refactor markdown handling - Updated "@lobehub/ui" to version "^4.27.4" in package.json. - Replaced "markdown-to-txt" with a local utility "markdownToTxt" for converting markdown to plain text across multiple components. - Refactored imports in various files to utilize the new markdownToTxt utility, improving code consistency and maintainability. Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
---
|
||||
description: Modal 命令式调用指南
|
||||
globs: "**/features/**/*.tsx"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Modal 命令式调用指南
|
||||
|
||||
当需要创建可命令式调用的 Modal 组件时,使用 `@lobehub/ui` 提供的 `createModal` API。
|
||||
|
||||
## 核心理念
|
||||
|
||||
**命令式调用** vs **声明式调用**:
|
||||
|
||||
| 模式 | 特点 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| 声明式 | 需要维护 `open` state,渲染 `<Modal />` 组件 | ❌ 不推荐 |
|
||||
| 命令式 | 直接调用函数打开,无需 state 管理 | ✅ 推荐 |
|
||||
|
||||
## 文件组织结构
|
||||
|
||||
```
|
||||
features/
|
||||
└── MyFeatureModal/
|
||||
├── index.tsx # 导出 createXxxModal 函数
|
||||
├── MyFeatureContent.tsx # Modal 内容组件
|
||||
└── ...其他子组件
|
||||
```
|
||||
|
||||
## createModal 用法(推荐)
|
||||
|
||||
### 1. 定义 Content 组件 (`MyFeatureContent.tsx`)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useModalContext } from '@lobehub/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const MyFeatureContent = () => {
|
||||
const { t } = useTranslation('namespace');
|
||||
const { close } = useModalContext(); // 可选:获取关闭方法
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Modal 内容 */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 导出 createModal 函数 (`index.tsx`)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { createModal } from '@lobehub/ui';
|
||||
import { t } from 'i18next'; // 注意:使用 i18next 而非 react-i18next
|
||||
|
||||
import { MyFeatureContent } from './MyFeatureContent';
|
||||
|
||||
export const createMyFeatureModal = () =>
|
||||
createModal({
|
||||
allowFullscreen: true,
|
||||
children: <MyFeatureContent />,
|
||||
destroyOnHidden: false,
|
||||
footer: null,
|
||||
styles: {
|
||||
body: { overflow: 'hidden', padding: 0 },
|
||||
},
|
||||
title: t('myFeature.title', { ns: 'setting' }),
|
||||
width: 'min(80%, 800px)',
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 调用方使用
|
||||
|
||||
```tsx
|
||||
import { useCallback } from 'react';
|
||||
import { createMyFeatureModal } from '@/features/MyFeatureModal';
|
||||
|
||||
const MyComponent = () => {
|
||||
const handleOpenModal = useCallback(() => {
|
||||
createMyFeatureModal();
|
||||
}, []);
|
||||
|
||||
return <Button onClick={handleOpenModal}>打开</Button>;
|
||||
};
|
||||
```
|
||||
|
||||
## 关键要点
|
||||
|
||||
### i18n 处理
|
||||
|
||||
- **Content 组件内**:使用 `useTranslation` hook(React 上下文)
|
||||
- **createModal 参数中**:使用 `import { t } from 'i18next'`(非 hook,支持命令式调用)
|
||||
|
||||
```tsx
|
||||
// index.tsx - 命令式上下文
|
||||
import { t } from 'i18next';
|
||||
title: t('key', { ns: 'namespace' })
|
||||
|
||||
// Content.tsx - React 组件上下文
|
||||
import { useTranslation } from 'react-i18next';
|
||||
const { t } = useTranslation('namespace');
|
||||
```
|
||||
|
||||
### useModalContext Hook
|
||||
|
||||
在 Content 组件内可使用 `useModalContext` 获取 Modal 控制方法:
|
||||
|
||||
```tsx
|
||||
const { close, setCanDismissByClickOutside } = useModalContext();
|
||||
```
|
||||
|
||||
### ModalHost
|
||||
|
||||
`createModal` 依赖全局 `<ModalHost />` 组件。项目中已在 `src/layout/GlobalProvider/index.tsx` 配置,无需额外添加。
|
||||
|
||||
## 常用配置项
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `allowFullscreen` | `boolean` | 允许全屏模式 |
|
||||
| `destroyOnHidden` | `boolean` | 关闭时是否销毁内容(`destroyOnClose` 已废弃) |
|
||||
| `footer` | `ReactNode \| null` | 底部内容,`null` 表示无底部 |
|
||||
| `width` | `string \| number` | Modal 宽度 |
|
||||
| `styles.body` | `CSSProperties` | body 区域样式 |
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### Before(声明式)
|
||||
|
||||
```tsx
|
||||
// 调用方需要维护 state
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>打开</Button>
|
||||
<MyModal open={open} setOpen={setOpen} />
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### After(命令式)
|
||||
|
||||
```tsx
|
||||
// 调用方无需 state,直接调用函数
|
||||
const handleOpen = useCallback(() => {
|
||||
createMyModal();
|
||||
}, []);
|
||||
|
||||
return <Button onClick={handleOpen}>打开</Button>;
|
||||
```
|
||||
|
||||
## 示例参考
|
||||
|
||||
- `src/features/SkillStore/index.tsx` - createModal 标准用法
|
||||
- `src/features/SkillStore/SkillStoreContent.tsx` - Content 组件示例
|
||||
- `src/features/LibraryModal/CreateNew/index.tsx` - 带回调的 createModal 用法
|
||||
- `src/features/Electron/updater/UpdateModal.tsx` - 复杂 Modal 控制示例
|
||||
@@ -18,6 +18,7 @@ All following rules are saved under `.cursor/rules/` directory:
|
||||
- `i18n.mdc` – Internationalization guide using react-i18next
|
||||
- `typescript.mdc` – TypeScript code style guide
|
||||
- `packages/react-layout-kit.mdc` – Usage guide for react-layout-kit
|
||||
- `modal-imperative.mdc` – Modal imperative API usage guide (createRawModal/createModal)
|
||||
|
||||
## State Management
|
||||
|
||||
|
||||
+6
-6
@@ -35,12 +35,12 @@
|
||||
"prebuild": "tsx scripts/prebuild.mts && npm run lint",
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build --webpack",
|
||||
"postbuild": "npm run build-sitemap && npm run build-migrate-db",
|
||||
"build-migrate-db": "bun run db:migrate",
|
||||
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
|
||||
"build:analyze": "NODE_OPTIONS=--max-old-space-size=81920 ANALYZE=true next build --webpack",
|
||||
"build:docker": "npm run prebuild && NODE_OPTIONS=--max-old-space-size=8192 DOCKER=true next build --webpack && npm run build-sitemap",
|
||||
"build:electron": "cross-env NODE_OPTIONS=--max-old-space-size=8192 NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/electronWorkflow/buildNextApp.mts",
|
||||
"build:vercel": "npm run prebuild && cross-env NODE_OPTIONS=--max-old-space-size=6144 next build --webpack && npm run postbuild",
|
||||
"build-migrate-db": "bun run db:migrate",
|
||||
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
|
||||
"clean:node_modules": "bash -lc 'set -e; echo \"Removing all node_modules...\"; rm -rf node_modules; pnpm -r exec rm -rf node_modules; rm -rf apps/desktop/node_modules; echo \"All node_modules removed.\"'",
|
||||
"db:generate": "drizzle-kit generate && npm run workflow:dbml",
|
||||
"db:migrate": "MIGRATION_DB=1 tsx ./scripts/migrateServerDB/index.ts",
|
||||
@@ -87,11 +87,11 @@
|
||||
"start": "next start -p 3210",
|
||||
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"test": "npm run test-app && npm run test-server",
|
||||
"test-app": "vitest run",
|
||||
"test-app:coverage": "vitest --coverage --silent='passed-only'",
|
||||
"test:e2e": "pnpm --filter @lobechat/e2e-tests test",
|
||||
"test:e2e:smoke": "pnpm --filter @lobechat/e2e-tests test:smoke",
|
||||
"test:update": "vitest -u",
|
||||
"test-app": "vitest run",
|
||||
"test-app:coverage": "vitest --coverage --silent='passed-only'",
|
||||
"tunnel:cloudflare": "cloudflared tunnel --url http://localhost:3010",
|
||||
"tunnel:ngrok": "ngrok http http://localhost:3011",
|
||||
"type-check": "tsgo --noEmit",
|
||||
@@ -207,7 +207,7 @@
|
||||
"@lobehub/icons": "^4.0.2",
|
||||
"@lobehub/market-sdk": "0.29.1",
|
||||
"@lobehub/tts": "^4.0.2",
|
||||
"@lobehub/ui": "^4.25.0",
|
||||
"@lobehub/ui": "^4.27.4",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@neondatabase/serverless": "^1.0.2",
|
||||
"@next/third-parties": "^16.1.1",
|
||||
@@ -274,7 +274,6 @@
|
||||
"langfuse-core": "^3.38.6",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"markdown-to-txt": "^2.0.1",
|
||||
"marked": "^17.0.1",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"model-bank": "workspace:*",
|
||||
@@ -329,6 +328,7 @@
|
||||
"remark": "^15.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-html": "^16.0.1",
|
||||
"remove-markdown": "^0.6.0",
|
||||
"resend": "^6.6.0",
|
||||
"resolve-accept-language": "^3.1.15",
|
||||
"rtl-detect": "^1.1.2",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { consola } from 'consola';
|
||||
import { readJsonSync, writeJSONSync } from 'fs-extra';
|
||||
import { markdownToTxt } from 'markdown-to-txt';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import semver from 'semver';
|
||||
|
||||
import { markdownToTxt } from '@/utils/markdownToTxt';
|
||||
|
||||
import { CHANGELOG_DIR, CHANGELOG_FILE } from './const';
|
||||
|
||||
export interface ChangelogStaticItem {
|
||||
|
||||
@@ -4,10 +4,10 @@ import { getKlavisServerByServerIdentifier, getLobehubSkillProviderById } from '
|
||||
import { Avatar, Flexbox, Icon } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { Blocks } from 'lucide-react';
|
||||
import { type ReactNode, createElement, memo, useMemo, useState } from 'react';
|
||||
import { type ReactNode, createElement, memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SkillStore from '@/features/SkillStore';
|
||||
import { createSkillStoreModal } from '@/features/SkillStore';
|
||||
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
|
||||
@@ -61,7 +61,6 @@ const BANNER_SKILL_IDS = [
|
||||
|
||||
const SkillInstallBanner = memo(() => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const isLobehubSkillEnabled = useServerConfigStore(serverConfigSelectors.enableLobehubSkill);
|
||||
const isKlavisEnabled = useServerConfigStore(serverConfigSelectors.enableKlavis);
|
||||
@@ -108,20 +107,21 @@ const SkillInstallBanner = memo(() => {
|
||||
return items;
|
||||
}, []);
|
||||
|
||||
const handleOpenStore = useCallback(() => {
|
||||
createSkillStoreModal();
|
||||
}, []);
|
||||
|
||||
// Don't show banner if no skills are enabled
|
||||
if (!isLobehubSkillEnabled && !isKlavisEnabled) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.banner} onClick={() => setOpen(true)}>
|
||||
<Flexbox align="center" gap={8} horizontal>
|
||||
<Icon className={styles.icon} icon={Blocks} size={18} />
|
||||
<span className={styles.text}>{t('skillInstallBanner.title')}</span>
|
||||
</Flexbox>
|
||||
{avatarItems.length > 0 && <Avatar.Group items={avatarItems} shape="circle" size={24} />}
|
||||
</div>
|
||||
<SkillStore open={open} setOpen={setOpen} />
|
||||
</>
|
||||
<div className={styles.banner} onClick={handleOpenStore}>
|
||||
<Flexbox align="center" gap={8} horizontal>
|
||||
<Icon className={styles.icon} icon={Blocks} size={18} />
|
||||
<span className={styles.text}>{t('skillInstallBanner.title')}</span>
|
||||
</Flexbox>
|
||||
{avatarItems.length > 0 && <Avatar.Group items={avatarItems} shape="circle" size={24} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { Avatar, Block, Center, Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
import markdownToTxt from 'markdown-to-txt';
|
||||
import { memo } from 'react';
|
||||
|
||||
import Time from '@/app/[variants]/(main)/home/features/components/Time';
|
||||
import { RECENT_BLOCK_SIZE } from '@/app/[variants]/(main)/home/features/const';
|
||||
import { type FileListItem } from '@/types/files';
|
||||
import markdownToTxt from '@/utils/markdownToTxt';
|
||||
|
||||
// Helper to extract title from markdown content
|
||||
const extractTitle = (content: string): string | null => {
|
||||
@@ -24,7 +24,7 @@ const getPreviewText = (item: FileListItem): string => {
|
||||
if (!item.content) return '';
|
||||
|
||||
// Convert markdown to plain text
|
||||
let plainText = markdownToTxt(item.content);
|
||||
let plainText = markdownToTxt(item.content.slice(0, 120));
|
||||
|
||||
// Remove the title line if it exists
|
||||
const title = extractTitle(item.content);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Loader2, MoreVerticalIcon, SquareArrowOutUpRight, Unplug } from 'lucide
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import IntegrationDetailModal from '@/features/IntegrationDetailModal';
|
||||
import { createIntegrationDetailModal } from '@/features/IntegrationDetailModal';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import { type KlavisServer, KlavisServerStatus } from '@/store/tool/slices/klavisStore';
|
||||
import { useUserStore } from '@/store/user';
|
||||
@@ -72,7 +72,6 @@ const KlavisSkillItem = memo<KlavisSkillItemProps>(({ serverType, server }) => {
|
||||
const { modal } = App.useApp();
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isWaitingAuth, setIsWaitingAuth] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const oauthWindowRef = useRef<Window | null>(null);
|
||||
const windowCheckIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -313,8 +312,7 @@ const KlavisSkillItem = memo<KlavisSkillItemProps>(({ serverType, server }) => {
|
||||
const isConnected = server?.status === KlavisServerStatus.CONNECTED;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flexbox
|
||||
<Flexbox
|
||||
align="center"
|
||||
className={styles.container}
|
||||
gap={16}
|
||||
@@ -324,7 +322,16 @@ const KlavisSkillItem = memo<KlavisSkillItemProps>(({ serverType, server }) => {
|
||||
<Flexbox align="center" gap={16} horizontal style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<div className={styles.icon}>{renderIcon()}</div>
|
||||
<Flexbox gap={4} style={{ overflow: 'hidden' }}>
|
||||
<span className={styles.title} onClick={() => setDetailOpen(true)}>
|
||||
<span
|
||||
className={styles.title}
|
||||
onClick={() =>
|
||||
createIntegrationDetailModal({
|
||||
identifier: serverType.identifier,
|
||||
serverName: serverType.serverName,
|
||||
type: 'klavis',
|
||||
})
|
||||
}
|
||||
>
|
||||
{serverType.label}
|
||||
</span>
|
||||
{!isConnected && renderStatus()}
|
||||
@@ -335,15 +342,6 @@ const KlavisSkillItem = memo<KlavisSkillItemProps>(({ serverType, server }) => {
|
||||
{renderAction()}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
<IntegrationDetailModal
|
||||
identifier={serverType.identifier}
|
||||
isConnecting={isConnecting || isWaitingAuth}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
onConnect={handleConnect}
|
||||
open={detailOpen}
|
||||
type="klavis"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Loader2, MoreVerticalIcon, SquareArrowOutUpRight, Unplug } from 'lucide
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import IntegrationDetailModal from '@/features/IntegrationDetailModal';
|
||||
import { createIntegrationDetailModal } from '@/features/IntegrationDetailModal';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import {
|
||||
type LobehubSkillServer,
|
||||
@@ -75,7 +75,6 @@ const LobehubSkillItem = memo<LobehubSkillItemProps>(({ provider, server }) => {
|
||||
const { modal } = App.useApp();
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isWaitingAuth, setIsWaitingAuth] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const oauthWindowRef = useRef<Window | null>(null);
|
||||
const windowCheckIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -299,8 +298,7 @@ const LobehubSkillItem = memo<LobehubSkillItemProps>(({ provider, server }) => {
|
||||
const isConnected = server?.status === LobehubSkillStatus.CONNECTED;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flexbox
|
||||
<Flexbox
|
||||
align="center"
|
||||
className={styles.container}
|
||||
gap={16}
|
||||
@@ -314,7 +312,12 @@ const LobehubSkillItem = memo<LobehubSkillItemProps>(({ provider, server }) => {
|
||||
<Flexbox gap={4} style={{ overflow: 'hidden' }}>
|
||||
<span
|
||||
className={`${styles.title} ${!isConnected ? styles.disconnectedTitle : ''}`}
|
||||
onClick={() => setDetailOpen(true)}
|
||||
onClick={() =>
|
||||
createIntegrationDetailModal({
|
||||
identifier: provider.id,
|
||||
type: 'lobehub',
|
||||
})
|
||||
}
|
||||
>
|
||||
{provider.label}
|
||||
</span>
|
||||
@@ -326,15 +329,6 @@ const LobehubSkillItem = memo<LobehubSkillItemProps>(({ provider, server }) => {
|
||||
{renderAction()}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
<IntegrationDetailModal
|
||||
identifier={provider.id}
|
||||
isConnecting={isConnecting || isWaitingAuth}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
onConnect={handleConnect}
|
||||
open={detailOpen}
|
||||
type="lobehub"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,30 +2,32 @@
|
||||
|
||||
import { Button, Icon } from '@lobehub/ui';
|
||||
import { Store } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SettingHeader from '@/app/[variants]/(main)/settings/features/SettingHeader';
|
||||
import SkillStore from '@/features/SkillStore';
|
||||
import { createSkillStoreModal } from '@/features/SkillStore';
|
||||
|
||||
import SkillList from './features/SkillList';
|
||||
|
||||
const Page = () => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpenStore = useCallback(() => {
|
||||
createSkillStoreModal();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
extra={
|
||||
<Button icon={<Icon icon={Store} />} onClick={() => setOpen(true)}>
|
||||
<Button icon={<Icon icon={Store} />} onClick={handleOpenStore}>
|
||||
{t('skillStore.button')}
|
||||
</Button>
|
||||
}
|
||||
title={t('tab.skill')}
|
||||
/>
|
||||
<SkillList />
|
||||
<SkillStore open={open} setOpen={setOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,14 +5,14 @@ import { Center, Flexbox } from '@lobehub/ui';
|
||||
import { Space, Switch } from 'antd';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { LucideTrash2, Plug2, Store } from 'lucide-react';
|
||||
import { memo, useState } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import PluginAvatar from '@/components/Plugins/PluginAvatar';
|
||||
import PluginTag from '@/components/Plugins/PluginTag';
|
||||
import { FORM_STYLE } from '@/const/layoutTokens';
|
||||
import SkillStore from '@/features/SkillStore';
|
||||
import { createSkillStoreModal } from '@/features/SkillStore';
|
||||
import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
|
||||
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
import { pluginHelpers, useToolStore } from '@/store/tool';
|
||||
@@ -27,10 +27,12 @@ import PluginAction from './PluginAction';
|
||||
const AgentPlugin = memo(() => {
|
||||
const { t } = useTranslation('setting');
|
||||
|
||||
const [showStore, setShowStore] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleOpenStore = useCallback(() => {
|
||||
createSkillStoreModal();
|
||||
}, []);
|
||||
|
||||
const [userEnabledPlugins, toggleAgentPlugin] = useStore((s) => [
|
||||
s.config.plugins || [],
|
||||
s.toggleAgentPlugin,
|
||||
@@ -120,7 +122,7 @@ const AgentPlugin = memo(() => {
|
||||
icon={Store}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowStore(true);
|
||||
handleOpenStore();
|
||||
}}
|
||||
size={'small'}
|
||||
/>
|
||||
@@ -139,7 +141,7 @@ const AgentPlugin = memo(() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowStore(true);
|
||||
handleOpenStore();
|
||||
navigate('/community/mcp');
|
||||
}}
|
||||
to={'/community/mcp'}
|
||||
@@ -168,12 +170,7 @@ const AgentPlugin = memo(() => {
|
||||
title: t('settingPlugin.title'),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SkillStore open={showStore} setOpen={setShowStore} />
|
||||
<Form items={[plugin]} itemsType={'group'} variant={'borderless'} {...FORM_STYLE} />
|
||||
</>
|
||||
);
|
||||
return <Form items={[plugin]} itemsType={'group'} variant={'borderless'} {...FORM_STYLE} />;
|
||||
});
|
||||
|
||||
export default AgentPlugin;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Blocks } from 'lucide-react';
|
||||
import { Suspense, memo, useState } from 'react';
|
||||
import { Suspense, memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SkillStore from '@/features/SkillStore';
|
||||
import { createSkillStoreModal } from '@/features/SkillStore';
|
||||
import { useModelSupportToolUse } from '@/hooks/useModelSupportToolUse';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
@@ -15,7 +15,6 @@ import { useControls } from './useControls';
|
||||
|
||||
const Tools = memo(() => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const { marketItems } = useControls({
|
||||
setUpdating,
|
||||
@@ -29,6 +28,10 @@ const Tools = memo(() => {
|
||||
|
||||
const enableFC = useModelSupportToolUse(model, provider);
|
||||
|
||||
const handleOpenStore = useCallback(() => {
|
||||
createSkillStoreModal();
|
||||
}, []);
|
||||
|
||||
if (!enableFC)
|
||||
return <Action disabled icon={Blocks} showTooltip={true} title={t('tools.disabled')} />;
|
||||
|
||||
@@ -42,7 +45,7 @@ const Tools = memo(() => {
|
||||
<PopoverContent
|
||||
enableKlavis={enableKlavis}
|
||||
items={marketItems}
|
||||
onOpenStore={() => setModalOpen(true)}
|
||||
onOpenStore={handleOpenStore}
|
||||
/>
|
||||
),
|
||||
maxWidth: 320,
|
||||
@@ -56,7 +59,6 @@ const Tools = memo(() => {
|
||||
showTooltip={false}
|
||||
title={t('tools.title')}
|
||||
/>
|
||||
<SkillStore open={modalOpen} setOpen={setModalOpen} />
|
||||
</Suspense>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { markdownToTxt } from 'markdown-to-txt';
|
||||
import { markdownToTxt } from '@/utils/markdownToTxt';
|
||||
|
||||
const MIN_WIDTH = 12;
|
||||
const MAX_WIDTH = 24;
|
||||
|
||||
@@ -11,12 +11,12 @@ import {
|
||||
Puzzle,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { markdownToTxt } from 'markdown-to-txt';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import type { SearchResult } from '@/database/repositories/search';
|
||||
import { markdownToTxt } from '@/utils/markdownToTxt';
|
||||
|
||||
import { CommandItem } from './components';
|
||||
import { styles } from './styles';
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type KlavisServerType,
|
||||
type LobehubSkillProviderType,
|
||||
getKlavisServerByServerIdentifier,
|
||||
getLobehubSkillProviderById,
|
||||
} from '@lobechat/const';
|
||||
import { Flexbox, Icon, Image, Tag, Text, Typography, useModalContext } from '@lobehub/ui';
|
||||
import { Button, Divider } from 'antd';
|
||||
import { createStyles, cssVar } from 'antd-style';
|
||||
import type { Klavis } from 'klavis';
|
||||
import { ExternalLink, Loader2, SquareArrowOutUpRight } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSkillConnect } from '@/features/SkillStore/LobeHubList/useSkillConnect';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import { klavisStoreSelectors, lobehubSkillStoreSelectors } from '@/store/tool/selectors';
|
||||
import { KlavisServerStatus } from '@/store/tool/slices/klavisStore';
|
||||
import { LobehubSkillStatus } from '@/store/tool/slices/lobehubSkillStore/types';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
authorLink: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
color: ${token.colorPrimary};
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
detailItem: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`,
|
||||
detailLabel: css`
|
||||
font-size: 12px;
|
||||
color: ${token.colorTextTertiary};
|
||||
`,
|
||||
header: css`
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
|
||||
background: ${token.colorFillTertiary};
|
||||
`,
|
||||
icon: css`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
|
||||
background: ${token.colorBgContainer};
|
||||
`,
|
||||
introduction: css`
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: ${token.colorText};
|
||||
`,
|
||||
sectionTitle: css`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${token.colorText};
|
||||
`,
|
||||
title: css`
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: ${token.colorText};
|
||||
`,
|
||||
toolTag: css`
|
||||
font-family: ${token.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
`,
|
||||
toolsContainer: css`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
`,
|
||||
trustWarning: css`
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: ${token.colorTextTertiary};
|
||||
`,
|
||||
}));
|
||||
|
||||
export type IntegrationType = 'klavis' | 'lobehub';
|
||||
|
||||
export interface IntegrationDetailContentProps {
|
||||
identifier: string;
|
||||
serverName?: Klavis.McpServerName;
|
||||
type: IntegrationType;
|
||||
}
|
||||
|
||||
export const IntegrationDetailContent = ({
|
||||
type,
|
||||
identifier,
|
||||
serverName,
|
||||
}: IntegrationDetailContentProps) => {
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation(['plugin', 'setting']);
|
||||
const { close } = useModalContext();
|
||||
|
||||
const {
|
||||
handleConnect,
|
||||
isConnecting,
|
||||
isConnected: hookIsConnected,
|
||||
} = useSkillConnect({
|
||||
identifier,
|
||||
serverName,
|
||||
type,
|
||||
});
|
||||
|
||||
const hasTriggeredConnectRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasTriggeredConnectRef.current && hookIsConnected) {
|
||||
close();
|
||||
}
|
||||
}, [hookIsConnected, close]);
|
||||
|
||||
const handleConnectWithTracking = async () => {
|
||||
hasTriggeredConnectRef.current = true;
|
||||
await handleConnect();
|
||||
};
|
||||
|
||||
const config = useMemo((): KlavisServerType | LobehubSkillProviderType | undefined => {
|
||||
if (type === 'klavis') {
|
||||
return getKlavisServerByServerIdentifier(identifier);
|
||||
}
|
||||
return getLobehubSkillProviderById(identifier);
|
||||
}, [type, identifier]);
|
||||
|
||||
const klavisServers = useToolStore(klavisStoreSelectors.getServers);
|
||||
const lobehubSkillServers = useToolStore(lobehubSkillStoreSelectors.getServers);
|
||||
|
||||
const serverState = useMemo(() => {
|
||||
if (type === 'klavis') {
|
||||
return klavisServers.find((s) => s.identifier === identifier);
|
||||
}
|
||||
return lobehubSkillServers.find((s) => s.identifier === identifier);
|
||||
}, [type, identifier, klavisServers, lobehubSkillServers]);
|
||||
|
||||
const isConnected = useMemo(() => {
|
||||
if (!serverState) return false;
|
||||
if (type === 'klavis') {
|
||||
return serverState.status === KlavisServerStatus.CONNECTED;
|
||||
}
|
||||
return serverState.status === LobehubSkillStatus.CONNECTED;
|
||||
}, [type, serverState]);
|
||||
|
||||
const tools = useMemo(() => {
|
||||
return serverState?.tools?.map((tool) => tool.name) || [];
|
||||
}, [serverState]);
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const { author, authorUrl, description, icon, introduction, label } = config;
|
||||
|
||||
const i18nIdentifier =
|
||||
type === 'klavis'
|
||||
? (config as KlavisServerType).identifier
|
||||
: (config as LobehubSkillProviderType).id;
|
||||
const i18nPrefix = type === 'klavis' ? 'tools.klavis.servers' : 'tools.lobehubSkill.providers';
|
||||
|
||||
const localizedDescription = t(`${i18nPrefix}.${i18nIdentifier}.description`, {
|
||||
defaultValue: description,
|
||||
ns: 'setting',
|
||||
});
|
||||
const localizedIntroduction = t(`${i18nPrefix}.${i18nIdentifier}.introduction`, {
|
||||
defaultValue: introduction,
|
||||
ns: 'setting',
|
||||
});
|
||||
|
||||
const renderIcon = () => {
|
||||
if (typeof icon === 'string') {
|
||||
return <Image alt={label} height={36} src={icon} width={36} />;
|
||||
}
|
||||
return <Icon fill={cssVar.colorText} icon={icon} size={36} />;
|
||||
};
|
||||
|
||||
const handleAuthorClick = () => {
|
||||
if (authorUrl) {
|
||||
window.open(authorUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const renderConnectButton = () => {
|
||||
if (isConnected) return null;
|
||||
|
||||
if (isConnecting) {
|
||||
return (
|
||||
<Button disabled icon={<Icon icon={Loader2} spin />} type="default">
|
||||
{t('tools.klavis.connect', { defaultValue: 'Connect', ns: 'setting' })}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={<Icon icon={SquareArrowOutUpRight} />}
|
||||
onClick={handleConnectWithTracking}
|
||||
type="primary"
|
||||
>
|
||||
{t('tools.klavis.connect', { defaultValue: 'Connect', ns: 'setting' })}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox gap={20}>
|
||||
{/* Header */}
|
||||
<Flexbox
|
||||
align="center"
|
||||
className={styles.header}
|
||||
horizontal
|
||||
justify="space-between"
|
||||
style={{ flexWrap: 'nowrap' }}
|
||||
>
|
||||
<Flexbox align="center" gap={16} horizontal>
|
||||
<div className={styles.icon}>{renderIcon()}</div>
|
||||
<Flexbox gap={4}>
|
||||
<span className={styles.title}>{label}</span>
|
||||
<Text style={{ fontSize: 14 }} type="secondary">
|
||||
{localizedDescription}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
{renderConnectButton()}
|
||||
</Flexbox>
|
||||
|
||||
{/* Introduction */}
|
||||
<Typography className={styles.introduction}>{localizedIntroduction}</Typography>
|
||||
|
||||
{/* Developed by */}
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox align="center" gap={4} horizontal>
|
||||
<span className={styles.sectionTitle}>{t('integrationDetail.developedBy')}</span>
|
||||
<span
|
||||
className={styles.authorLink}
|
||||
onClick={handleAuthorClick}
|
||||
style={{ cursor: authorUrl ? 'pointer' : 'default' }}
|
||||
>
|
||||
{author}
|
||||
{authorUrl && <Icon icon={ExternalLink} size={12} />}
|
||||
</span>
|
||||
</Flexbox>
|
||||
<Text className={styles.trustWarning} type="secondary">
|
||||
{t('integrationDetail.trustWarning')}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
|
||||
{/* Tools */}
|
||||
{tools.length > 0 && (
|
||||
<>
|
||||
<Divider style={{ margin: 0 }} />
|
||||
<Flexbox gap={12}>
|
||||
<Flexbox align="center" gap={8} horizontal>
|
||||
<span className={styles.sectionTitle}>{t('integrationDetail.tools')}</span>
|
||||
<Tag>{tools.length}</Tag>
|
||||
</Flexbox>
|
||||
<div className={styles.toolsContainer}>
|
||||
{tools.map((tool) => (
|
||||
<Tag className={styles.toolTag} key={tool}>
|
||||
{tool}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Flexbox>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Details */}
|
||||
<Divider style={{ margin: 0 }} />
|
||||
<Flexbox gap={12}>
|
||||
<span className={styles.sectionTitle}>{t('integrationDetail.details')}</span>
|
||||
<Flexbox gap={16} horizontal>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>{t('integrationDetail.author')}</span>
|
||||
<span
|
||||
className={styles.authorLink}
|
||||
onClick={handleAuthorClick}
|
||||
style={{ cursor: authorUrl ? 'pointer' : 'default' }}
|
||||
>
|
||||
{author}
|
||||
{authorUrl && <Icon icon={ExternalLink} size={12} />}
|
||||
</span>
|
||||
</div>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
@@ -1,292 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type KlavisServerType,
|
||||
type LobehubSkillProviderType,
|
||||
getKlavisServerByServerIdentifier,
|
||||
getLobehubSkillProviderById,
|
||||
} from '@lobechat/const';
|
||||
import { Flexbox, Icon, Image, Modal, Tag, Text, Typography } from '@lobehub/ui';
|
||||
import { Button, Divider } from 'antd';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { ExternalLink, Loader2, SquareArrowOutUpRight } from 'lucide-react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createModal } from '@lobehub/ui';
|
||||
import { t } from 'i18next';
|
||||
import type { Klavis } from 'klavis';
|
||||
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import { klavisStoreSelectors, lobehubSkillStoreSelectors } from '@/store/tool/selectors';
|
||||
import { KlavisServerStatus } from '@/store/tool/slices/klavisStore';
|
||||
import { LobehubSkillStatus } from '@/store/tool/slices/lobehubSkillStore/types';
|
||||
import { IntegrationDetailContent, type IntegrationType } from './IntegrationDetailContent';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
authorLink: css`
|
||||
cursor: pointer;
|
||||
export type { IntegrationType } from './IntegrationDetailContent';
|
||||
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
color: ${cssVar.colorPrimary};
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
detailItem: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`,
|
||||
detailLabel: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
header: css`
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
icon: css`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
introduction: css`
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
sectionTitle: css`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
title: css`
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
toolTag: css`
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
`,
|
||||
toolsContainer: css`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
`,
|
||||
trustWarning: css`
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
}));
|
||||
|
||||
export type IntegrationType = 'klavis' | 'lobehub';
|
||||
|
||||
export interface IntegrationDetailModalProps {
|
||||
export interface CreateIntegrationDetailModalOptions {
|
||||
identifier: string;
|
||||
isConnecting?: boolean;
|
||||
onClose: () => void;
|
||||
onConnect?: () => void;
|
||||
open: boolean;
|
||||
serverName?: Klavis.McpServerName;
|
||||
type: IntegrationType;
|
||||
}
|
||||
|
||||
const IntegrationDetailModal = memo<IntegrationDetailModalProps>(
|
||||
({ open, onClose, type, identifier, isConnecting, onConnect }) => {
|
||||
const { t } = useTranslation(['plugin', 'setting']);
|
||||
|
||||
// Get static config based on type
|
||||
const config = useMemo((): KlavisServerType | LobehubSkillProviderType | undefined => {
|
||||
if (type === 'klavis') {
|
||||
return getKlavisServerByServerIdentifier(identifier);
|
||||
}
|
||||
return getLobehubSkillProviderById(identifier);
|
||||
}, [type, identifier]);
|
||||
|
||||
// Get dynamic state from store
|
||||
const klavisServers = useToolStore(klavisStoreSelectors.getServers);
|
||||
const lobehubSkillServers = useToolStore(lobehubSkillStoreSelectors.getServers);
|
||||
|
||||
const serverState = useMemo(() => {
|
||||
if (type === 'klavis') {
|
||||
return klavisServers.find((s) => s.identifier === identifier);
|
||||
}
|
||||
return lobehubSkillServers.find((s) => s.identifier === identifier);
|
||||
}, [type, identifier, klavisServers, lobehubSkillServers]);
|
||||
|
||||
const isConnected = useMemo(() => {
|
||||
if (!serverState) return false;
|
||||
if (type === 'klavis') {
|
||||
return serverState.status === KlavisServerStatus.CONNECTED;
|
||||
}
|
||||
return serverState.status === LobehubSkillStatus.CONNECTED;
|
||||
}, [type, serverState]);
|
||||
|
||||
const tools = useMemo(() => {
|
||||
return serverState?.tools?.map((tool) => tool.name) || [];
|
||||
}, [serverState]);
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const { author, authorUrl, description, icon, introduction, label } = config;
|
||||
|
||||
// Get identifier for i18n keys
|
||||
const i18nIdentifier =
|
||||
type === 'klavis'
|
||||
? (config as KlavisServerType).identifier
|
||||
: (config as LobehubSkillProviderType).id;
|
||||
const i18nPrefix = type === 'klavis' ? 'tools.klavis.servers' : 'tools.lobehubSkill.providers';
|
||||
|
||||
const localizedDescription = t(`${i18nPrefix}.${i18nIdentifier}.description`, {
|
||||
defaultValue: description,
|
||||
ns: 'setting',
|
||||
});
|
||||
const localizedIntroduction = t(`${i18nPrefix}.${i18nIdentifier}.introduction`, {
|
||||
defaultValue: introduction,
|
||||
ns: 'setting',
|
||||
});
|
||||
|
||||
const renderIcon = () => {
|
||||
if (typeof icon === 'string') {
|
||||
return <Image alt={label} height={36} src={icon} width={36} />;
|
||||
}
|
||||
return <Icon fill={cssVar.colorText} icon={icon} size={36} />;
|
||||
};
|
||||
|
||||
const handleAuthorClick = () => {
|
||||
if (authorUrl) {
|
||||
window.open(authorUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const renderConnectButton = () => {
|
||||
if (isConnected) return null;
|
||||
if (!onConnect) return null;
|
||||
|
||||
if (isConnecting) {
|
||||
return (
|
||||
<Button disabled icon={<Icon icon={Loader2} spin />} type="default">
|
||||
{t('tools.klavis.connect', { defaultValue: 'Connect', ns: 'setting' })}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button icon={<Icon icon={SquareArrowOutUpRight} />} onClick={onConnect} type="primary">
|
||||
{t('tools.klavis.connect', { defaultValue: 'Connect', ns: 'setting' })}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
destroyOnHidden
|
||||
footer={null}
|
||||
onCancel={onClose}
|
||||
open={open}
|
||||
title={t('dev.title.skillDetails')}
|
||||
width={800}
|
||||
>
|
||||
<Flexbox gap={20}>
|
||||
{/* Header */}
|
||||
<Flexbox
|
||||
align="center"
|
||||
className={styles.header}
|
||||
horizontal
|
||||
justify="space-between"
|
||||
style={{ flexWrap: 'nowrap' }}
|
||||
>
|
||||
<Flexbox align="center" gap={16} horizontal>
|
||||
<div className={styles.icon}>{renderIcon()}</div>
|
||||
<Flexbox gap={4}>
|
||||
<span className={styles.title}>{label}</span>
|
||||
<Text style={{ fontSize: 14 }} type="secondary">
|
||||
{localizedDescription}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
{renderConnectButton()}
|
||||
</Flexbox>
|
||||
|
||||
{/* Introduction */}
|
||||
<Typography className={styles.introduction}>{localizedIntroduction}</Typography>
|
||||
|
||||
{/* Developed by */}
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox align="center" gap={4} horizontal>
|
||||
<span className={styles.sectionTitle}>{t('integrationDetail.developedBy')}</span>
|
||||
<span
|
||||
className={styles.authorLink}
|
||||
onClick={handleAuthorClick}
|
||||
style={{ cursor: authorUrl ? 'pointer' : 'default' }}
|
||||
>
|
||||
{author}
|
||||
{authorUrl && <Icon icon={ExternalLink} size={12} />}
|
||||
</span>
|
||||
</Flexbox>
|
||||
<Text className={styles.trustWarning} type="secondary">
|
||||
{t('integrationDetail.trustWarning')}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
|
||||
{/* Tools */}
|
||||
{tools.length > 0 && (
|
||||
<>
|
||||
<Divider style={{ margin: 0 }} />
|
||||
<Flexbox gap={12}>
|
||||
<Flexbox align="center" gap={8} horizontal>
|
||||
<span className={styles.sectionTitle}>{t('integrationDetail.tools')}</span>
|
||||
<Tag>{tools.length}</Tag>
|
||||
</Flexbox>
|
||||
<div className={styles.toolsContainer}>
|
||||
{tools.map((tool) => (
|
||||
<Tag className={styles.toolTag} key={tool}>
|
||||
{tool}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Flexbox>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Details */}
|
||||
<Divider style={{ margin: 0 }} />
|
||||
<Flexbox gap={12}>
|
||||
<span className={styles.sectionTitle}>{t('integrationDetail.details')}</span>
|
||||
<Flexbox gap={16} horizontal>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>{t('integrationDetail.author')}</span>
|
||||
<span
|
||||
className={styles.authorLink}
|
||||
onClick={handleAuthorClick}
|
||||
style={{ cursor: authorUrl ? 'pointer' : 'default' }}
|
||||
>
|
||||
{author}
|
||||
{authorUrl && <Icon icon={ExternalLink} size={12} />}
|
||||
</span>
|
||||
</div>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
IntegrationDetailModal.displayName = 'IntegrationDetailModal';
|
||||
|
||||
export default IntegrationDetailModal;
|
||||
export const createIntegrationDetailModal = ({
|
||||
identifier,
|
||||
serverName,
|
||||
type,
|
||||
}: CreateIntegrationDetailModalOptions) =>
|
||||
createModal({
|
||||
children: (
|
||||
<IntegrationDetailContent identifier={identifier} serverName={serverName} type={type} />
|
||||
),
|
||||
destroyOnHidden: true,
|
||||
footer: null,
|
||||
title: t('dev.title.skillDetails', { ns: 'plugin' }),
|
||||
width: 800,
|
||||
});
|
||||
|
||||
@@ -23,12 +23,12 @@ import {
|
||||
Package,
|
||||
TerminalIcon,
|
||||
} from 'lucide-react';
|
||||
import { markdownToTxt } from 'markdown-to-txt';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Descriptions from '@/components/Descriptions';
|
||||
import InlineTable from '@/components/InlineTable';
|
||||
import { markdownToTxt } from '@/utils/markdownToTxt';
|
||||
|
||||
import Title from '../../../app/[variants]/(main)/community/features/Title';
|
||||
import InstallationIcon from '../../../components/MCPDepsIcon';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Block, Collapse, Empty, Highlighter, Icon, Markdown } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { CheckIcon, MessageSquare, MinusIcon } from 'lucide-react';
|
||||
import { markdownToTxt } from 'markdown-to-txt';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import InlineTable from '@/components/InlineTable';
|
||||
import { markdownToTxt } from '@/utils/markdownToTxt';
|
||||
|
||||
import Title from '../../../app/[variants]/(main)/community/features/Title';
|
||||
import CollapseDesc from '../CollapseDesc';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Block, Collapse, Empty, Highlighter, Icon, Markdown, Tag } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { CheckIcon, MinusIcon, Wrench } from 'lucide-react';
|
||||
import { markdownToTxt } from 'markdown-to-txt';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import InlineTable from '@/components/InlineTable';
|
||||
import { markdownToTxt } from '@/utils/markdownToTxt';
|
||||
|
||||
import Title from '../../../app/[variants]/(main)/community/features/Title';
|
||||
import CollapseDesc from '../CollapseDesc';
|
||||
|
||||
@@ -18,7 +18,7 @@ import KlavisServerItem from '@/features/ChatInput/ActionBar/Tools/KlavisServerI
|
||||
import LobehubSkillServerItem from '@/features/ChatInput/ActionBar/Tools/LobehubSkillServerItem';
|
||||
import ToolItem from '@/features/ChatInput/ActionBar/Tools/ToolItem';
|
||||
import ActionDropdown from '@/features/ChatInput/ActionBar/components/ActionDropdown';
|
||||
import SkillStore from '@/features/SkillStore';
|
||||
import { createSkillStoreModal } from '@/features/SkillStore';
|
||||
import { useCheckPluginsIsInstalled } from '@/hooks/useCheckPluginsIsInstalled';
|
||||
import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
@@ -157,8 +157,6 @@ const AgentTool = memo<AgentToolProps>(
|
||||
const allLobehubSkillServers = useToolStore(lobehubSkillStoreSelectors.getServers, isEqual);
|
||||
const isLobehubSkillEnabled = useServerConfigStore(serverConfigSelectors.enableLobehubSkill);
|
||||
|
||||
// Plugin store modal state
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
// Tab state for dual-column layout
|
||||
@@ -423,7 +421,7 @@ const AgentTool = memo<AgentToolProps>(
|
||||
key: 'plugin-store',
|
||||
label: t('tools.plugins.store'),
|
||||
onClick: () => {
|
||||
setModalOpen(true);
|
||||
createSkillStoreModal();
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -623,9 +621,6 @@ const AgentTool = memo<AgentToolProps>(
|
||||
</ActionDropdown>
|
||||
</Suspense>
|
||||
</Flexbox>
|
||||
|
||||
{/* PluginStore Modal - rendered outside Flexbox to avoid event interference */}
|
||||
{modalOpen && <SkillStore open={modalOpen} setOpen={setModalOpen} />}
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
+1
-1
@@ -2,7 +2,6 @@ import { Button, Tooltip } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { isNull } from 'es-toolkit/compat';
|
||||
import { FileBoxIcon } from 'lucide-react';
|
||||
import markdownToTxt from 'markdown-to-txt';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -10,6 +9,7 @@ import FileIcon from '@/components/FileIcon';
|
||||
import { fileManagerSelectors, useFileStore } from '@/store/file';
|
||||
import { type AsyncTaskStatus, type IAsyncTaskError } from '@/types/asyncTask';
|
||||
import { isChunkingUnsupported } from '@/utils/isChunkingUnsupported';
|
||||
import markdownToTxt from '@/utils/markdownToTxt';
|
||||
|
||||
import ChunksBadge from '../../ListView/ListItem/ChunkTag';
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
import { KLAVIS_SERVER_TYPES, LOBEHUB_SKILL_PROVIDERS } from '@lobechat/const';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import type { Klavis } from 'klavis';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
|
||||
import IntegrationDetailModal from '@/features/IntegrationDetailModal';
|
||||
import { createIntegrationDetailModal } from '@/features/IntegrationDetailModal';
|
||||
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import { klavisStoreSelectors, lobehubSkillStoreSelectors } from '@/store/tool/selectors';
|
||||
@@ -15,7 +14,6 @@ import { LobehubSkillStatus } from '@/store/tool/slices/lobehubSkillStore/types'
|
||||
|
||||
import Empty from '../Empty';
|
||||
import Item from './Item';
|
||||
import { useSkillConnect } from './useSkillConnect';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
grid: css`
|
||||
@@ -36,40 +34,7 @@ interface LobeHubListProps {
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
interface DetailState {
|
||||
identifier: string;
|
||||
serverName?: Klavis.McpServerName;
|
||||
type: 'klavis' | 'lobehub';
|
||||
}
|
||||
|
||||
interface DetailModalWithConnectProps {
|
||||
detailState: DetailState;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DetailModalWithConnect = memo<DetailModalWithConnectProps>(({ detailState, onClose }) => {
|
||||
const { handleConnect, isConnecting } = useSkillConnect({
|
||||
identifier: detailState.identifier,
|
||||
serverName: detailState.serverName,
|
||||
type: detailState.type,
|
||||
});
|
||||
|
||||
return (
|
||||
<IntegrationDetailModal
|
||||
identifier={detailState.identifier}
|
||||
isConnecting={isConnecting}
|
||||
onClose={onClose}
|
||||
onConnect={handleConnect}
|
||||
open
|
||||
type={detailState.type}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DetailModalWithConnect.displayName = 'DetailModalWithConnect';
|
||||
|
||||
export const LobeHubList = memo<LobeHubListProps>(({ keywords }) => {
|
||||
const [detailState, setDetailState] = useState<DetailState | null>(null);
|
||||
|
||||
const isLobehubSkillEnabled = useServerConfigStore(serverConfigSelectors.enableLobehubSkill);
|
||||
const isKlavisEnabled = useServerConfigStore(serverConfigSelectors.enableKlavis);
|
||||
@@ -84,13 +49,19 @@ export const LobeHubList = memo<LobeHubListProps>(({ keywords }) => {
|
||||
useFetchLobehubSkillConnections(isLobehubSkillEnabled);
|
||||
useFetchUserKlavisServers(isKlavisEnabled);
|
||||
|
||||
const getLobehubSkillServerByProvider = (providerId: string) => {
|
||||
return allLobehubSkillServers.find((server) => server.identifier === providerId);
|
||||
};
|
||||
const getLobehubSkillServerByProvider = useCallback(
|
||||
(providerId: string) => {
|
||||
return allLobehubSkillServers.find((server) => server.identifier === providerId);
|
||||
},
|
||||
[allLobehubSkillServers],
|
||||
);
|
||||
|
||||
const getKlavisServerByIdentifier = (identifier: string) => {
|
||||
return allKlavisServers.find((server) => server.identifier === identifier);
|
||||
};
|
||||
const getKlavisServerByIdentifier = useCallback(
|
||||
(identifier: string) => {
|
||||
return allKlavisServers.find((server) => server.identifier === identifier);
|
||||
},
|
||||
[allKlavisServers],
|
||||
);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const items: Array<
|
||||
@@ -127,57 +98,49 @@ export const LobeHubList = memo<LobeHubListProps>(({ keywords }) => {
|
||||
if (filteredItems.length === 0) return <Empty search={hasSearchKeywords} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
{filteredItems.map((item) => {
|
||||
if (item.type === 'lobehub') {
|
||||
const server = getLobehubSkillServerByProvider(item.provider.id);
|
||||
const isConnected = server?.status === LobehubSkillStatus.CONNECTED;
|
||||
return (
|
||||
<Item
|
||||
description={item.provider.description}
|
||||
icon={item.provider.icon}
|
||||
identifier={item.provider.id}
|
||||
isConnected={isConnected}
|
||||
key={item.provider.id}
|
||||
label={item.provider.label}
|
||||
onOpenDetail={() =>
|
||||
setDetailState({ identifier: item.provider.id, type: 'lobehub' })
|
||||
}
|
||||
type="lobehub"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const server = getKlavisServerByIdentifier(item.serverType.identifier);
|
||||
const isConnected = server?.status === KlavisServerStatus.CONNECTED;
|
||||
<div className={styles.grid}>
|
||||
{filteredItems.map((item) => {
|
||||
if (item.type === 'lobehub') {
|
||||
const server = getLobehubSkillServerByProvider(item.provider.id);
|
||||
const isConnected = server?.status === LobehubSkillStatus.CONNECTED;
|
||||
return (
|
||||
<Item
|
||||
description={item.serverType.description}
|
||||
icon={item.serverType.icon}
|
||||
identifier={item.serverType.identifier}
|
||||
description={item.provider.description}
|
||||
icon={item.provider.icon}
|
||||
identifier={item.provider.id}
|
||||
isConnected={isConnected}
|
||||
key={item.serverType.identifier}
|
||||
label={item.serverType.label}
|
||||
key={item.provider.id}
|
||||
label={item.provider.label}
|
||||
onOpenDetail={() =>
|
||||
setDetailState({
|
||||
identifier: item.serverType.identifier,
|
||||
serverName: item.serverType.serverName,
|
||||
type: 'klavis',
|
||||
})
|
||||
createIntegrationDetailModal({ identifier: item.provider.id, type: 'lobehub' })
|
||||
}
|
||||
serverName={item.serverType.serverName}
|
||||
type="klavis"
|
||||
type="lobehub"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{detailState && (
|
||||
<DetailModalWithConnect
|
||||
detailState={detailState}
|
||||
onClose={() => setDetailState(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
const server = getKlavisServerByIdentifier(item.serverType.identifier);
|
||||
const isConnected = server?.status === KlavisServerStatus.CONNECTED;
|
||||
return (
|
||||
<Item
|
||||
description={item.serverType.description}
|
||||
icon={item.serverType.icon}
|
||||
identifier={item.serverType.identifier}
|
||||
isConnected={isConnected}
|
||||
key={item.serverType.identifier}
|
||||
label={item.serverType.label}
|
||||
onOpenDetail={() =>
|
||||
createIntegrationDetailModal({
|
||||
identifier: item.serverType.identifier,
|
||||
serverName: item.serverType.serverName,
|
||||
type: 'klavis',
|
||||
})
|
||||
}
|
||||
serverName={item.serverType.serverName}
|
||||
type="klavis"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useToolStore } from '@/store/tool';
|
||||
|
||||
import { SkillStoreTab } from '../Content';
|
||||
import { SkillStoreTab } from '../SkillStoreContent';
|
||||
|
||||
interface SearchProps {
|
||||
activeTab: SkillStoreTab;
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
import { Flexbox, Segmented } from '@lobehub/ui';
|
||||
import { type SegmentedOptions } from 'antd/es/segmented';
|
||||
import { memo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AddSkillButton from './AddSkillButton';
|
||||
|
||||
import CommunityList from './CommunityList';
|
||||
import LobeHubList from './LobeHubList';
|
||||
import Search from './Search';
|
||||
@@ -16,7 +15,7 @@ export enum SkillStoreTab {
|
||||
LobeHub = 'lobehub',
|
||||
}
|
||||
|
||||
export const Content = memo(() => {
|
||||
export const SkillStoreContent = () => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [activeTab, setActiveTab] = useState<SkillStoreTab>(SkillStoreTab.LobeHub);
|
||||
const [lobehubKeywords, setLobehubKeywords] = useState('');
|
||||
@@ -52,8 +51,4 @@ export const Content = memo(() => {
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
Content.displayName = 'SkillStoreContent';
|
||||
|
||||
export default Content;
|
||||
};
|
||||
@@ -1,37 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { Modal } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createModal } from '@lobehub/ui';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import Content from './Content';
|
||||
import { SkillStoreContent } from './SkillStoreContent';
|
||||
|
||||
interface SkillStoreProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const SkillStore = memo<SkillStoreProps>(({ open, setOpen }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
allowFullscreen
|
||||
destroyOnClose={false}
|
||||
footer={null}
|
||||
onCancel={() => setOpen(false)}
|
||||
open={open}
|
||||
styles={{
|
||||
body: { overflow: 'hidden', padding: 0 },
|
||||
}}
|
||||
title={t('skillStore.title')}
|
||||
width={'min(80%, 800px)'}
|
||||
>
|
||||
<Content />
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
SkillStore.displayName = 'SkillStore';
|
||||
|
||||
export default SkillStore;
|
||||
export const createSkillStoreModal = () =>
|
||||
createModal({
|
||||
allowFullscreen: true,
|
||||
children: <SkillStoreContent />,
|
||||
destroyOnHidden: false,
|
||||
footer: null,
|
||||
styles: {
|
||||
body: { overflow: 'hidden', padding: 0 },
|
||||
},
|
||||
title: t('skillStore.title', { ns: 'setting' }),
|
||||
width: 'min(80%, 800px)',
|
||||
});
|
||||
|
||||
@@ -19,8 +19,9 @@ vi.mock('gray-matter', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('markdown-to-txt', () => ({
|
||||
markdownToTxt: vi.fn().mockImplementation((text) => text),
|
||||
vi.mock('@/utils/markdownToTxt', () => ({
|
||||
default: vi.fn().mockImplementation((text: string) => text),
|
||||
markdownToTxt: vi.fn().mockImplementation((text: string) => text),
|
||||
}));
|
||||
|
||||
vi.mock('semver', async (importOriginal) => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { template } from 'es-toolkit/compat';
|
||||
import matter from 'gray-matter';
|
||||
import { markdownToTxt } from 'markdown-to-txt';
|
||||
import semver from 'semver';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
import { FetchCacheTag } from '@/const/cacheControl';
|
||||
import { type Locales } from '@/locales/resources';
|
||||
import { type ChangelogIndexItem } from '@/types/changelog';
|
||||
import { markdownToTxt } from '@/utils/markdownToTxt';
|
||||
|
||||
const URL_TEMPLATE = 'https://raw.githubusercontent.com/{{user}}/{{repo}}/{{branch}}/{{path}}';
|
||||
const LAST_MODIFIED = new Date().toISOString();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import removeMarkdown from 'remove-markdown';
|
||||
|
||||
/**
|
||||
* Convert markdown into plain text.
|
||||
*
|
||||
* This is a local wrapper to avoid importing third-party markdown-to-txt directly.
|
||||
* It uses `remark` + `strip-markdown` under the hood.
|
||||
*/
|
||||
export const markdownToTxt = (markdown: string): string => {
|
||||
if (!markdown) return '';
|
||||
|
||||
try {
|
||||
return removeMarkdown(markdown).trimEnd();
|
||||
} catch {
|
||||
// Best-effort: fall back to raw input when parsing fails.
|
||||
return markdown;
|
||||
}
|
||||
};
|
||||
|
||||
export default markdownToTxt;
|
||||
Reference in New Issue
Block a user