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:
Innei
2026-01-23 14:41:07 +08:00
committed by GitHub
parent f45f508fbd
commit e999851592
27 changed files with 656 additions and 500 deletions
+162
View File
@@ -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` hookReact 上下文)
- **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 控制示例
+1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { markdownToTxt } from 'markdown-to-txt';
import { markdownToTxt } from '@/utils/markdownToTxt';
const MIN_WIDTH = 12;
const MAX_WIDTH = 24;
+1 -1
View File
@@ -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>
);
};
+21 -283
View File
@@ -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';
+2 -7
View File
@@ -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} />}
</>
);
},
@@ -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';
+50 -87
View File
@@ -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>
);
});
+1 -1
View File
@@ -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;
};
+15 -33
View File
@@ -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)',
});
+3 -2
View File
@@ -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 -1
View File
@@ -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();
+20
View File
@@ -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;