Compare commits

..

3 Commits

Author SHA1 Message Date
Innei cdeec322bd 🐛 fix: emit function_call stream events in Responses API adapter
Made-with: Cursor
2026-04-01 21:32:34 +08:00
Arvin Xu 8af28a778b 🐛 fix(fetch-sse): stop injecting contextBody into structured provider errors (#13477)
* 🐛 fix(fetch-sse): stop injecting contextBody into structured provider errors

Structured errors (ProviderBizError etc.) already contain complete context.
Spreading contextBody into their body overwrites fields like `provider` and
pollutes the error structure that downstream renderers depend on.

Fixes #13476

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

*  test(fetch-sse): add regression test for structured error body pollution

Ensures structured provider errors (e.g. ProviderBizError) are passed through
unchanged without contextBody injection, and that contextBody is only applied
to unknown/unstructured errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:24:01 +08:00
Innei 6ecae1bbd1 ♻️ refactor: gate agent onboarding with dedicated business flag (#13472)
* ♻️ refactor: gate agent onboarding with dedicated business flag

Made-with: Cursor

* 🗑️ chore(migrations): remove agent onboarding column from users table

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(onboarding): enable agent onboarding based on environment and add redirect to classic onboarding

- Updated AGENT_ONBOARDING_ENABLED to be true in development mode.
- Introduced RedirectToClassicOnboarding component to handle navigation to classic onboarding.
- Simplified ClassicOnboardingPage by removing the mode switch button for non-development environments.
- Adjusted OnBoardingContainer to conditionally render the skip onboarding button based on the current route.

This change enhances the onboarding experience by ensuring that the agent onboarding feature is only available in development, while also improving navigation for users.

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(test): inline emoji-mart and @lobehub/* deps in Vitest to fix ESM JSON import error

Widen server.deps.inline to include `emoji-mart` and all `@lobehub/*`
packages so their transitive `@emoji-mart/data` import (a .json main
entry) goes through Vite's transform pipeline instead of Node's native
ESM loader, which requires `with { type: "json" }`.

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-01 19:38:14 +08:00
40 changed files with 174 additions and 1715 deletions
-25
View File
@@ -547,14 +547,6 @@
"skills.categories.transportation.name": "Transportation",
"skills.categories.web-frontend-development.description": "Web development, frontend frameworks, and UI tooling skills",
"skills.categories.web-frontend-development.name": "Web & Frontend Development",
"skills.collection.editorCollection": "EDITOR'S COLLECTION",
"skills.collection.get": "Get",
"skills.collection.installAll": "Install all skills",
"skills.collection.installDesc": "Get all {{count}} skills installed at once. Your agent will walk you through each one.",
"skills.collection.installTitle": "Install the full collection",
"skills.collection.moreCollections": "More collections",
"skills.collection.skillCount": "{{count}} skills",
"skills.collection.skillsInCollection": "Skills in this collection",
"skills.details.nav.needHelp": "Need Help?",
"skills.details.nav.reportIssue": "Report Issue",
"skills.details.nav.viewSourceCode": "View Source Code",
@@ -598,28 +590,11 @@
"skills.details.versions.title": "Version History",
"skills.hero.guide.agent": "I am Agent",
"skills.hero.guide.human": "I am Human",
"skills.sections.collection": "Collection",
"skills.sections.collection1Desc": "A curated starter pack for developers building their first end-to-end coding agent workflow.",
"skills.sections.collection1Title": "Get started: your first autonomous coding agent",
"skills.sections.collection2Desc": "5 must-have skills to guard your agents against injection, leakage, and runaway permissions.",
"skills.sections.collection2Title": "Essential security skills for production agents",
"skills.sections.collections": "Editor's collections",
"skills.sections.editorsPick": "EDITOR'S PICK",
"skills.sections.editorsPickDesc": "Security-first vetting for every skill before it touches your agent. Checks permission scope, red flags, and injection risks automatically.",
"skills.sections.editorsPickTitle": "Build safer agents with skill-vetter",
"skills.sections.featured": "Featured",
"skills.sections.getSkill": "Get skill",
"skills.sections.learnMore": "Learn more",
"skills.sections.seeAll": "See all →",
"skills.sections.trending": "Trending this week",
"skills.sorts.createdAt": "Recently Published",
"skills.sorts.installCount": "Downloads",
"skills.sorts.name": "Name",
"skills.sorts.stars": "GitHub Stars",
"skills.sorts.updatedAt": "Recently Updated",
"skills.tabs.discover": "Discover",
"skills.tabs.new": "New",
"skills.tabs.trending": "Trending",
"tab.assistant": "Agent",
"tab.home": "Home",
"tab.model": "Model",
-25
View File
@@ -547,14 +547,6 @@
"skills.categories.transportation.name": "交通运输",
"skills.categories.web-frontend-development.description": "Web开发、前端框架和UI工具技能",
"skills.categories.web-frontend-development.name": "Web与前端开发",
"skills.collection.editorCollection": "编辑精选",
"skills.collection.get": "获取",
"skills.collection.installAll": "安装全部技能",
"skills.collection.installDesc": "一次性安装全部 {{count}} 个技能。你的智能体将逐一引导你完成配置。",
"skills.collection.installTitle": "安装完整合集",
"skills.collection.moreCollections": "更多合集",
"skills.collection.skillCount": "{{count}} 个技能",
"skills.collection.skillsInCollection": "合集中的技能",
"skills.details.nav.needHelp": "需要帮助?",
"skills.details.nav.reportIssue": "报告问题",
"skills.details.nav.viewSourceCode": "查看源码",
@@ -598,28 +590,11 @@
"skills.details.versions.title": "版本历史",
"skills.hero.guide.agent": "我是 Agent",
"skills.hero.guide.human": "我是人类",
"skills.sections.collection": "精选集",
"skills.sections.collection1Desc": "为开发者打造的入门套件,帮助你构建第一个端到端的编程智能体工作流。",
"skills.sections.collection1Title": "入门指南:你的第一个自主编程智能体",
"skills.sections.collection2Desc": "5 个必备技能,保护你的智能体免受注入、泄露和权限失控的威胁。",
"skills.sections.collection2Title": "生产环境智能体必备安全技能",
"skills.sections.collections": "编辑精选集",
"skills.sections.editorsPick": "编辑推荐",
"skills.sections.editorsPickDesc": "在技能触及你的智能体之前,进行安全优先的审查。自动检查权限范围、危险信号和注入风险。",
"skills.sections.editorsPickTitle": "使用 skill-vetter 构建更安全的智能体",
"skills.sections.featured": "精选",
"skills.sections.getSkill": "获取技能",
"skills.sections.learnMore": "了解更多",
"skills.sections.seeAll": "查看全部 →",
"skills.sections.trending": "本周热门",
"skills.sorts.createdAt": "最近发布",
"skills.sorts.installCount": "最多下载",
"skills.sorts.name": "名称",
"skills.sorts.stars": "GitHub 星标",
"skills.sorts.updatedAt": "最近更新",
"skills.tabs.discover": "发现",
"skills.tabs.new": "最新",
"skills.tabs.trending": "热门",
"tab.assistant": "助理",
"tab.home": "首页",
"tab.model": "模型",
+4
View File
@@ -3,4 +3,8 @@ export * from './branding';
export * from './llm';
export * from './url';
const isDev = process.env.NODE_ENV === 'development';
export const ENABLE_BUSINESS_FEATURES = false;
export const AGENT_ONBOARDING_ENABLED = isDev;
@@ -1 +0,0 @@
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "agent_onboarding" jsonb;
@@ -598,6 +598,42 @@ describe('fetchSSE', () => {
expect(mockOnErrorHandle).toHaveBeenCalledWith(mockError);
});
it('should NOT inject contextBody into structured provider errors (regression)', async () => {
const mockOnErrorHandle = vi.fn();
const mockError: ChatMessageError = {
body: {
error: {
type: 'invalid_request_error',
message: 'Invalid signature in thinking block',
},
provider: 'lobehub',
errorType: 'ProviderBizError',
},
message: 'ProviderBizError',
type: 'ProviderBizError',
};
(fetchEventSource as any).mockImplementationOnce(
(url: string, options: FetchEventSourceInit) => {
options.onerror!(mockError);
},
);
try {
await fetchSSE('/', {
onErrorHandle: mockOnErrorHandle,
requestContext: { provider: 'openai', model: 'gpt-4o' },
});
} catch (e) {}
expect(mockOnErrorHandle).toHaveBeenCalledWith(mockError);
const receivedError = mockOnErrorHandle.mock.calls[0][0];
expect(receivedError.body).not.toHaveProperty('elapsedMs');
expect(receivedError.body).not.toHaveProperty('networkStatus');
expect(receivedError.body).not.toHaveProperty('model');
expect(receivedError.body.provider).toBe('lobehub');
});
it('should call onErrorHandle when Unknown error is thrown', async () => {
const mockOnErrorHandle = vi.fn();
const mockError = new Error('Unknown error');
@@ -609,7 +645,10 @@ describe('fetchSSE', () => {
);
try {
await fetchSSE('/', { onErrorHandle: mockOnErrorHandle });
await fetchSSE('/', {
onErrorHandle: mockOnErrorHandle,
requestContext: { provider: 'openai', model: 'gpt-4o' },
});
} catch (e) {}
expect(mockOnErrorHandle).toHaveBeenCalledWith({
@@ -618,7 +657,10 @@ describe('fetchSSE', () => {
body: {
message: 'Unknown error',
name: 'Error',
stack: expect.any(String),
provider: 'openai',
model: 'gpt-4o',
elapsedMs: expect.any(Number),
networkStatus: expect.any(Boolean),
},
});
});
+1 -1
View File
@@ -323,7 +323,7 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
options.onErrorHandle?.(
error.type
? { ...error, body: { ...error.body, ...contextBody } }
? error
: {
body: {
message: error.message,
@@ -91,9 +91,11 @@ export const transformResponseToStream = (data: OpenAI.ChatCompletion) =>
export const transformResponseAPIToStream = (data: OpenAI.Responses.Response) =>
new ReadableStream({
start(controller) {
let toolIndex = 0;
// Check if output exists and is an array
if (data.output && Array.isArray(data.output)) {
data.output.forEach((output) => {
data.output.forEach((output, outputIndex) => {
switch (output.type) {
case 'message': {
// Check if content exists and is an array
@@ -115,6 +117,46 @@ export const transformResponseAPIToStream = (data: OpenAI.Responses.Response) =>
}
break;
}
case 'function_call': {
const fnOutput = output as any;
// Emit output_item.added so the stream handler sets up tool context
controller.enqueue({
item: {
call_id: fnOutput.call_id,
name: fnOutput.name,
type: 'function_call',
},
output_index: outputIndex,
type: 'response.output_item.added',
});
// Emit the full arguments as a single delta
if (fnOutput.arguments) {
controller.enqueue({
delta: fnOutput.arguments,
item_id: fnOutput.id,
output_index: outputIndex,
type: 'response.function_call_arguments.delta',
});
}
// Emit done event for this function call
controller.enqueue({
item: {
arguments: fnOutput.arguments || '',
call_id: fnOutput.call_id,
name: fnOutput.name,
type: 'function_call',
},
output_index: outputIndex,
type: 'response.output_item.done',
});
toolIndex++;
break;
}
}
});
}
-26
View File
@@ -94,29 +94,3 @@ export interface DiscoverSkillDetail extends MarketSkillDetail {
}
export type SkillCategoryItem = MarketSkillCategory;
// ============================== Skill Collections ==============================
export interface SkillCollectionItem {
cover?: string;
createdAt: string;
icon: string;
id: number;
itemCount: number;
position: number;
slug: string;
summary: string;
title: string;
updatedAt: string;
}
export interface SkillCollectionDetail extends SkillCollectionItem {
currentPage: number;
description?: string;
items: DiscoverSkillItem[];
pageSize: number;
totalCount: number;
totalPages: number;
}
export type SkillCollectionListResponse = SkillCollectionItem[];
+17 -9
View File
@@ -2,11 +2,12 @@
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
import { SESSION_CHAT_URL } from '@lobechat/const';
import { Button, ErrorBoundary, Flexbox, Text } from '@lobehub/ui';
import { Button, ErrorBoundary, Flexbox } from '@lobehub/ui';
import { Drawer } from 'antd';
import { History } from 'lucide-react';
import { memo, useMemo, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Loading from '@/components/Loading/BrandTextLoading';
import ModeSwitch from '@/features/Onboarding/components/ModeSwitch';
@@ -25,6 +26,19 @@ import AgentOnboardingDebugExportButton from './DebugExportButton';
import HistoryPanel from './HistoryPanel';
import OnboardingConversationProvider from './OnboardingConversationProvider';
const CLASSIC_ONBOARDING_PATH = '/onboarding/classic';
const RedirectToClassicOnboarding = memo(() => {
const navigate = useNavigate();
useEffect(() => {
navigate(CLASSIC_ONBOARDING_PATH, { replace: true });
}, [navigate]);
return <Loading debugId="AgentOnboardingRedirectClassic" />;
});
RedirectToClassicOnboarding.displayName = 'RedirectToClassicOnboarding';
const AgentOnboardingPage = memo(() => {
const { t } = useTranslation('onboarding');
const useInitBuiltinAgent = useAgentStore((s) => s.useInitBuiltinAgent);
@@ -89,13 +103,7 @@ const AgentOnboardingPage = memo(() => {
if (error) {
return (
<OnboardingContainer>
<Flexbox gap={16} style={{ maxWidth: 720, width: '100%' }}>
<ModeSwitch />
<Flexbox gap={8}>
<Text weight={'bold'}>Failed to initialize onboarding.</Text>
<Button onClick={() => mutate()}>Retry</Button>
</Flexbox>
</Flexbox>
<RedirectToClassicOnboarding />
</OnboardingContainer>
);
}
+2 -13
View File
@@ -1,9 +1,8 @@
'use client';
import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
import { Button, Flexbox } from '@lobehub/ui';
import { Flexbox } from '@lobehub/ui';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Loading from '@/components/Loading/BrandTextLoading';
import ModeSwitch from '@/features/Onboarding/components/ModeSwitch';
@@ -15,10 +14,8 @@ import ResponseLanguageStep from '@/routes/onboarding/features/ResponseLanguageS
import TelemetryStep from '@/routes/onboarding/features/TelemetryStep';
import { useUserStore } from '@/store/user';
import { onboardingSelectors } from '@/store/user/selectors';
import { isDev } from '@/utils/env';
const ClassicOnboardingPage = memo(() => {
const { t } = useTranslation('onboarding');
const [isUserStateInit, currentStep, goToNextStep, goToPreviousStep, resetOnboarding] =
useUserStore((s) => [
s.isUserStateInit,
@@ -69,15 +66,7 @@ const ClassicOnboardingPage = memo(() => {
return (
<OnboardingContainer>
<Flexbox gap={24} style={{ maxWidth: 480, width: '100%' }}>
<ModeSwitch
actions={
isDev ? (
<Button danger loading={isResetting} size={'small'} onClick={handleReset}>
{t('agent.modeSwitch.reset')}
</Button>
) : undefined
}
/>
<ModeSwitch />
{renderStep()}
</Flexbox>
</OnboardingContainer>
-54
View File
@@ -1094,60 +1094,6 @@ export default {
'skills.hero.guide.human': 'I am Human',
'skills.sections.collection': 'Collection',
'skills.sections.collection1Desc':
'A curated starter pack for developers building their first end-to-end coding agent workflow.',
'skills.sections.collection1Title': 'Get started: your first autonomous coding agent',
'skills.sections.collection2Desc':
'5 must-have skills to guard your agents against injection, leakage, and runaway permissions.',
'skills.sections.collection2Title': 'Essential security skills for production agents',
'skills.sections.collections': "Editor's collections",
'skills.sections.editorsPick': "EDITOR'S PICK",
'skills.sections.editorsPickDesc':
'Security-first vetting for every skill before it touches your agent. Checks permission scope, red flags, and injection risks automatically.',
'skills.sections.editorsPickTitle': 'Build safer agents with skill-vetter',
'skills.sections.featured': 'Featured',
'skills.sections.getSkill': 'Get skill',
'skills.sections.learnMore': 'Learn more',
'skills.sections.seeAll': 'See all →',
'skills.sections.trending': 'Trending this week',
'skills.tabs.discover': 'Discover',
'skills.tabs.new': 'New',
'skills.tabs.trending': 'Trending',
'skills.collection.editorCollection': "EDITOR'S COLLECTION",
'skills.collection.get': 'Get',
'skills.collection.installAll': 'Install all skills',
'skills.collection.installDesc':
'Get all {{count}} skills installed at once. Your agent will walk you through each one.',
'skills.collection.installTitle': 'Install the full collection',
'skills.collection.moreCollections': 'More collections',
'skills.collection.skillCount': '{{count}} skills',
'skills.collection.skillsInCollection': 'Skills in this collection',
'skills.sorts.createdAt': 'Recently Published',
'skills.sorts.installCount': 'Downloads',
@@ -1,138 +0,0 @@
'use client';
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { Tag } from 'antd';
import { createStaticStyles, responsive } from 'antd-style';
import { ArrowLeftIcon } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import PublishedTime from '@/components/PublishedTime';
const styles = createStaticStyles(({ css }) => ({
author: css`
font-size: 13px;
color: rgb(255 255 255 / 70%);
`,
backButton: css`
position: absolute;
inset-block-start: 16px;
inset-inline-start: 16px;
color: rgb(255 255 255 / 80%);
&:hover {
color: rgb(255 255 255);
background: rgb(255 255 255 / 15%);
}
`,
container: css`
position: relative;
overflow: hidden;
width: 100%;
min-height: 280px;
padding-block: 80px 40px;
padding-inline: 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
${responsive.sm} {
min-height: 240px;
padding-block: 60px 24px;
padding-inline: 20px;
}
`,
cover: css`
position: absolute;
inset: 0;
opacity: 0.4;
background-position: center;
background-size: cover;
`,
description: css`
max-width: 500px;
margin: 0;
font-size: 15px;
line-height: 1.6;
color: rgb(255 255 255 / 85%);
${responsive.sm} {
font-size: 14px;
}
`,
tag: css`
border: none;
font-size: 10px;
font-weight: 700;
color: rgb(255 255 255 / 90%);
text-transform: uppercase;
letter-spacing: 1px;
background: rgb(255 255 255 / 20%);
`,
title: css`
margin: 0;
font-size: 36px;
font-weight: 700;
line-height: 1.2;
color: rgb(255 255 255);
${responsive.sm} {
font-size: 28px;
}
`,
}));
interface HeroProps {
cover?: string;
createdAt: string;
description?: string;
itemCount: number;
mobile?: boolean;
summary: string;
title: string;
updatedAt: string;
}
const Hero = memo<HeroProps>(
({ title, summary, description, itemCount, cover, createdAt, updatedAt, mobile }) => {
const { t } = useTranslation('discover');
const navigate = useNavigate();
const handleBack = useCallback(() => {
navigate('/community/skill');
}, [navigate]);
// Use description if available, otherwise fall back to summary
const displayDescription = description || summary;
return (
<div className={styles.container}>
{cover && <div className={styles.cover} style={{ backgroundImage: `url(${cover})` }} />}
<ActionIcon className={styles.backButton} icon={ArrowLeftIcon} onClick={handleBack} />
<Flexbox gap={16} style={{ maxWidth: 600, position: 'relative', zIndex: 1 }}>
<Tag className={styles.tag}>{t('skills.collection.editorCollection')}</Tag>
<h1 className={styles.title}>{title}</h1>
<p className={styles.description}>{displayDescription}</p>
<Flexbox horizontal align={'center'} className={styles.author} gap={8}>
<span>{t('skills.collection.skillCount', { count: itemCount })}</span>
{(updatedAt || createdAt) && (
<>
<span>·</span>
<PublishedTime date={updatedAt || createdAt} template={'MMM DD, YYYY'} />
</>
)}
</Flexbox>
</Flexbox>
</div>
);
},
);
export default Hero;
@@ -1,103 +0,0 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { Button } from 'antd';
import { createStaticStyles, cx } from 'antd-style';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { INBOX_SESSION_ID } from '@/const/session';
import { useIsDark } from '@/hooks/useIsDark';
import { useAgentStore } from '@/store/agent';
import { builtinAgentSelectors } from '@/store/agent/selectors';
import type { DiscoverSkillItem } from '@/types/discover';
const styles = createStaticStyles(({ css }) => ({
button: css`
padding-inline: 24px;
border-radius: 24px;
`,
container: css`
width: 100%;
max-width: 720px;
margin-block: 0;
margin-inline: auto;
padding: 32px;
border-radius: 16px;
`,
container_dark: css`
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
`,
container_light: css`
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
`,
description: css`
margin: 0;
font-size: 14px;
line-height: 1.5;
color: rgb(255 255 255 / 65%);
`,
title: css`
margin: 0;
font-size: 18px;
font-weight: 600;
color: rgb(255 255 255 / 95%);
`,
}));
interface InstallCTAProps {
collectionTitle: string;
skills: DiscoverSkillItem[];
}
const InstallCTA = memo<InstallCTAProps>(({ collectionTitle, skills }) => {
const { t } = useTranslation('discover');
const isDark = useIsDark();
const navigate = useNavigate();
const inboxAgentId = useAgentStore(builtinAgentSelectors.inboxAgentId);
const handleInstallAll = useCallback(() => {
if (!skills || skills.length === 0) return;
// Build the skill identifiers list
const skillIdentifiers = skills.map((s) => `- ${s.identifier}`).join('\n');
// Build the installation message
const message = `Read https://lobehub.com/skills/skill.md and follow the instructions to setup LobeHub Skills Marketplace.
Then install every skill in the "${collectionTitle}" collection:
${skillIdentifiers}
After installation, read each installed SKILL.md and complete any required setup.`;
// Navigate to inbox with the message
const agentId = inboxAgentId || INBOX_SESSION_ID;
const encodedMessage = encodeURIComponent(message);
navigate(`/agent/${agentId}?message=${encodedMessage}`);
}, [skills, collectionTitle, inboxAgentId, navigate]);
const skillCount = skills?.length || 0;
return (
<Flexbox
align={'center'}
className={cx(styles.container, isDark ? styles.container_dark : styles.container_light)}
gap={16}
>
<h3 className={styles.title}>{t('skills.collection.installTitle')}</h3>
<p className={styles.description}>
{t('skills.collection.installDesc', { count: skillCount })}
</p>
<Button
className={styles.button}
size="large"
style={{ background: '#fff', color: '#000' }}
onClick={handleInstallAll}
>
{t('skills.collection.installAll')}
</Button>
</Flexbox>
);
});
export default InstallCTA;
@@ -1,126 +0,0 @@
'use client';
import { Block, Flexbox, Grid, Text } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { useIsDark } from '@/hooks/useIsDark';
import Title from '@/routes/(main)/community/components/Title';
import { useDiscoverStore } from '@/store/discover';
import { type SkillCollectionItem } from '@/types/discover';
const styles = createStaticStyles(({ css }) => ({
card: css`
position: relative;
overflow: hidden;
height: 160px;
padding: 20px;
border-radius: 12px;
`,
card_dark: css`
border: 1px solid rgb(255 255 255 / 8%);
background: rgb(255 255 255 / 4%);
`,
card_light: css`
border: 1px solid rgb(0 0 0 / 6%);
background: rgb(255 255 255);
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
`,
cover: css`
position: absolute;
inset: 0;
opacity: 0.15;
background-position: center;
background-size: cover;
`,
desc: css`
margin: 0;
font-size: 13px;
line-height: 1.5;
`,
desc_dark: css`
color: rgb(255 255 255 / 50%);
`,
desc_light: css`
color: rgb(0 0 0 / 50%);
`,
title: css`
margin: 0;
font-size: 16px;
font-weight: 600;
line-height: 1.4;
`,
title_dark: css`
color: rgb(255 255 255 / 88%);
`,
title_light: css`
color: rgb(0 0 0 / 88%);
`,
}));
interface CollectionCardProps extends SkillCollectionItem {}
const CollectionCard = memo<CollectionCardProps>(({ title, summary, slug, cover }) => {
const isDark = useIsDark();
const navigate = useNavigate();
const handleClick = useCallback(() => {
navigate(urlJoin('/community/collection', slug));
}, [navigate, slug]);
return (
<Block
clickable
className={cx(styles.card, isDark ? styles.card_dark : styles.card_light)}
style={{ cursor: 'pointer' }}
onClick={handleClick}
>
{cover && <div className={styles.cover} style={{ backgroundImage: `url(${cover})` }} />}
<Flexbox gap={8} style={{ position: 'relative', zIndex: 1 }}>
<Text className={cx(styles.title, isDark ? styles.title_dark : styles.title_light)}>
{title}
</Text>
<Text
className={cx(styles.desc, isDark ? styles.desc_dark : styles.desc_light)}
ellipsis={{ rows: 3 }}
>
{summary}
</Text>
</Flexbox>
</Block>
);
});
interface MoreCollectionsProps {
currentSlug: string;
}
const MoreCollections = memo<MoreCollectionsProps>(({ currentSlug }) => {
const { t } = useTranslation('discover');
const useFetchSkillCollections = useDiscoverStore((s) => s.useFetchSkillCollections);
const { data, isLoading } = useFetchSkillCollections();
if (isLoading || !data || data.length === 0) return null;
const otherCollections = data.filter((c) => c.slug !== currentSlug);
if (otherCollections.length === 0) return null;
return (
<Flexbox gap={16} style={{ maxWidth: 720, margin: '0 auto', width: '100%' }}>
<Title>{t('skills.collection.moreCollections')}</Title>
<Grid maxItemWidth={340} rows={2} width={'100%'}>
{otherCollections.slice(0, 4).map((item) => (
<CollectionCard key={item.slug} {...item} />
))}
</Grid>
</Flexbox>
);
});
export default MoreCollections;
@@ -1,150 +0,0 @@
'use client';
import { Avatar, Flexbox, Icon, Text } from '@lobehub/ui';
import { Button, Divider } from 'antd';
import { createStaticStyles, cssVar } from 'antd-style';
import { StarIcon } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import type { DiscoverSkillItem } from '@/types/discover';
const styles = createStaticStyles(({ css, cssVar }) => ({
author: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
container: css`
width: 100%;
max-width: 720px;
margin-block: 0;
margin-inline: auto;
`,
count: css`
font-size: 13px;
color: ${cssVar.colorTextDescription};
`,
description: css`
margin: 0;
font-size: 13px;
line-height: 1.5;
color: ${cssVar.colorTextSecondary};
`,
getButton: css`
padding-inline: 16px;
border-radius: 20px;
`,
item: css`
padding-block: 16px;
border-radius: 8px;
&:hover {
background: ${cssVar.colorFillQuaternary};
}
`,
name: css`
font-size: 15px;
font-weight: 600;
color: ${cssVar.colorText};
`,
rating: css`
font-size: 13px;
font-weight: 500;
color: ${cssVar.colorWarning};
`,
sectionTitle: css`
margin: 0;
font-size: 18px;
font-weight: 600;
color: ${cssVar.colorText};
`,
}));
interface SkillItemProps extends DiscoverSkillItem {}
const SkillItem = memo<SkillItemProps>(
({ name, icon, identifier, author, description, ratingAvg }) => {
const { t } = useTranslation('discover');
const navigate = useNavigate();
const handleClick = useCallback(() => {
navigate(urlJoin('/community/skill', identifier));
}, [navigate, identifier]);
const handleGet = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
navigate(urlJoin('/community/skill', identifier));
},
[navigate, identifier],
);
// Use ratingAvg, fallback to a default display if not available
const rating = ratingAvg || 0;
return (
<Flexbox
horizontal
align={'center'}
className={styles.item}
gap={16}
paddingInline={8}
style={{ cursor: 'pointer' }}
onClick={handleClick}
>
<Avatar avatar={icon || name} size={56} style={{ flexShrink: 0 }} />
<Flexbox flex={1} gap={4} style={{ minWidth: 0 }}>
<Text ellipsis className={styles.name}>
{name}
</Text>
<Text className={styles.author}>{author}</Text>
<Text ellipsis className={styles.description}>
{description}
</Text>
</Flexbox>
<Flexbox align={'center'} gap={8} style={{ flexShrink: 0 }}>
{rating > 0 && (
<Flexbox horizontal align={'center'} className={styles.rating} gap={4}>
<Icon fill={cssVar.colorWarning} fillOpacity={1} icon={StarIcon} size={14} />
{rating.toFixed(1)}
</Flexbox>
)}
<Button className={styles.getButton} shape="round" onClick={handleGet}>
{t('skills.collection.get')}
</Button>
</Flexbox>
</Flexbox>
);
},
);
interface SkillsListProps {
skills: DiscoverSkillItem[];
}
const SkillsList = memo<SkillsListProps>(({ skills }) => {
const { t } = useTranslation('discover');
if (!skills || skills.length === 0) return null;
return (
<Flexbox className={styles.container} gap={16}>
<Divider style={{ margin: 0 }} />
<Flexbox horizontal align={'center'} justify={'space-between'}>
<h2 className={styles.sectionTitle}>{t('skills.collection.skillsInCollection')}</h2>
<span className={styles.count}>
{t('skills.collection.skillCount', { count: skills.length })}
</span>
</Flexbox>
<Flexbox>
{skills.map((skill) => (
<SkillItem key={skill.identifier} {...skill} />
))}
</Flexbox>
</Flexbox>
);
});
export default SkillsList;
@@ -1,53 +0,0 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { useParams } from 'react-router-dom';
import { useDiscoverStore } from '@/store/discover';
import NotFound from '../components/NotFound';
import Hero from './features/Hero';
import InstallCTA from './features/InstallCTA';
import MoreCollections from './features/MoreCollections';
import SkillsList from './features/SkillsList';
import Loading from './loading';
interface CollectionDetailPageProps {
mobile?: boolean;
}
const CollectionDetailPage = memo<CollectionDetailPageProps>(({ mobile }) => {
const params = useParams<{ slug: string }>();
const slug = params.slug ?? '';
const useFetchSkillCollectionDetail = useDiscoverStore((s) => s.useFetchSkillCollectionDetail);
const { data, isLoading } = useFetchSkillCollectionDetail({ slug });
if (isLoading) return <Loading />;
if (!data) return <NotFound />;
return (
<Flexbox data-testid="collection-detail-content" gap={40} width={'100%'}>
<Hero
cover={data.cover}
createdAt={data.createdAt}
description={data.description}
itemCount={data.itemCount}
mobile={mobile}
summary={data.summary}
title={data.title}
updatedAt={data.updatedAt}
/>
<SkillsList skills={data.items} />
{data.items.length > 0 && <InstallCTA collectionTitle={data.title} skills={data.items} />}
<MoreCollections currentSlug={slug} />
</Flexbox>
);
});
export const MobileCollectionPage = memo<{ mobile?: boolean }>(() => {
return <CollectionDetailPage mobile={true} />;
});
export default CollectionDetailPage;
@@ -1 +0,0 @@
export { DetailsLoading as default } from '../../components/ListLoading';
@@ -1,10 +1,19 @@
import { Flexbox } from '@lobehub/ui';
import { Outlet } from 'react-router-dom';
import CategoryContainer from '../../../components/CategoryContainer';
import Category from '../features/Category';
import { styles } from './style';
const Layout = () => {
return (
<Flexbox gap={24} width={'100%'}>
<Outlet />
<Flexbox horizontal className={styles.mainContainer} gap={24} width={'100%'}>
<CategoryContainer>
<Category />
</CategoryContainer>
<Flexbox flex={1} gap={16}>
<Outlet />
</Flexbox>
</Flexbox>
);
};
@@ -1,122 +0,0 @@
'use client';
import { Block, Flexbox, Grid, Text } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { useIsDark } from '@/hooks/useIsDark';
import Title from '@/routes/(main)/community/components/Title';
import { useDiscoverStore } from '@/store/discover';
import { type SkillCollectionItem } from '@/types/discover';
const styles = createStaticStyles(({ css }) => ({
card: css`
position: relative;
overflow: hidden;
height: 180px;
padding: 20px;
border-radius: 12px;
`,
card_dark: css`
border: 1px solid rgb(255 255 255 / 8%);
background: rgb(255 255 255 / 4%);
`,
card_light: css`
border: 1px solid rgb(0 0 0 / 6%);
background: rgb(255 255 255);
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
`,
cover: css`
position: absolute;
inset: 0;
opacity: 0.15;
background-position: center;
background-size: cover;
`,
desc: css`
margin: 0;
font-size: 13px;
line-height: 1.5;
`,
desc_dark: css`
color: rgb(255 255 255 / 50%);
`,
desc_light: css`
color: rgb(0 0 0 / 50%);
`,
title: css`
margin: 0;
font-size: 16px;
font-weight: 600;
line-height: 1.4;
`,
title_dark: css`
color: rgb(255 255 255 / 88%);
`,
title_light: css`
color: rgb(0 0 0 / 88%);
`,
}));
interface CollectionCardProps extends SkillCollectionItem {}
const CollectionCard = memo<CollectionCardProps>(({ title, summary, slug, cover }) => {
const isDark = useIsDark();
const navigate = useNavigate();
const handleClick = useCallback(() => {
navigate(urlJoin('/community/collection', slug));
}, [navigate, slug]);
return (
<Block
clickable
className={cx(styles.card, isDark ? styles.card_dark : styles.card_light)}
style={{ cursor: 'pointer' }}
onClick={handleClick}
>
{cover && <div className={styles.cover} style={{ backgroundImage: `url(${cover})` }} />}
<Flexbox gap={8} style={{ position: 'relative', zIndex: 1 }}>
<Text className={cx(styles.title, isDark ? styles.title_dark : styles.title_light)}>
{title}
</Text>
<Text
className={cx(styles.desc, isDark ? styles.desc_dark : styles.desc_light)}
ellipsis={{ rows: 3 }}
>
{summary}
</Text>
</Flexbox>
</Block>
);
});
const CollectionsSection = memo(() => {
const { t } = useTranslation('discover');
const useFetchSkillCollections = useDiscoverStore((s) => s.useFetchSkillCollections);
const { data, isLoading } = useFetchSkillCollections();
// Skip the first collection since it's shown in EditorsPick
const collections = data?.slice(1) || [];
if (isLoading || collections.length === 0) return null;
return (
<Flexbox gap={16}>
<Title>{t('skills.sections.collections')}</Title>
<Grid maxItemWidth={400} rows={2} width={'100%'}>
{collections.slice(0, 4).map((item) => (
<CollectionCard key={item.slug} {...item} />
))}
</Grid>
</Flexbox>
);
});
export default CollectionsSection;
@@ -1,139 +0,0 @@
'use client';
import { Button, Flexbox } from '@lobehub/ui';
import { Skeleton, Tag } from 'antd';
import { createStaticStyles, cx, responsive } from 'antd-style';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { useIsDark } from '@/hooks/useIsDark';
import { useDiscoverStore } from '@/store/discover';
const styles = createStaticStyles(({ css }) => ({
banner: css`
position: relative;
width: 100%;
padding-block: 32px;
padding-inline: 40px;
border-radius: 16px;
${responsive.sm} {
padding-block: 20px;
padding-inline: 24px;
}
`,
banner_dark: css`
background: linear-gradient(135deg, #2a2a2a 0%, #3a3a3a 50%, #2a2a2a 100%);
`,
banner_light: css`
background: linear-gradient(135deg, #e8e8e8 0%, #f5f5f5 50%, #e0e0e0 100%);
`,
description: css`
margin: 0;
font-size: 14px;
font-weight: 400;
line-height: 1.6;
${responsive.sm} {
font-size: 13px;
}
`,
description_dark: css`
color: rgb(255 255 255 / 65%);
`,
description_light: css`
color: rgb(0 0 0 / 65%);
`,
tag: css`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
`,
title: css`
margin: 0;
font-size: 28px;
font-weight: 700;
line-height: 1.3;
${responsive.sm} {
font-size: 22px;
}
`,
title_dark: css`
color: rgb(255 255 255 / 88%);
`,
title_light: css`
color: rgb(0 0 0 / 88%);
`,
}));
const EditorsPick = memo(() => {
const { t } = useTranslation('discover');
const isDark = useIsDark();
const navigate = useNavigate();
const useFetchSkillCollections = useDiscoverStore((s) => s.useFetchSkillCollections);
const { data, isLoading } = useFetchSkillCollections();
const collection = data?.[0];
const handleLearnMore = useCallback(() => {
if (collection?.slug) {
navigate(urlJoin('/community/collection', collection.slug));
}
}, [navigate, collection?.slug]);
if (isLoading) {
return (
<Flexbox
className={cx(styles.banner, isDark ? styles.banner_dark : styles.banner_light)}
width={'100%'}
>
<Flexbox gap={12} style={{ maxWidth: 500 }}>
<Skeleton.Button active size="small" style={{ width: 80 }} />
<Skeleton.Input active size="large" style={{ width: 300 }} />
<Skeleton active paragraph={{ rows: 2 }} title={false} />
</Flexbox>
</Flexbox>
);
}
if (!collection) return null;
return (
<Flexbox
className={cx(styles.banner, isDark ? styles.banner_dark : styles.banner_light)}
width={'100%'}
>
<Flexbox gap={12} style={{ maxWidth: 600, position: 'relative', zIndex: 1 }}>
<Tag className={styles.tag} color={isDark ? 'default' : 'default'}>
{t('skills.sections.editorsPick')}
</Tag>
<h2 className={cx(styles.title, isDark ? styles.title_dark : styles.title_light)}>
{collection.title}
</h2>
<p
className={cx(
styles.description,
isDark ? styles.description_dark : styles.description_light,
)}
>
{collection.summary}
</p>
<Flexbox horizontal gap={12} style={{ marginBlockStart: 8 }}>
<Button
style={{ background: isDark ? '#333' : '#333', color: '#fff' }}
onClick={handleLearnMore}
>
{t('skills.sections.learnMore')}
</Button>
</Flexbox>
</Flexbox>
</Flexbox>
);
});
export default EditorsPick;
@@ -1,113 +0,0 @@
'use client';
import { Avatar, Block, Flexbox, Icon, Tag, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { StarIcon } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { discoverService } from '@/services/discover';
import { type DiscoverSkillItem } from '@/types/discover';
const styles = createStaticStyles(({ css }) => ({
author: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
category: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
desc: css`
flex: 1;
margin: 0 !important;
font-size: 13px;
color: ${cssVar.colorTextSecondary};
`,
name: css`
margin: 0 !important;
font-size: 15px !important;
font-weight: 600 !important;
`,
rating: css`
font-size: 13px;
font-weight: 500;
color: ${cssVar.colorWarning};
`,
}));
const FeaturedCard = memo<DiscoverSkillItem>(
({ name, icon, author, description, identifier, category, isFeatured, ratingAvg }) => {
const { t } = useTranslation('discover');
const navigate = useNavigate();
const link = urlJoin('/community/skill', identifier);
const handleClick = useCallback(() => {
discoverService
.reportSkillEvent({
event: 'click',
identifier,
source: location.pathname,
})
.catch(() => {});
navigate(link);
}, [identifier, link, navigate]);
return (
<Block
clickable
height={'100%'}
variant={'outlined'}
width={'100%'}
style={{
overflow: 'hidden',
position: 'relative',
}}
onClick={handleClick}
>
<Flexbox gap={12} height={'100%'} padding={16}>
<Flexbox horizontal align={'center'} gap={12}>
<Avatar avatar={icon || name} shape={'square'} size={40} style={{ flex: 'none' }} />
<Flexbox flex={1} gap={2} style={{ overflow: 'hidden' }}>
<Flexbox horizontal align={'center'} gap={8}>
<Text ellipsis className={styles.name}>
{name}
</Text>
{isFeatured && (
<Tag color={'orange'} size={'small'}>
{t('isFeatured')}
</Tag>
)}
</Flexbox>
<Text ellipsis className={styles.author}>
{author}
</Text>
</Flexbox>
</Flexbox>
<Text
className={styles.desc}
ellipsis={{
rows: 2,
}}
>
{description}
</Text>
<Flexbox horizontal align={'center'} justify={'space-between'}>
<Tag size={'small'}>{category && t(`skills.categories.${category}.name` as any)}</Tag>
{Boolean(ratingAvg) && (
<Flexbox horizontal align={'center'} className={styles.rating} gap={4}>
<Icon icon={StarIcon} size={14} />
{ratingAvg?.toFixed(1)}
</Flexbox>
)}
</Flexbox>
</Flexbox>
</Block>
);
},
);
export default FeaturedCard;
@@ -1,54 +0,0 @@
'use client';
import { Flexbox, Grid } from '@lobehub/ui';
import { Skeleton } from 'antd';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Title from '@/routes/(main)/community/components/Title';
import { useDiscoverStore } from '@/store/discover';
import { SkillSorts } from '@/types/discover';
import FeaturedCard from './FeaturedCard';
const FeaturedSection = memo(() => {
const { t } = useTranslation('discover');
const useFetchSkillList = useDiscoverStore((s) => s.useFetchSkillList);
const { data, isLoading } = useFetchSkillList({
order: 'desc',
page: 1,
pageSize: 6,
sort: SkillSorts.InstallCount,
});
if (isLoading) {
return (
<Flexbox gap={16}>
<Title>{t('skills.sections.featured')}</Title>
<Grid rows={3} width={'100%'}>
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton.Button active block key={index} style={{ height: 160 }} />
))}
</Grid>
</Flexbox>
);
}
if (!data?.items?.length) return null;
return (
<Flexbox gap={16}>
<Title more={t('skills.sections.seeAll')} moreLink={'/community/skill?sort=createdAt'}>
{t('skills.sections.featured')}
</Title>
<Grid rows={3} width={'100%'}>
{data.items.slice(0, 6).map((item) => (
<FeaturedCard key={item.identifier} {...item} />
))}
</Grid>
</Flexbox>
);
});
export default FeaturedSection;
@@ -1,85 +0,0 @@
'use client';
import { Avatar, Block, Flexbox, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { discoverService } from '@/services/discover';
import { type DiscoverSkillItem } from '@/types/discover';
const styles = createStaticStyles(({ css }) => ({
category: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
count: css`
font-size: 14px;
font-weight: 500;
color: ${cssVar.colorTextSecondary};
`,
name: css`
margin: 0 !important;
font-size: 15px !important;
font-weight: 600 !important;
`,
rank: css`
font-size: 24px;
font-weight: 700;
color: ${cssVar.colorTextDescription};
`,
}));
interface TrendingCardProps extends DiscoverSkillItem {
rank: number;
}
const TrendingCard = memo<TrendingCardProps>(
({ rank, name, icon, identifier, category, installCount }) => {
const { t } = useTranslation('discover');
const navigate = useNavigate();
const link = urlJoin('/community/skill', identifier);
const handleClick = useCallback(() => {
discoverService
.reportSkillEvent({
event: 'click',
identifier,
source: location.pathname,
})
.catch(() => {});
navigate(link);
}, [identifier, link, navigate]);
return (
<Block
clickable
variant={'outlined'}
style={{
overflow: 'hidden',
position: 'relative',
}}
onClick={handleClick}
>
<Flexbox horizontal align={'center'} gap={16} padding={16}>
<Text className={styles.rank}>{rank}</Text>
<Avatar avatar={icon || name} shape={'square'} size={40} style={{ flex: 'none' }} />
<Flexbox flex={1} gap={2} style={{ overflow: 'hidden' }}>
<Text ellipsis className={styles.name}>
{name}
</Text>
<Text className={styles.category}>
{category && t(`skills.categories.${category}.name` as any)}
</Text>
</Flexbox>
<Text className={styles.count}>{installCount?.toLocaleString()}</Text>
</Flexbox>
</Block>
);
},
);
export default TrendingCard;
@@ -1,54 +0,0 @@
'use client';
import { Flexbox, Grid } from '@lobehub/ui';
import { Skeleton } from 'antd';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Title from '@/routes/(main)/community/components/Title';
import { useDiscoverStore } from '@/store/discover';
import { SkillSorts } from '@/types/discover';
import TrendingCard from './TrendingCard';
const TrendingSection = memo(() => {
const { t } = useTranslation('discover');
const useFetchSkillList = useDiscoverStore((s) => s.useFetchSkillList);
const { data, isLoading } = useFetchSkillList({
order: 'desc',
page: 1,
pageSize: 4,
sort: SkillSorts.InstallCount,
});
if (isLoading) {
return (
<Flexbox gap={16}>
<Title>{t('skills.sections.trending')}</Title>
<Grid maxItemWidth={300} rows={2} width={'100%'}>
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton.Button active block key={index} style={{ height: 72 }} />
))}
</Grid>
</Flexbox>
);
}
if (!data?.items?.length) return null;
return (
<Flexbox gap={16}>
<Title more={t('skills.sections.seeAll')} moreLink={'/community/skill?sort=installCount'}>
{t('skills.sections.trending')}
</Title>
<Grid maxItemWidth={300} rows={2} width={'100%'}>
{data.items.slice(0, 4).map((item, index) => (
<TrendingCard key={item.identifier} rank={index + 1} {...item} />
))}
</Grid>
</Flexbox>
);
});
export default TrendingSection;
@@ -1,22 +0,0 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import CollectionsSection from './CollectionsSection';
import EditorsPick from './EditorsPick';
import FeaturedSection from './FeaturedSection';
import TrendingSection from './TrendingSection';
const DiscoverView = memo(() => {
return (
<Flexbox gap={40} width={'100%'}>
<EditorsPick />
<TrendingSection />
<FeaturedSection />
<CollectionsSection />
</Flexbox>
);
});
export default DiscoverView;
@@ -1,51 +0,0 @@
'use client';
import { Flexbox, Grid } from '@lobehub/ui';
import { memo } from 'react';
import { useQuery } from '@/hooks/useQuery';
import { useDiscoverStore } from '@/store/discover';
import { DiscoverTab, type SkillQueryParams, SkillSorts } from '@/types/discover';
import SkillEmpty from '../../../../features/SkillEmpty';
import Pagination from '../../../features/Pagination';
import Loading from '../../loading';
import FeaturedCard from '../DiscoverView/FeaturedSection/FeaturedCard';
const FilteredListView = memo(() => {
const { q, page, category, sort, order } = useQuery() as SkillQueryParams;
const useFetchSkillList = useDiscoverStore((s) => s.useFetchSkillList);
const { data, isLoading } = useFetchSkillList({
category,
order,
page,
pageSize: 21,
q,
sort: sort ?? SkillSorts.InstallCount,
});
if (isLoading || !data) return <Loading />;
const { items, currentPage, pageSize, totalCount } = data;
if (items.length === 0) return <SkillEmpty />;
return (
<Flexbox gap={32} width={'100%'}>
<Grid rows={3} width={'100%'}>
{items.map((item) => (
<FeaturedCard key={item.identifier} {...item} />
))}
</Grid>
<Pagination
currentPage={currentPage}
pageSize={pageSize}
tab={DiscoverTab.Skills}
total={totalCount}
/>
</Flexbox>
);
});
export default FilteredListView;
@@ -1,70 +0,0 @@
import { SkillCategory, SkillSorts } from '@/types/discover';
export enum SkillTabKey {
AILLMs = 'ai-llms',
CodingAgent = 'coding-agents-ides',
DevOpsCloud = 'devops-cloud',
Discover = 'discover',
New = 'new',
Productivity = 'productivity-tasks',
Trending = 'trending',
WebFrontend = 'web-frontend-development',
}
export interface SkillTabConfig {
category?: SkillCategory;
isCategory?: boolean;
key: SkillTabKey;
labelKey: string;
sort?: SkillSorts;
}
export const SKILL_TABS: SkillTabConfig[] = [
{
isCategory: false,
key: SkillTabKey.Discover,
labelKey: 'skills.tabs.discover',
},
{
isCategory: false,
key: SkillTabKey.Trending,
labelKey: 'skills.tabs.trending',
sort: SkillSorts.InstallCount,
},
{
isCategory: false,
key: SkillTabKey.New,
labelKey: 'skills.tabs.new',
sort: SkillSorts.CreatedAt,
},
{
category: SkillCategory.CodingAgentsIDEs,
isCategory: true,
key: SkillTabKey.CodingAgent,
labelKey: 'skills.categories.coding-agents-ides.name',
},
{
category: SkillCategory.WebFrontendDevelopment,
isCategory: true,
key: SkillTabKey.WebFrontend,
labelKey: 'skills.categories.web-frontend-development.name',
},
{
category: SkillCategory.DevOpsCloud,
isCategory: true,
key: SkillTabKey.DevOpsCloud,
labelKey: 'skills.categories.devops-cloud.name',
},
{
category: SkillCategory.AILLMs,
isCategory: true,
key: SkillTabKey.AILLMs,
labelKey: 'skills.categories.ai-llms.name',
},
{
category: SkillCategory.ProductivityTasks,
isCategory: true,
key: SkillTabKey.Productivity,
labelKey: 'skills.categories.productivity-tasks.name',
},
];
@@ -1,90 +0,0 @@
'use client';
import { Tabs } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@/hooks/useQuery';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { type SkillQueryParams, SkillSorts } from '@/types/discover';
import { SKILL_TABS, SkillTabKey } from './const';
const styles = createStaticStyles(({ css, cssVar }) => ({
tabs: css`
.ant-tabs-nav {
margin-block-end: 0;
&::before {
border-block-end: none;
}
}
.ant-tabs-tab {
padding-block: 8px;
padding-inline: 16px;
border-radius: 8px;
&:hover {
background: ${cssVar.colorFillTertiary};
}
}
.ant-tabs-tab-active {
border: 1px solid ${cssVar.colorBorder};
background: ${cssVar.colorFillSecondary};
.ant-tabs-tab-btn {
color: ${cssVar.colorText};
}
}
.ant-tabs-ink-bar {
display: none;
}
`,
}));
const TabNavigation = memo(() => {
const { t } = useTranslation('discover');
const router = useQueryRoute();
const { category, sort } = useQuery() as SkillQueryParams;
const activeTab = useMemo(() => {
if (!category && !sort) return SkillTabKey.Discover;
if (sort === SkillSorts.InstallCount && !category) return SkillTabKey.Trending;
if (sort === SkillSorts.CreatedAt && !category) return SkillTabKey.New;
const categoryTab = SKILL_TABS.find((tab) => tab.category === category);
return categoryTab?.key ?? SkillTabKey.Discover;
}, [category, sort]);
const handleTabChange = (key: string) => {
const tab = SKILL_TABS.find((item) => item.key === key);
if (!tab) return;
if (tab.key === SkillTabKey.Discover) {
router.push('/community/skill', { query: {}, replace: true });
} else if (tab.isCategory && tab.category) {
router.push('/community/skill', { query: { category: tab.category }, replace: true });
} else if (tab.sort) {
router.push('/community/skill', { query: { sort: tab.sort }, replace: true });
}
};
const items = useMemo(
() =>
SKILL_TABS.map((tab) => ({
key: tab.key,
label: t(tab.labelKey as any),
})),
[t],
);
return (
<Tabs activeKey={activeTab} className={styles.tabs} items={items} onChange={handleTabChange} />
);
});
export default TabNavigation;
@@ -1,26 +1,42 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo, useMemo } from 'react';
import { memo } from 'react';
import { useQuery } from '@/hooks/useQuery';
import { useDiscoverStore } from '@/store/discover';
import { type SkillQueryParams } from '@/types/discover';
import { DiscoverTab, SkillSorts } from '@/types/discover';
import DiscoverView from './features/DiscoverView';
import FilteredListView from './features/FilteredListView';
import TabNavigation from './features/TabNavigation';
import Pagination from '../features/Pagination';
import List from './features/List';
import Loading from './loading';
const SkillPage = memo(() => {
const { category, sort, q } = useQuery() as SkillQueryParams;
const { q, page, category, sort, order } = useQuery() as SkillQueryParams;
const useSkillList = useDiscoverStore((s) => s.useFetchSkillList);
const { data, isLoading } = useSkillList({
category,
order,
page,
pageSize: 21,
q,
sort: sort ?? SkillSorts.InstallCount,
});
const isDiscoverView = useMemo(() => {
return !category && !sort && !q;
}, [category, sort, q]);
if (isLoading || !data) return <Loading />;
const { items, currentPage, pageSize, totalCount } = data;
return (
<Flexbox gap={24} width={'100%'}>
<TabNavigation />
{isDiscoverView ? <DiscoverView /> : <FilteredListView />}
<Flexbox gap={32} width={'100%'}>
<List data={items} />
<Pagination
currentPage={currentPage}
pageSize={pageSize}
tab={DiscoverTab.Skills}
total={totalCount}
/>
</Flexbox>
);
});
@@ -29,6 +29,19 @@ describe('OnBoardingContainer', () => {
expect(screen.getByText('Lang Button')).toBeInTheDocument();
expect(screen.getByText('Theme Button')).toBeInTheDocument();
expect(screen.getByText('Onboarding Content')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'agent.skipOnboarding' })).not.toBeInTheDocument();
expect(screen.queryByText('© 2026 LobeHub. All rights reserved.')).not.toBeInTheDocument();
});
it('shows skip onboarding on agent onboarding route', () => {
render(
<MemoryRouter initialEntries={['/onboarding/agent']}>
<OnBoardingContainer>
<div>Onboarding Content</div>
</OnBoardingContainer>
</MemoryRouter>,
);
expect(screen.getByRole('button', { name: 'agent.skipOnboarding' })).toBeInTheDocument();
});
});
+8 -4
View File
@@ -5,7 +5,7 @@ import { Divider } from 'antd';
import { cx, useTheme } from 'antd-style';
import { type FC, type PropsWithChildren, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { ProductLogo } from '@/components/Branding';
import LangButton from '@/features/User/UserPanel/LangButton';
@@ -19,8 +19,10 @@ const OnBoardingContainer: FC<PropsWithChildren> = ({ children }) => {
const isDarkMode = useIsDark();
const theme = useTheme();
const { t } = useTranslation('onboarding');
const { pathname } = useLocation();
const navigate = useNavigate();
const finishOnboarding = useUserStore((s) => s.finishOnboarding);
const isAgentOnboarding = pathname.startsWith('/onboarding/agent');
const handleSkip = useCallback(() => {
finishOnboarding();
@@ -49,9 +51,11 @@ const OnBoardingContainer: FC<PropsWithChildren> = ({ children }) => {
<Divider className={styles.divider} orientation={'vertical'} />
<ThemeButton placement={'bottomRight'} size={18} />
</Flexbox>
<Button size={'small'} type={'text'} onClick={handleSkip}>
{t('agent.skipOnboarding')}
</Button>
{isAgentOnboarding ? (
<Button size={'small'} type={'text'} onClick={handleSkip}>
{t('agent.skipOnboarding')}
</Button>
) : null}
</Flexbox>
</Flexbox>
<Center height={'100%'} width={'100%'}>
+2 -5
View File
@@ -1,5 +1,4 @@
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { isDev } from '@lobechat/utils';
import { AGENT_ONBOARDING_ENABLED } from '@lobechat/business-const';
import {
ChartNetworkIcon,
CodeXmlIcon,
@@ -13,11 +12,9 @@ import {
/** Default target when the user opens `/onboarding`. Flip to `'agent'` when agent onboarding is ready to ship as the primary flow. */
export type DefaultOnboardingEntryVariant = 'agent' | 'classic';
export { AGENT_ONBOARDING_ENABLED };
export const DEFAULT_ONBOARDING_ENTRY_VARIANT: DefaultOnboardingEntryVariant = 'classic';
export const AGENT_ONBOARDING_ENABLED = ENABLE_BUSINESS_FEATURES || isDev;
const resolveDefaultOnboardingPath = (variant: DefaultOnboardingEntryVariant) =>
variant === 'agent' && AGENT_ONBOARDING_ENABLED ? '/onboarding/agent' : '/onboarding/classic';
-47
View File
@@ -25,53 +25,6 @@ const marketProcedure = publicProcedure
});
export const skillRouter = router({
getSkillCollectionDetail: marketProcedure
.input(
z.object({
locale: z.string().optional(),
slug: z.string(),
}),
)
.query(async ({ input, ctx }) => {
log('getSkillCollectionDetail input: %O', input);
try {
return await ctx.marketService.getSkillCollectionDetail(input.slug, {
locale: input.locale,
});
} catch (error) {
log('Error fetching skill collection detail: %O', error);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to fetch skill collection detail',
});
}
}),
getSkillCollections: marketProcedure
.input(
z
.object({
locale: z.string().optional(),
})
.optional(),
)
.query(async ({ input, ctx }) => {
log('getSkillCollections input: %O', input);
try {
return await ctx.marketService.getSkillCollections({
locale: input?.locale,
});
} catch (error) {
log('Error fetching skill collections: %O', error);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to fetch skill collections',
});
}
}),
getSkillCategories: marketProcedure
.input(
z
-55
View File
@@ -457,61 +457,6 @@ export class MarketService {
return this.market.marketSkills.getCategories();
}
/**
* Get skill collections list
*/
async getSkillCollections(options?: { locale?: string }) {
log('getSkillCollections: %O', options);
const url = new URL(`${MARKET_BASE_URL}/api/v1/skills/collections/`);
if (options?.locale) {
url.searchParams.set('locale', options.locale);
}
const response = await fetch(url.toString(), {
// @ts-ignore
headers: this.market.headers,
method: 'GET',
});
if (!response.ok) {
throw new Error(`Failed to fetch skill collections: ${response.status}`);
}
return response.json();
}
/**
* Get skill collection detail by slug
*/
async getSkillCollectionDetail(slug: string, options?: { locale?: string }) {
log('getSkillCollectionDetail: slug=%s, options=%O', slug, options);
const url = new URL(`${MARKET_BASE_URL}/api/v1/skills/collections/${slug}`);
if (options?.locale) {
url.searchParams.set('locale', options.locale);
}
const response = await fetch(url.toString(), {
// @ts-ignore
headers: this.market.headers,
method: 'GET',
});
if (!response.ok) {
throw new Error(`Failed to fetch skill collection detail: ${response.status}`);
}
const data = await response.json();
// Map items to skills for compatibility
if (data.items && !data.skills) {
data.skills = data.items;
}
return data;
}
/**
* Execute a LobeHub Skill tool
* @param params - The skill execution parameters (provider, toolName, args)
-15
View File
@@ -564,21 +564,6 @@ class DiscoverService {
});
};
getSkillCollections = async (): Promise<any> => {
const locale = globalHelpers.getCurrentLanguage();
return lambdaClient.market.skill.getSkillCollections.query({
locale,
});
};
getSkillCollectionDetail = async (params: { slug: string }): Promise<any> => {
const locale = globalHelpers.getCurrentLanguage();
return lambdaClient.market.skill.getSkillCollectionDetail.query({
...params,
locale,
});
};
reportSkillEvent = async (eventData: { event: string; identifier: string; source?: string }) => {
const allow = userGeneralSettingsSelectors.telemetry(useUserStore.getState());
if (!allow) return;
@@ -22,7 +22,6 @@ import AgentProfilePage from '@/routes/(main)/agent/profile';
import CommunityLayout from '@/routes/(main)/community/_layout';
import CommunityDetailLayout from '@/routes/(main)/community/(detail)/_layout';
import CommunityDetailAgentPage from '@/routes/(main)/community/(detail)/agent';
import CommunityDetailCollectionPage from '@/routes/(main)/community/(detail)/collection';
import CommunityDetailGroupAgentPage from '@/routes/(main)/community/(detail)/group_agent';
import CommunityDetailMcpPage from '@/routes/(main)/community/(detail)/mcp';
import CommunityDetailModelPage from '@/routes/(main)/community/(detail)/model';
@@ -218,10 +217,6 @@ export const desktopRoutes: RouteObject[] = [
element: <CommunityDetailSkillPage />,
path: 'skill/:slug',
},
{
element: <CommunityDetailCollectionPage />,
path: 'collection/:slug',
},
{
element: <CommunityDetailMcpPage />,
path: 'mcp/:slug',
-7
View File
@@ -221,13 +221,6 @@ export const desktopRoutes: RouteObject[] = [
),
path: 'skill/:slug',
},
{
element: dynamicElement(
() => import('@/routes/(main)/community/(detail)/collection'),
'Desktop > Discover > Detail > Collection',
),
path: 'collection/:slug',
},
{
element: dynamicElement(
() => import('@/routes/(main)/community/(detail)/mcp'),
-10
View File
@@ -144,16 +144,6 @@ export const mobileRoutes: RouteObject[] = [
),
path: 'mcp/:slug',
},
{
element: dynamicElement(
() =>
import('@/routes/(main)/community/(detail)/collection').then(
(m) => m.MobileCollectionPage,
),
'Mobile > Discover > Detail > Collection',
),
path: 'collection/:slug',
},
{
element: dynamicElement(
() =>
-26
View File
@@ -9,8 +9,6 @@ import { type StoreSetter } from '@/store/types';
import {
type DiscoverSkillDetail,
type SkillCategoryItem,
type SkillCollectionDetail,
type SkillCollectionListResponse,
type SkillListResponse,
type SkillQueryParams,
} from '@/types/discover';
@@ -65,30 +63,6 @@ export class SkillActionImpl {
},
);
};
useFetchSkillCollections = (): SWRResponse<SkillCollectionListResponse> => {
const locale = globalHelpers.getCurrentLanguage();
return useClientDataSWR(
['skill-collections', locale].join('-'),
async () => discoverService.getSkillCollections(),
{
revalidateOnFocus: false,
},
);
};
useFetchSkillCollectionDetail = ({
slug,
}: {
slug?: string;
}): SWRResponse<SkillCollectionDetail> => {
const locale = globalHelpers.getCurrentLanguage();
return useClientDataSWR(
!slug ? null : ['skill-collection-detail', locale, slug].filter(Boolean).join('-'),
async () => discoverService.getSkillCollectionDetail({ slug: slug! }),
);
};
}
export type SkillAction = Pick<SkillActionImpl, keyof SkillActionImpl>;
+2
View File
@@ -113,6 +113,7 @@ export default defineConfig({
inline: [
'vitest-canvas-mock',
/@emoji-mart/,
'emoji-mart',
'@lobehub/ui',
'@lobehub/fluent-emoji',
'@pierre/diffs',
@@ -120,6 +121,7 @@ export default defineConfig({
'lru_map',
'lexical',
/@lexical\//,
/@lobehub\//,
],
},
},