feat: add agent group publish into market & use market group agents in lobehub (#11535)

* feat: add the publihs group button into group profiles

* feat: add agent group detail page

* feat: the /community/group_agent pages inital

* feat: upload the agent group detail get fixed

* feat: add the agent group add it to user way

* feat: add agent group in agents list & item update

* feat: update the market-sdk

* feat: add group active tab as overview default
This commit is contained in:
Shinji-Li
2026-01-16 20:06:18 +08:00
committed by GitHub
parent 5bf204b112
commit 02b9e76bb9
46 changed files with 3348 additions and 25 deletions

View File

@@ -204,7 +204,7 @@
"@lobehub/desktop-ipc-typings": "workspace:*",
"@lobehub/editor": "^3.11.0",
"@lobehub/icons": "^4.0.2",
"@lobehub/market-sdk": "0.28.1",
"@lobehub/market-sdk": "0.29.0",
"@lobehub/tts": "^4.0.2",
"@lobehub/ui": "^4.19.0",
"@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -8,7 +8,7 @@
"@lobechat/python-interpreter": "workspace:*",
"@lobechat/web-crawler": "workspace:*",
"@lobehub/chat-plugin-sdk": "^1.32.4",
"@lobehub/market-sdk": "0.28.1",
"@lobehub/market-sdk": "0.29.0",
"@lobehub/market-types": "^1.12.3",
"model-bank": "workspace:*",
"type-fest": "^4.41.0",

View File

@@ -42,6 +42,8 @@ export enum AssistantNavKey {
export type AgentStatus = 'published' | 'unpublished' | 'archived' | 'deprecated';
export type AgentType = 'agent' | 'agent-group';
export interface DiscoverAssistantItem extends Omit<LobeAgentSettings, 'meta'>, MetaData {
author: string;
category?: AssistantCategory;
@@ -53,6 +55,7 @@ export interface DiscoverAssistantItem extends Omit<LobeAgentSettings, 'meta'>,
pluginCount: number;
status?: AgentStatus;
tokenUsage: number;
type?: AgentType;
userName?: string;
}
@@ -60,6 +63,7 @@ export type AssistantMarketSource = 'legacy' | 'new';
export interface AssistantQueryParams {
category?: string;
includeAgentGroup?: boolean;
locale?: string;
order?: 'asc' | 'desc';
ownerId?: string;

View File

@@ -0,0 +1,196 @@
import type { MetaData } from '../meta';
/**
* Group Agent Member - represents a member agent in a group
*/
export interface GroupAgentMember {
avatar?: string;
category?: string;
config?: Record<string, any>;
description: string;
displayOrder?: number;
enabled?: boolean;
identifier: string;
name: string;
role: 'supervisor' | 'participant';
url: string;
version?: string;
}
/**
* Group Agent Status
*/
export type GroupAgentStatus = 'published' | 'unpublished' | 'archived' | 'deprecated';
/**
* Group Agent Visibility
*/
export type GroupAgentVisibility = 'public' | 'private' | 'internal';
/**
* Group Agent Category
*/
export type GroupAgentCategory =
| 'productivity'
| 'entertainment'
| 'education'
| 'development'
| 'business'
| 'other';
/**
* Group Agent Config - similar to LobeAgentConfig but for groups
*/
export interface GroupAgentConfig {
/**
* Opening message when starting a conversation with the group
*/
openingMessage?: string;
/**
* Opening questions to guide users
*/
openingQuestions?: string[];
/**
* System role/prompt for the group
*/
systemRole?: string;
/**
* Additional configuration
*/
[key: string]: any;
}
/**
* Group Agent Item - basic info for list display
*/
export interface DiscoverGroupAgentItem extends MetaData {
author?: string;
avatar?: string;
backgroundColor?: string;
category?: GroupAgentCategory;
config?: GroupAgentConfig;
createdAt: string;
description?: string;
homepage?: string;
identifier: string;
installCount?: number;
isFeatured?: boolean;
isOfficial?: boolean;
/**
* Number of knowledge bases across all member agents
*/
knowledgeCount?: number;
/**
* Number of member agents in the group
*/
memberCount: number;
/**
* Number of plugins across all member agents
*/
pluginCount?: number;
status?: GroupAgentStatus;
tags?: string[];
title: string;
/**
* Estimated token usage for the group
*/
tokenUsage?: number;
updatedAt: string;
userName?: string;
version?: string;
versionNumber?: number;
visibility?: GroupAgentVisibility;
}
/**
* Group Agent Version
*/
export interface DiscoverGroupAgentVersion {
changelog?: string;
createdAt?: string;
isLatest?: boolean;
isValidated?: boolean;
status?: GroupAgentStatus;
version: string;
versionNumber: number;
}
/**
* Group Agent Detail - complete info for detail page
*/
export interface DiscoverGroupAgentDetail extends DiscoverGroupAgentItem {
/**
* Current version string
*/
currentVersion?: string;
/**
* Current version number
*/
currentVersionNumber?: number;
/**
* Example conversations (if available from config)
*/
examples?: any;
/**
* Member agents in the group
*/
memberAgents: GroupAgentMember[];
/**
* Owner ID
*/
ownerId?: string;
/**
* Related group agents
*/
related?: DiscoverGroupAgentItem[];
/**
* Summary text (extracted from description or config)
*/
summary?: string;
/**
* Version history
*/
versions?: DiscoverGroupAgentVersion[];
}
/**
* Group Agent List Response
*/
export interface GroupAgentListResponse {
currentPage: number;
items: DiscoverGroupAgentItem[];
totalCount: number;
totalPages: number;
}
/**
* Group Agent Query Parameters
*/
export interface GroupAgentQueryParams {
category?: string;
locale?: string;
order?: 'asc' | 'desc';
ownerId?: string;
page?: number;
pageSize?: number;
q?: string;
sort?: 'createdAt' | 'updatedAt' | 'name' | 'recommended';
}
/**
* Group Agent Detail Query Parameters
*/
export interface GroupAgentDetailParams {
identifier: string;
locale?: string;
version?: string;
}
/**
* Group Agent Category Item
*/
export interface GroupAgentCategoryItem {
category: GroupAgentCategory;
count: number;
name: string;
}

View File

@@ -1,6 +1,8 @@
import { DiscoverAssistantItem } from './assistants';
import { DiscoverGroupAgentItem } from './groupAgents';
export * from './assistants';
export * from './groupAgents';
export * from './mcp';
export * from './models';
export * from './plugins';
@@ -8,6 +10,7 @@ export * from './providers';
export enum DiscoverTab {
Assistants = 'assistant',
GroupAgents = 'group_agent',
Home = 'home',
Mcp = 'mcp',
Models = 'model',
@@ -58,9 +61,10 @@ export interface DiscoverUserInfo {
}
/**
* User profile with their published agents
* User profile with their published agents and groups
*/
export interface DiscoverUserProfile {
agentGroups?: DiscoverGroupAgentItem[];
agents: DiscoverAssistantItem[];
user: DiscoverUserInfo;
}

View File

@@ -0,0 +1,19 @@
'use client';
import { type ReactNode, createContext, memo, use } from 'react';
import { type DiscoverGroupAgentDetail } from '@/types/discover';
export type DetailContextConfig = Partial<DiscoverGroupAgentDetail>;
export const DetailContext = createContext<DetailContextConfig>({});
export const DetailProvider = memo<{ children: ReactNode; config?: DetailContextConfig }>(
({ children, config = {} }) => {
return <DetailContext value={config}>{children}</DetailContext>;
},
);
export const useDetailContext = () => {
return use(DetailContext);
};

View File

@@ -0,0 +1,137 @@
import { Avatar, Flexbox, Tag } from '@lobehub/ui';
import { Card, Typography } from 'antd';
import { Crown, User } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDetailContext } from '../../DetailProvider';
const { Title, Text, Paragraph } = Typography;
const MemberCard = memo(
({
agent,
currentVersion,
}: {
agent: any;
currentVersion: any;
}) => {
const { t } = useTranslation('discover');
const isSupervisor = agent.role === 'supervisor';
return (
<Card hoverable>
<Flexbox gap={12}>
{/* Avatar and Basic Info */}
<Flexbox align="center" gap={12} horizontal>
<Avatar avatar={currentVersion.avatar || agent.name[0]} size={48} />
<Flexbox flex={1} gap={4}>
<Flexbox align="center" gap={8} horizontal>
<Title level={5} style={{ margin: 0 }}>
{currentVersion.name || agent.name}
</Title>
{isSupervisor ? (
<Tag color="gold" icon={<Crown size={12} />}>
{t('members.supervisor', { defaultValue: 'Supervisor' })}
</Tag>
) : (
<Tag color="blue" icon={<User size={12} />}>
{t('members.participant', { defaultValue: 'Participant' })}
</Tag>
)}
</Flexbox>
<Text type="secondary">{agent.identifier}</Text>
</Flexbox>
</Flexbox>
{/* Description */}
{currentVersion.description && (
<Paragraph ellipsis={{ rows: 2 }} style={{ margin: 0 }} type="secondary">
{currentVersion.description}
</Paragraph>
)}
{/* System Role (if available) */}
{currentVersion.config?.systemRole && (
<Flexbox gap={4}>
<Text strong>{t('members.systemRole', { defaultValue: 'System Role' })}:</Text>
<Paragraph ellipsis={{ rows: 3 }} style={{ margin: 0 }} type="secondary">
{currentVersion.config.systemRole}
</Paragraph>
</Flexbox>
)}
{/* Metadata */}
<Flexbox gap={8} horizontal wrap="wrap">
{currentVersion.version && (
<Text type="secondary">
{t('members.version', { defaultValue: 'Version' })}: {currentVersion.version}
</Text>
)}
{currentVersion.tokenUsage !== undefined && (
<Text type="secondary">
{t('members.tokenUsage', { defaultValue: 'Token Usage' })}:{' '}
{currentVersion.tokenUsage}
</Text>
)}
</Flexbox>
{/* URL */}
{currentVersion.url && (
<Text
copyable={{ text: currentVersion.url }}
ellipsis
style={{ fontSize: 12 }}
type="secondary"
>
{currentVersion.url}
</Text>
)}
</Flexbox>
</Card>
);
},
);
MemberCard.displayName = 'MemberCard';
const Members = memo(() => {
const { t } = useTranslation('discover');
const { memberAgents = [] } = useDetailContext();
// Sort: supervisors first, then by displayOrder
const sortedMembers = [...(memberAgents || [])].sort((a: any, b: any) => {
const aRole = a.role || a.agent?.role;
const bRole = b.role || b.agent?.role;
if (aRole === 'supervisor' && bRole !== 'supervisor') return -1;
if (aRole !== 'supervisor' && bRole === 'supervisor') return 1;
const aOrder = a.displayOrder || a.agent?.displayOrder || 0;
const bOrder = b.displayOrder || b.agent?.displayOrder || 0;
return aOrder - bOrder;
});
return (
<Flexbox gap={16}>
<Title level={4}>
{t('members.title', { defaultValue: 'Member Agents' })} ({memberAgents?.length || 0})
</Title>
<Flexbox gap={12}>
{sortedMembers.map((member: any, index) => {
// Support both flat structure and nested structure
const agent = member.agent || member;
const currentVersion = member.currentVersion || member;
return (
<MemberCard
agent={agent}
currentVersion={currentVersion}
key={agent.identifier || index}
/>
);
})}
</Flexbox>
</Flexbox>
);
});
export default Members;

View File

@@ -0,0 +1,89 @@
'use client';
import { SOCIAL_URL } from '@lobechat/business-const';
import { Flexbox, Icon, Tabs, Tag } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { BookOpenIcon, HistoryIcon, LayersIcon, ListIcon, SquareUserIcon, UsersIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDetailContext } from '../DetailProvider';
const styles = createStaticStyles(({ css, cssVar }) => {
return {
link: css`
color: ${cssVar.colorTextDescription};
&:hover {
color: ${cssVar.colorInfo};
}
`,
nav: css`
border-block-end: 1px solid ${cssVar.colorBorder};
`,
};
});
export enum GroupAgentNavKey {
Overview = 'overview',
SystemRole = 'systemRole',
Versions = 'versions',
}
interface NavProps {
activeTab?: GroupAgentNavKey;
mobile?: boolean;
setActiveTab?: (tab: GroupAgentNavKey) => void;
}
const Nav = memo<NavProps>(({ mobile, setActiveTab, activeTab = GroupAgentNavKey.Overview }) => {
const { t } = useTranslation('discover');
const nav = (
<Tabs
activeKey={activeTab}
compact={mobile}
items={[
{
icon: <Icon icon={BookOpenIcon} size={16} />,
key: GroupAgentNavKey.Overview,
label: t('groupAgents.details.overview.title', { defaultValue: 'Overview' }),
},
{
icon: <Icon icon={SquareUserIcon} size={16} />,
key: GroupAgentNavKey.SystemRole,
label: t('groupAgents.details.systemRole.title', { defaultValue: 'System Role' }),
},
{
icon: <Icon icon={HistoryIcon} size={16} />,
key: GroupAgentNavKey.Versions,
label: t('groupAgents.details.versions.title', { defaultValue: 'Versions' }),
},
]}
onChange={(key) => setActiveTab?.(key as GroupAgentNavKey)}
/>
);
return mobile ? (
nav
) : (
<Flexbox align={'center'} className={styles.nav} horizontal justify={'space-between'}>
{nav}
<Flexbox gap={12} horizontal>
<a className={styles.link} href={SOCIAL_URL.discord} rel="noreferrer" target="_blank">
{t('groupAgents.details.nav.needHelp', { defaultValue: 'Need help?' })}
</a>
<a
className={styles.link}
href="https://github.com/lobehub/lobe-chat/issues/new/choose"
rel="noreferrer"
target="_blank"
>
{t('groupAgents.details.nav.reportIssue', { defaultValue: 'Report issue' })}
</a>
</Flexbox>
</Flexbox>
);
});
export default Nav;

View File

@@ -0,0 +1,213 @@
import { BRANDING_NAME } from '@lobechat/business-const';
import { Avatar, Block, Collapse, Flexbox, Grid, Text } from '@lobehub/ui';
import { ChatList } from '@lobehub/ui/chat';
import { createStaticStyles, useTheme } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { DEFAULT_USER_AVATAR_URL } from '@/const/meta';
import { useUserStore } from '@/store/user';
import { authSelectors, userProfileSelectors } from '@/store/user/selectors';
import Title from '../../../../../features/Title';
import { useDetailContext } from '../../DetailProvider';
const styles = createStaticStyles(({ css, cssVar }) => {
return {
desc: css`
flex: 1;
margin: 0 !important;
color: ${cssVar.colorTextSecondary};
`,
title: css`
margin: 0 !important;
font-size: 14px !important;
font-weight: 500 !important;
`,
};
});
const MemberCard = memo(
({
agent,
currentVersion,
}: {
agent: any;
currentVersion: any;
}) => {
return (
<Block
height={'100%'}
style={{
cursor: 'default',
overflow: 'hidden',
}}
variant={'outlined'}
width={'100%'}
>
<Flexbox gap={12} padding={16}>
{/* Avatar and Basic Info */}
<Flexbox align={'flex-start'} gap={12} horizontal>
<Avatar
avatar={currentVersion.avatar || agent.name?.[0]}
shape={'square'}
size={40}
style={{ flex: 'none' }}
/>
<Flexbox
flex={1}
gap={4}
style={{
overflow: 'hidden',
}}
>
<Text as={'h3'} className={styles.title} ellipsis>
{currentVersion.name || agent.name}
</Text>
</Flexbox>
</Flexbox>
{/* Description */}
{currentVersion.description && currentVersion.description !== 'No description provided' && (
<Text
as={'p'}
className={styles.desc}
ellipsis={{
rows: 2,
}}
>
{currentVersion.description}
</Text>
)}
</Flexbox>
</Block>
);
},
);
MemberCard.displayName = 'MemberCard';
const Overview = memo(() => {
const [userAvatar, username] = useUserStore((s) => [
userProfileSelectors.userAvatar(s),
userProfileSelectors.username(s),
]);
const isSignedIn = useUserStore(authSelectors.isLogin);
const { t } = useTranslation('discover');
const theme = useTheme();
const {
examples = [],
description,
summary,
avatar,
title,
backgroundColor,
config,
memberAgents = [],
} = useDetailContext();
const data: any = [
{
content: config?.openingMessage,
role: 'assistant',
},
...examples,
].map((item, index) => {
let meta = {
avatar,
backgroundColor: backgroundColor || 'transparent',
title,
};
if (item.role === 'user') {
meta = {
avatar: isSignedIn && !!userAvatar ? userAvatar : DEFAULT_USER_AVATAR_URL,
backgroundColor: 'transparent',
title: isSignedIn && !!username ? username : BRANDING_NAME,
};
}
return {
extra: {},
id: index,
...item,
meta,
};
});
// Sort: supervisors first, then by displayOrder
const sortedMembers = [...(memberAgents || [])].sort((a: any, b: any) => {
const aRole = a.role || a.agent?.role;
const bRole = b.role || b.agent?.role;
if (aRole === 'supervisor' && bRole !== 'supervisor') return -1;
if (aRole !== 'supervisor' && bRole === 'supervisor') return 1;
const aOrder = a.displayOrder || a.agent?.displayOrder || 0;
const bOrder = b.displayOrder || b.agent?.displayOrder || 0;
return aOrder - bOrder;
});
return (
<Flexbox gap={16}>
<Collapse
defaultActiveKey={['summary']}
expandIconPlacement={'end'}
items={[
{
children: summary || description,
key: 'summary',
label: t('groupAgents.details.summary.title', { defaultValue: 'Summary' }),
},
]}
variant={'outlined'}
/>
{/* Members Section */}
{memberAgents.length > 0 && (
<>
<Title>
{t('groupAgents.details.members.title', { defaultValue: 'Member Agents' })} (
{memberAgents.length})
</Title>
<Grid rows={4} width={'100%'}>
{sortedMembers.map((member: any, index) => {
// Support both flat structure and nested structure
const agent = member.agent || member;
const currentVersion = member.currentVersion || member;
return (
<MemberCard
agent={agent}
currentVersion={currentVersion}
key={agent.identifier || index}
/>
);
})}
</Grid>
</>
)}
{data.length > 0 && config?.openingMessage && (
<>
<Title>
{t('groupAgents.details.overview.example', { defaultValue: 'Conversation Example' })}
</Title>
<Block
style={{
background: theme.colorBgContainerSecondary,
}}
variant={'outlined'}
>
<ChatList
data={data}
renderMessages={{
default: ({ id, editableContent }) => <div id={id}>{editableContent}</div>,
}}
style={{ width: '100%' }}
/>
</Block>
</>
)}
</Flexbox>
);
});
export default Overview;

View File

@@ -0,0 +1,86 @@
import { Avatar, Flexbox, Text } from '@lobehub/ui';
import { Grid } from '@lobehub/ui';
import qs from 'query-string';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { type DiscoverGroupAgentItem } from '@/types/discover';
import Title from '../../../../../features/Title';
import { useDetailContext } from '../../DetailProvider';
const GroupAgentCard = memo<DiscoverGroupAgentItem>((item) => {
const navigate = useNavigate();
const handleClick = () => {
if (!item.identifier) return;
navigate(qs.stringifyUrl({
url: urlJoin('/community/group_agent', item.identifier),
}));
};
return (
<Flexbox
gap={12}
onClick={handleClick}
padding={16}
style={{
borderRadius: 8,
border: '1px solid var(--lobe-border-color)',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
<Flexbox align="center" gap={12} horizontal>
<Avatar avatar={item.avatar || item.title[0]} shape="square" size={48} />
<Flexbox flex={1} gap={4}>
<Text ellipsis style={{ fontWeight: 500 }}>
{item.title}
</Text>
<Text ellipsis style={{ fontSize: 12, opacity: 0.65 }} type="secondary">
{item.description}
</Text>
</Flexbox>
</Flexbox>
</Flexbox>
);
});
const Related = memo(() => {
const { t } = useTranslation('discover');
const { related = [], category } = useDetailContext();
return (
<Flexbox gap={16}>
<Title
more={t('groupAgents.details.related.more', { defaultValue: 'View More' })}
moreLink={qs.stringifyUrl(
{
query: {
category,
},
url: '/community/group_agent',
},
{ skipNull: true },
)}
>
{t('groupAgents.details.related.listTitle', { defaultValue: 'Related Group Agents' })}
</Title>
{related.length > 0 ? (
<Grid rows={4}>
{related.map((item) => (
<GroupAgentCard key={item.identifier} {...item} />
))}
</Grid>
) : (
<Flexbox align="center" padding={32} style={{ color: '#999' }}>
{t('groupAgents.details.related.empty', { defaultValue: 'No related group agents found' })}
</Flexbox>
)}
</Flexbox>
);
});
export default Related;

View File

@@ -0,0 +1,20 @@
import { Tag as AntdTag, Flexbox } from '@lobehub/ui';
import { memo } from 'react';
interface TagListProps {
tags?: string[];
}
const TagList = memo<TagListProps>(({ tags = [] }) => {
if (!tags || tags.length === 0) return null;
return (
<Flexbox gap={8} horizontal wrap={'wrap'}>
{tags.map((tag, index) => (
<AntdTag key={index}>{tag}</AntdTag>
))}
</Flexbox>
);
});
export default TagList;

View File

@@ -0,0 +1,71 @@
import { Block, Flexbox, Icon, Tag } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { MessageCircleHeartIcon, MessageCircleQuestionIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Title from '../../../../../features/Title';
import MarkdownRender from '../../../../features/MakedownRender';
import { useDetailContext } from '../../DetailProvider';
import TagList from './TagList';
const SystemRole = memo(() => {
const { t } = useTranslation('discover');
const { tokenUsage, tags = [], config } = useDetailContext();
const { systemRole, openingMessage, openingQuestions } = config || {};
return (
<Flexbox gap={16}>
{systemRole && (
<>
<Title tag={tokenUsage && <Tag>{t('groupAgents.details.tokenUsage', { defaultValue: `${tokenUsage} tokens` })}</Tag>}>
{t('groupAgents.details.systemRole.title', { defaultValue: 'System Role' })}
</Title>
<Block gap={16} padding={16} variant={'outlined'}>
{<MarkdownRender>{systemRole.trimEnd()}</MarkdownRender>}
<TagList tags={tags} />
</Block>
</>
)}
{openingMessage && (
<>
<Title>
{t('groupAgents.details.systemRole.openingMessage', {
defaultValue: 'Opening Message',
})}
</Title>
<Block align={'flex-start'} gap={12} horizontal padding={16} variant={'outlined'}>
<Icon
color={cssVar.colorError}
icon={MessageCircleHeartIcon}
size={20}
style={{
marginTop: 4,
}}
/>
<MarkdownRender>{openingMessage?.trimEnd()}</MarkdownRender>
</Block>
</>
)}
{openingQuestions && openingQuestions.length > 0 && (
<>
<Title tag={<Tag>{openingQuestions?.length}</Tag>}>
{t('groupAgents.details.systemRole.openingQuestions', {
defaultValue: 'Opening Questions',
})}
</Title>
<Flexbox gap={8}>
{openingQuestions?.map((item, key) => (
<Block gap={12} horizontal key={key} padding={16} variant={'outlined'}>
<Icon color={cssVar.colorWarning} icon={MessageCircleQuestionIcon} size={20} />
<MarkdownRender>{item}</MarkdownRender>
</Block>
))}
</Flexbox>
</>
)}
</Flexbox>
);
});
export default SystemRole;

View File

@@ -0,0 +1,119 @@
import { Block, Flexbox, Icon, Tag } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { CheckIcon, MinusIcon } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import InlineTable from '@/components/InlineTable';
import PublishedTime from '@/components/PublishedTime';
import Title from '../../../../../features/Title';
import { useDetailContext } from '../../DetailProvider';
const Versions = memo(() => {
const { t } = useTranslation('discover');
const { versions = [], currentVersion } = useDetailContext();
const statusTagMap = useMemo(
() => ({
archived: {
color: 'default' as const,
label: t('groupAgents.details.version.status.archived', { defaultValue: 'Archived' }),
},
deprecated: {
color: 'warning' as const,
label: t('groupAgents.details.version.status.deprecated', { defaultValue: 'Deprecated' }),
},
published: {
color: 'success' as const,
label: t('groupAgents.details.version.status.published', { defaultValue: 'Published' }),
},
unpublished: {
color: 'default' as const,
label: t('groupAgents.details.version.status.unpublished', { defaultValue: 'Unpublished' }),
},
}),
[t],
);
if (!versions.length) {
return (
<Flexbox gap={16}>
<Title>
{t('groupAgents.details.version.title', { defaultValue: 'Version History' })}
</Title>
<Block padding={24} variant={'outlined'}>
{t('groupAgents.details.version.empty', { defaultValue: 'No version history available' })}
</Block>
</Flexbox>
);
}
return (
<Flexbox gap={16}>
<Title>
{t('groupAgents.details.version.title', { defaultValue: 'Version History' })}
</Title>
<Block variant={'outlined'}>
<InlineTable
columns={[
{
dataIndex: 'version',
render: (_: any, record: any) => {
const statusKey =
record.status &&
Object.prototype.hasOwnProperty.call(statusTagMap, record.status)
? (record.status as keyof typeof statusTagMap)
: undefined;
const statusMeta = statusKey ? statusTagMap[statusKey] : undefined;
return (
<Flexbox align={'center'} gap={8} horizontal>
<code style={{ fontSize: 14 }}>{record.version}</code>
{(record.isLatest || record.version === currentVersion) && (
<Tag color={'info'}>
{t('groupAgents.details.version.table.isLatest', { defaultValue: 'Latest' })}
</Tag>
)}
{statusMeta && <Tag color={statusMeta.color}>{statusMeta.label}</Tag>}
</Flexbox>
);
},
title: t('groupAgents.details.version.table.version', { defaultValue: 'Version' }),
},
{
align: 'center',
dataIndex: 'isValidated',
render: (_: any, record: any) => (
<Icon
color={record.isValidated ? cssVar.colorSuccess : cssVar.colorTextDescription}
icon={record.isValidated ? CheckIcon : MinusIcon}
/>
),
title: t('groupAgents.details.version.table.isValidated', {
defaultValue: 'Validated',
}),
},
{
align: 'end',
dataIndex: 'createdAt',
render: (_: any, record: any) => (
<PublishedTime date={record.createdAt} showPrefix={false} />
),
title: t('groupAgents.details.version.table.publishAt', {
defaultValue: 'Published At',
}),
},
]}
dataSource={versions}
rowKey={'version'}
size={'middle'}
/>
</Block>
</Flexbox>
);
});
Versions.displayName = 'GroupAgentVersions';
export default Versions;

View File

@@ -0,0 +1,51 @@
import { Flexbox } from '@lobehub/ui';
import { useResponsive } from 'antd-style';
import { useQueryState } from 'nuqs';
import { memo } from 'react';
import Sidebar from '../Sidebar';
import Nav, { GroupAgentNavKey } from './Nav';
import Overview from './Overview';
import SystemRole from './SystemRole';
import Versions from './Versions';
const Details = memo<{ mobile?: boolean }>(({ mobile: isMobile }) => {
const { mobile = isMobile } = useResponsive();
const [activeTabParam, setActiveTab] = useQueryState('activeTab');
const activeTab = activeTabParam || GroupAgentNavKey.Overview;
return (
<Flexbox gap={24}>
{/* Navigation */}
<Nav
activeTab={activeTab as GroupAgentNavKey}
mobile={mobile}
setActiveTab={(tab) => setActiveTab(tab)}
/>
<Flexbox
gap={48}
horizontal={!mobile}
style={mobile ? { flexDirection: 'column-reverse' } : undefined}
>
{/* Main Content */}
<Flexbox
style={{
overflow: 'hidden',
}}
width={'100%'}
>
{/* Tab Content */}
{activeTab === GroupAgentNavKey.Overview && <Overview />}
{activeTab === GroupAgentNavKey.SystemRole && <SystemRole />}
{activeTab === GroupAgentNavKey.Versions && <Versions />}
</Flexbox>
{/* Sidebar */}
<Sidebar mobile={mobile} />
</Flexbox>
</Flexbox>
);
});
export default Details;

View File

@@ -0,0 +1,253 @@
'use client';
import {
ActionIcon,
Avatar,
Button,
Flexbox,
Icon,
Text,
Tooltip,
TooltipGroup,
} from '@lobehub/ui';
import { App } from 'antd';
import { createStaticStyles, cssVar, useResponsive } from 'antd-style';
import { BookmarkCheckIcon, BookmarkIcon, DotIcon, HeartIcon, UsersIcon } from 'lucide-react';
import qs from 'query-string';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import urlJoin from 'url-join';
import PublishedTime from '@/components/PublishedTime';
import { useMarketAuth } from '@/layout/AuthProvider/MarketAuth';
import { socialService } from '@/services/social';
import { useDetailContext } from './DetailProvider';
const styles = createStaticStyles(({ css, cssVar }) => ({
time: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
}));
const Header = memo<{ mobile?: boolean }>(({ mobile: isMobile }) => {
const { t } = useTranslation('discover');
const { message } = App.useApp();
const data = useDetailContext();
const { mobile = isMobile } = useResponsive();
const { isAuthenticated, signIn, session } = useMarketAuth();
const [favoriteLoading, setFavoriteLoading] = useState(false);
const [likeLoading, setLikeLoading] = useState(false);
const {
memberAgents = [],
author,
avatar,
title,
category,
identifier,
createdAt,
userName,
} = data;
const displayAvatar = avatar || title?.[0] || '👥';
const memberCount = memberAgents?.length || 0;
// Set access token for social service
if (session?.accessToken) {
socialService.setAccessToken(session.accessToken);
}
// TODO: Use 'group_agent' type when social service supports it
// Fetch favorite status
const { data: favoriteStatus, mutate: mutateFavorite } = useSWR(
identifier && isAuthenticated ? ['favorite-status', 'agent', identifier] : null,
() => socialService.checkFavoriteStatus('agent', identifier!),
{ revalidateOnFocus: false },
);
const isFavorited = favoriteStatus?.isFavorited ?? false;
// Fetch like status
const { data: likeStatus, mutate: mutateLike } = useSWR(
identifier && isAuthenticated ? ['like-status', 'agent', identifier] : null,
() => socialService.checkLikeStatus('agent', identifier!),
{ revalidateOnFocus: false },
);
const isLiked = likeStatus?.isLiked ?? false;
const handleFavoriteClick = async () => {
if (!isAuthenticated) {
await signIn();
return;
}
if (!identifier) return;
setFavoriteLoading(true);
try {
if (isFavorited) {
await socialService.removeFavorite('agent', identifier);
message.success(t('assistant.unfavoriteSuccess'));
} else {
await socialService.addFavorite('agent', identifier);
message.success(t('assistant.favoriteSuccess'));
}
await mutateFavorite();
} catch {
message.error(t('assistant.favoriteFailed'));
} finally {
setFavoriteLoading(false);
}
};
const handleLikeClick = async () => {
if (!isAuthenticated) {
await signIn();
return;
}
if (!identifier) return;
setLikeLoading(true);
try {
if (isLiked) {
await socialService.unlike('agent', identifier);
message.success(t('assistant.unlikeSuccess'));
} else {
await socialService.like('agent', identifier);
message.success(t('assistant.likeSuccess'));
}
await mutateLike();
} catch {
message.error(t('assistant.likeFailed'));
} finally {
setLikeLoading(false);
}
};
const cateButton = category ? (
<Link
to={qs.stringifyUrl({
query: { category },
url: '/community/group_agent',
})}
>
<Button size={'middle'} variant={'outlined'}>
{category}
</Button>
</Link>
) : null;
return (
<Flexbox gap={12}>
<Flexbox align={'flex-start'} gap={16} horizontal width={'100%'}>
<Avatar avatar={displayAvatar} shape={'square'} size={mobile ? 48 : 64} />
<Flexbox
flex={1}
gap={4}
style={{
overflow: 'hidden',
}}
>
<Flexbox
align={'center'}
gap={8}
horizontal
justify={'space-between'}
style={{
overflow: 'hidden',
position: 'relative',
}}
>
<Flexbox
align={'center'}
flex={1}
gap={12}
horizontal
style={{
overflow: 'hidden',
position: 'relative',
}}
>
<Text
as={'h1'}
ellipsis
style={{ fontSize: mobile ? 18 : 24, margin: 0 }}
title={identifier}
>
{title}
</Text>
</Flexbox>
<Tooltip title={isLiked ? t('assistant.unlike') : t('assistant.like')}>
<ActionIcon
icon={HeartIcon}
loading={likeLoading}
onClick={handleLikeClick}
style={isLiked ? { color: '#ff4d4f' } : undefined}
/>
</Tooltip>
<Tooltip title={isFavorited ? t('assistant.unfavorite') : t('assistant.favorite')}>
<ActionIcon
icon={isFavorited ? BookmarkCheckIcon : BookmarkIcon}
loading={favoriteLoading}
onClick={handleFavoriteClick}
variant={isFavorited ? 'outlined' : undefined}
/>
</Tooltip>
</Flexbox>
<Flexbox align={'center'} gap={4} horizontal>
{(() => {
// API returns author as object {avatar, name, userName}, but type definition says string
const authorObj =
typeof author === 'object' && author !== null ? (author as any) : null;
const authorName = authorObj ? authorObj.name || authorObj.userName : author;
return authorName && userName ? (
<Link style={{ color: 'inherit' }} to={urlJoin('/community/user', userName)}>
{authorName}
</Link>
) : (
authorName
);
})()}
<Icon icon={DotIcon} />
<PublishedTime
className={styles.time}
date={createdAt as string}
template={'MMM DD, YYYY'}
/>
</Flexbox>
</Flexbox>
</Flexbox>
<TooltipGroup>
<Flexbox
align={'center'}
gap={mobile ? 12 : 24}
horizontal
style={{
color: cssVar.colorTextSecondary,
}}
>
{!mobile && cateButton}
{Boolean(memberCount) && (
<Tooltip
styles={{ root: { pointerEvents: 'none' } }}
title={t('groupAgents.memberCount', { defaultValue: 'Members' })}
>
<Flexbox align={'center'} gap={6} horizontal>
<Icon icon={UsersIcon} />
{memberCount}
</Flexbox>
</Tooltip>
)}
</Flexbox>
</TooltipGroup>
</Flexbox>
);
});
export default Header;

View File

@@ -0,0 +1,222 @@
'use client';
import { Button, DropdownMenu, Flexbox, Icon } from '@lobehub/ui';
import { App } from 'antd';
import { createStaticStyles } from 'antd-style';
import { ChevronDownIcon } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { chatGroupService } from '@/services/chatGroup';
import { discoverService } from '@/services/discover';
import { useAgentGroupStore } from '@/store/agentGroup';
import { useDetailContext } from '../../DetailProvider';
const styles = createStaticStyles(({ css }) => ({
buttonGroup: css`
width: 100%;
`,
menuButton: css`
padding-inline: 8px;
border-start-start-radius: 0 !important;
border-end-start-radius: 0 !important;
`,
primaryButton: css`
border-start-end-radius: 0 !important;
border-end-end-radius: 0 !important;
`,
}));
const AddGroupAgent = memo<{ mobile?: boolean }>(() => {
const {
avatar,
description,
tags,
title,
config,
backgroundColor,
identifier,
memberAgents = [],
} = useDetailContext();
const [isLoading, setIsLoading] = useState(false);
const { message, modal } = App.useApp();
const { t } = useTranslation('discover');
const navigate = useNavigate();
const loadGroups = useAgentGroupStore((s) => s.loadGroups);
const meta = {
avatar,
backgroundColor,
description,
tags,
title,
};
// Check if a group with the same title already exists
const checkDuplicateGroup = async () => {
if (!title) return false;
try {
const groups = await chatGroupService.getGroups();
return groups.some((g) => g.title === title);
} catch {
return false;
}
};
const showDuplicateConfirmation = (callback: () => void) => {
modal.confirm({
cancelText: t('cancel', { ns: 'common' }),
content: t('groupAgents.duplicateAdd.content', {
defaultValue: 'This group agent has already been added. Do you want to add it again?',
title,
}),
okText: t('groupAgents.duplicateAdd.ok', { defaultValue: 'Add Anyway' }),
onOk: callback,
title: t('groupAgents.duplicateAdd.title', { defaultValue: 'Group Already Added' }),
});
};
const createGroupFromMarket = async (shouldNavigate = true) => {
if (!config) {
message.error(
t('groupAgents.noConfig', { defaultValue: 'Group configuration not available' }),
);
return;
}
// Prepare group config
const groupConfig = {
config: {
allowDM: config.allowDM,
openingMessage: config.openingMessage,
openingQuestions: config.openingQuestions,
revealDM: config.revealDM,
},
content: config.systemRole,
...meta,
};
// Prepare member agents from market data
const members = memberAgents.map((member: any) => {
const agent = member.agent || member;
const currentVersion = member.currentVersion || member;
return {
avatar: currentVersion.avatar,
backgroundColor: currentVersion.backgroundColor,
description: currentVersion.description,
model: currentVersion.model,
plugins: currentVersion.plugins,
provider: currentVersion.provider,
systemRole: currentVersion.systemRole || currentVersion.content,
tags: currentVersion.tags,
title: currentVersion.name || agent.name,
};
});
try {
// Create group with all members in one request
const result = await chatGroupService.createGroupWithMembers(groupConfig, members);
// Refresh group list
await loadGroups();
// Report installation to marketplace
if (identifier) {
discoverService.reportAgentInstall(identifier);
discoverService.reportAgentEvent({
event: 'add',
identifier,
source: location.pathname,
});
}
message.success(
t('groupAgents.addSuccess', { defaultValue: 'Group agent added successfully!' }),
);
if (shouldNavigate) {
navigate(urlJoin('/group', result.groupId));
}
return result;
} catch (error) {
console.error('Failed to create group from market:', error);
message.error(
t('groupAgents.addError', {
defaultValue: 'Failed to add group agent. Please try again.',
}),
);
throw error;
}
};
const handleAddAndConverse = async () => {
setIsLoading(true);
try {
const isDuplicate = await checkDuplicateGroup();
if (isDuplicate) {
showDuplicateConfirmation(() => createGroupFromMarket(true));
} else {
await createGroupFromMarket(true);
}
} finally {
setIsLoading(false);
}
};
const handleAdd = async () => {
setIsLoading(true);
try {
const isDuplicate = await checkDuplicateGroup();
if (isDuplicate) {
showDuplicateConfirmation(() => createGroupFromMarket(false));
} else {
await createGroupFromMarket(false);
}
} finally {
setIsLoading(false);
}
};
const menuItems = [
{
key: 'addGroup',
label: t('groupAgents.addGroup', { defaultValue: 'Add Group' }),
onClick: handleAdd,
},
];
return (
<Flexbox className={styles.buttonGroup} gap={0} horizontal>
<Button
block
className={styles.primaryButton}
loading={isLoading}
onClick={handleAddAndConverse}
size={'large'}
style={{ flex: 1, width: 'unset' }}
type={'primary'}
>
{t('groupAgents.addAndConverse', { defaultValue: 'Add & Start Conversation' })}
</Button>
<DropdownMenu
items={menuItems}
popupProps={{ style: { minWidth: 267 } }}
triggerProps={{ disabled: isLoading }}
>
<Button
className={styles.menuButton}
disabled={isLoading}
icon={<Icon icon={ChevronDownIcon} />}
size={'large'}
type={'primary'}
/>
</DropdownMenu>
</Flexbox>
);
});
export default AddGroupAgent;

View File

@@ -0,0 +1,34 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import urlJoin from 'url-join';
import { OFFICIAL_URL } from '@/const/url';
import ShareButton from '../../../../features/ShareButton';
import { useDetailContext } from '../../DetailProvider';
import AddGroupAgent from './AddGroupAgent';
const ActionButton = memo<{ mobile?: boolean }>(({ mobile }) => {
const { avatar, title, description, tags, identifier } = useDetailContext();
return (
<Flexbox align={'center'} gap={8} horizontal>
<AddGroupAgent mobile={mobile} />
{identifier && (
<ShareButton
meta={{
avatar: avatar,
desc: description,
hashtags: tags,
title: title,
url: urlJoin(OFFICIAL_URL, '/community/group_agent', identifier),
}}
/>
)}
</Flexbox>
);
});
export default ActionButton;

View File

@@ -0,0 +1,42 @@
import { Collapse } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDetailContext } from '../../DetailProvider';
const Summary = memo(() => {
const { description, summary } = useDetailContext();
const { t } = useTranslation('discover');
const displayDescription = summary || description || 'No description provided';
return (
<Collapse
defaultActiveKey={['summary']}
expandIconPlacement={'end'}
items={[
{
children: (
<p
style={{
color: cssVar.colorTextSecondary,
margin: 0,
}}
>
{displayDescription}
</p>
),
key: 'summary',
label: t('groupAgents.details.summary.title', {
defaultValue: 'What can you use this group for?',
}),
},
]}
size={'small'}
variant={'borderless'}
/>
);
});
export default Summary;

View File

@@ -0,0 +1,41 @@
import { Flexbox, ScrollShadow } from '@lobehub/ui';
import { memo } from 'react';
import { useQuery } from '@/hooks/useQuery';
import { GroupAgentNavKey } from '../Details/Nav';
import ActionButton from './ActionButton';
import Summary from './Summary';
const Sidebar = memo<{ mobile?: boolean }>(({ mobile }) => {
const { activeTab = GroupAgentNavKey.Overview } = useQuery() as { activeTab: GroupAgentNavKey };
if (mobile) {
return (
<Flexbox gap={32}>
<ActionButton mobile />
</Flexbox>
);
}
return (
<ScrollShadow
flex={'none'}
gap={32}
hideScrollBar
size={4}
style={{
maxHeight: 'calc(100vh - 76px)',
paddingBottom: 24,
position: 'sticky',
top: 16,
}}
width={360}
>
<ActionButton />
{activeTab !== GroupAgentNavKey.Overview && <Summary />}
</ScrollShadow>
);
});
export default Sidebar;

View File

@@ -0,0 +1,104 @@
'use client';
import { ExclamationCircleOutlined, FolderOpenOutlined } from '@ant-design/icons';
import { Button, FluentEmoji, Text } from '@lobehub/ui';
import { Result } from 'antd';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
interface StatusPageProps {
status: 'unpublished' | 'archived' | 'deprecated';
}
const StatusPage = memo<StatusPageProps>(({ status }) => {
const navigate = useNavigate();
const { t } = useTranslation('discover');
const handleBackToMarket = () => {
navigate('/community');
};
// Unpublished status
if (status === 'unpublished') {
return (
<div
style={{
alignItems: 'center',
display: 'flex',
flex: 1,
justifyContent: 'center',
minHeight: '60vh',
padding: '20px',
}}
>
<Result
extra={
<Button onClick={handleBackToMarket} size={'large'} type="primary">
{t('groupAgents.status.backToMarket', { defaultValue: 'Back to Market' })}
</Button>
}
icon={<FluentEmoji emoji={'⌛'} size={96} type={'anim'} />}
subTitle={
<Text fontSize={16} type={'secondary'}>
{t('groupAgents.status.unpublished.subtitle', {
defaultValue:
'This group agent is under review. Please contact support@lobehub.com if you have questions.',
})}
</Text>
}
title={
<Text fontSize={28} weight={'bold'}>
{t('groupAgents.status.unpublished.title', { defaultValue: 'Under Review' })}
</Text>
}
/>
</div>
);
}
// Archived/Deprecated status
const isArchived = status === 'archived';
const statusKey = isArchived ? 'archived' : 'deprecated';
const statusIcon = isArchived ? (
<FolderOpenOutlined style={{ color: '#8c8c8c' }} />
) : (
<ExclamationCircleOutlined style={{ color: '#ff4d4f' }} />
);
return (
<div
style={{
alignItems: 'center',
display: 'flex',
flex: 1,
justifyContent: 'center',
minHeight: '60vh',
padding: '20px',
}}
>
<Result
extra={
<Button onClick={handleBackToMarket} type="primary">
{t('groupAgents.status.backToMarket', { defaultValue: 'Back to Market' })}
</Button>
}
icon={statusIcon}
subTitle={
<div style={{ color: '#666', lineHeight: 1.6 }}>
<p>
{t(`groupAgents.status.${statusKey}.subtitle`, {
defaultValue: `This group agent has been ${statusKey}.`,
})}
</p>
</div>
}
title={t(`groupAgents.status.${statusKey}.title`, {
defaultValue: isArchived ? 'Archived' : 'Deprecated',
})}
/>
</div>
);
});
export default StatusPage;

View File

@@ -0,0 +1,103 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@/hooks/useQuery';
import { useDiscoverStore } from '@/store/discover';
import NotFound from '../components/NotFound';
import { TocProvider } from '../features/Toc/useToc';
import Details from './features/Details';
import { DetailProvider } from './features/DetailProvider';
import Header from './features/Header';
import StatusPage from './features/StatusPage';
import Loading from './loading';
interface GroupAgentDetailPageProps {
mobile?: boolean;
}
const GroupAgentDetailPage = memo<GroupAgentDetailPageProps>(({ mobile }) => {
const params = useParams<{ slug: string }>();
const identifier = decodeURIComponent(params.slug ?? '');
const { version } = useQuery() as { version?: string };
// Fetch group agent detail
const useGroupAgentDetail = useDiscoverStore((s) => s.useGroupAgentDetail);
const { data, isLoading } = useGroupAgentDetail({ identifier, version });
if (isLoading) return <Loading />;
if (!data) return <NotFound />;
// Check status and show appropriate page
const status = (data as any)?.group?.status || (data as any)?.status;
if (status === 'unpublished' || status === 'archived' || status === 'deprecated') {
return <StatusPage status={status} />;
}
// Transform API data to match DiscoverGroupAgentDetail type
const apiConfig = (data as any)?.currentVersion?.config || {};
const transformedData = {
// Top level fields
author: (data as any)?.author,
// From currentVersion
avatar: (data as any)?.currentVersion?.avatar,
backgroundColor: (data as any)?.currentVersion?.backgroundColor,
category: (data as any)?.currentVersion?.category,
commentCount: (data as any)?.group?.commentCount,
// From currentVersion.config - rename systemPrompt to systemRole for consistency
config: {
...apiConfig,
allowDM: apiConfig.allowDM,
// Rename systemPrompt -> systemRole
openingMessage: apiConfig.openingMessage,
openingQuestions: apiConfig.openingQuestions,
revealDM: apiConfig.revealDM,
summary: apiConfig.summary,
systemRole: apiConfig.systemPrompt,
},
createdAt: (data as any)?.group?.createdAt,
// Version info
currentVersion: (data as any)?.currentVersion?.version,
currentVersionNumber: (data as any)?.currentVersion?.versionNumber,
description: (data as any)?.currentVersion?.description,
favoriteCount: (data as any)?.group?.favoriteCount,
homepage: (data as any)?.group?.homepage,
// From group
identifier: (data as any)?.group?.identifier,
installCount: (data as any)?.group?.installCount,
likeCount: (data as any)?.group?.likeCount,
locale: (data as any)?.locale,
memberAgents: (data as any)?.memberAgents || [],
status: (data as any)?.group?.status,
summary: (data as any)?.summary,
tags: (data as any)?.currentVersion?.tags,
title: (data as any)?.currentVersion?.name || (data as any)?.group?.name,
updatedAt: (data as any)?.group?.updatedAt,
userName: (data as any)?.author?.userName,
versions: (data as any)?.versions || [],
visibility: (data as any)?.group?.visibility,
};
return (
<TocProvider>
<DetailProvider config={transformedData}>
<Flexbox gap={16} width={'100%'}>
{/* Header Section */}
<Header mobile={mobile} />
{/* Details Section */}
<Details mobile={mobile} />
</Flexbox>
</DetailProvider>
</TocProvider>
);
});
export default GroupAgentDetailPage;

View File

@@ -0,0 +1 @@
export { DetailsLoading as default } from '../../components/ListLoading';

View File

@@ -3,11 +3,17 @@
import { type ReactNode, createContext, memo, use } from 'react';
import { type MarketUserProfile } from '@/layout/AuthProvider/MarketAuth/types';
import { type DiscoverAssistantItem, type DiscoverUserInfo } from '@/types/discover';
import {
type DiscoverAssistantItem,
type DiscoverGroupAgentItem,
type DiscoverUserInfo,
} from '@/types/discover';
export interface UserDetailContextConfig {
agentCount: number;
agentGroups?: DiscoverGroupAgentItem[];
agents: DiscoverAssistantItem[];
groupCount: number;
isOwner: boolean;
mobile?: boolean;
onEditProfile?: (onSuccess?: (profile: MarketUserProfile) => void) => void;

View File

@@ -6,11 +6,13 @@ import { memo } from 'react';
import UserAgentList from './UserAgentList';
import UserFavoriteAgents from './UserFavoriteAgents';
import UserFavoritePlugins from './UserFavoritePlugins';
import UserGroupList from './UserGroupList';
const UserContent = memo(() => {
return (
<Flexbox gap={32}>
<UserAgentList />
<UserGroupList />
<UserFavoriteAgents />
<UserFavoritePlugins />
</Flexbox>

View File

@@ -0,0 +1,186 @@
'use client';
import { Avatar, Block, Flexbox, Icon, Tag, Text, Tooltip, TooltipGroup } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { ClockIcon, DownloadIcon, HeartIcon, UsersIcon } from 'lucide-react';
import qs from 'query-string';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import PublishedTime from '@/components/PublishedTime';
import { type DiscoverGroupAgentItem } from '@/types/discover';
import { formatIntergerNumber } from '@/utils/format';
const styles = createStaticStyles(({ css, cssVar }) => {
return {
desc: css`
flex: 1;
margin: 0 !important;
color: ${cssVar.colorTextSecondary};
`,
footer: css`
margin-block-start: 16px;
border-block-start: 1px dashed ${cssVar.colorBorder};
background: ${cssVar.colorBgContainer};
`,
secondaryDesc: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
statTag: css`
border-radius: 4px;
font-family: ${cssVar.fontFamilyCode};
font-size: 11px;
color: ${cssVar.colorTextSecondary};
background: ${cssVar.colorFillTertiary};
`,
title: css`
margin: 0 !important;
font-size: 16px !important;
font-weight: 500 !important;
&:hover {
color: ${cssVar.colorLink};
}
`,
};
});
type UserGroupCardProps = DiscoverGroupAgentItem;
const UserGroupCard = memo<UserGroupCardProps>(
({ avatar, title, description, createdAt, category, installCount, identifier, memberCount }) => {
const { t } = useTranslation(['discover']);
const navigate = useNavigate();
const link = qs.stringifyUrl(
{
query: { source: 'new' },
url: urlJoin('/community/group_agent', identifier),
},
{ skipNull: true },
);
const handleCardClick = useCallback(() => {
navigate(link);
}, [link, navigate]);
return (
<Block
clickable
height={'100%'}
onClick={handleCardClick}
style={{
cursor: 'pointer',
overflow: 'hidden',
position: 'relative',
}}
variant={'outlined'}
width={'100%'}
>
<Flexbox
align={'flex-start'}
gap={16}
horizontal
justify={'space-between'}
padding={16}
width={'100%'}
>
<Flexbox
gap={12}
horizontal
style={{
overflow: 'hidden',
}}
>
<Avatar avatar={avatar} shape={'square'} size={40} style={{ flex: 'none' }} />
<Flexbox
flex={1}
gap={2}
style={{
overflow: 'hidden',
}}
>
<Link
onClick={(e) => e.stopPropagation()}
style={{ color: 'inherit', flex: 1, overflow: 'hidden' }}
to={link}
>
<Text as={'h3'} className={styles.title} ellipsis style={{ flex: 1 }}>
{title}
</Text>
</Link>
</Flexbox>
</Flexbox>
</Flexbox>
<Flexbox flex={1} gap={12} paddingInline={16}>
<Text
as={'p'}
className={styles.desc}
ellipsis={{
rows: 3,
}}
>
{description}
</Text>
<TooltipGroup>
<Flexbox align={'center'} gap={4} horizontal>
{memberCount !== undefined && memberCount > 0 && (
<Tooltip
placement={'top'}
styles={{ root: { pointerEvents: 'none' } }}
title={t('groupAgents.memberCount', { defaultValue: 'Members' })}
>
<Tag className={styles.statTag} icon={<Icon icon={UsersIcon} />}>
{formatIntergerNumber(memberCount)}
</Tag>
</Tooltip>
)}
{installCount !== undefined && installCount > 0 && (
<Tooltip
placement={'top'}
styles={{ root: { pointerEvents: 'none' } }}
title={t('groupAgents.downloads', { defaultValue: 'Downloads' })}
>
<Tag className={styles.statTag} icon={<Icon icon={DownloadIcon} />}>
{formatIntergerNumber(installCount)}
</Tag>
</Tooltip>
)}
</Flexbox>
</TooltipGroup>
</Flexbox>
<Flexbox
align={'center'}
className={styles.footer}
horizontal
justify={'space-between'}
padding={16}
>
<Flexbox
align={'center'}
className={styles.secondaryDesc}
horizontal
justify={'space-between'}
>
<Flexbox align={'center'} gap={4} horizontal>
<Icon icon={ClockIcon} size={14} />
<PublishedTime
className={styles.secondaryDesc}
date={createdAt}
template={'MMM DD, YYYY'}
/>
</Flexbox>
{category && t(`category.groupAgent.${category}` as any, { defaultValue: category })}
</Flexbox>
</Flexbox>
</Block>
);
},
);
export default UserGroupCard;

View File

@@ -0,0 +1,60 @@
'use client';
import { Flexbox, Grid, Tag, Text } from '@lobehub/ui';
import { Pagination } from 'antd';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import AssistantEmpty from '../../../features/AssistantEmpty';
import { useUserDetailContext } from './DetailProvider';
import UserGroupCard from './UserGroupCard';
interface UserGroupListProps {
pageSize?: number;
rows?: number;
}
const UserGroupList = memo<UserGroupListProps>(({ rows = 4, pageSize = 10 }) => {
const { t } = useTranslation('discover');
const { agentGroups, groupCount } = useUserDetailContext();
const [currentPage, setCurrentPage] = useState(1);
const paginatedGroups = useMemo(() => {
if (!agentGroups) return [];
const startIndex = (currentPage - 1) * pageSize;
return agentGroups.slice(startIndex, startIndex + pageSize);
}, [agentGroups, currentPage, pageSize]);
if (!agentGroups || agentGroups.length === 0) return null;
const showPagination = agentGroups.length > pageSize;
return (
<Flexbox gap={16}>
<Flexbox align={'center'} gap={8} horizontal>
<Text fontSize={16} weight={500}>
{t('user.publishedGroups', { defaultValue: '创作的群组' })}
</Text>
{groupCount > 0 && <Tag>{groupCount}</Tag>}
</Flexbox>
<Grid rows={rows} width={'100%'}>
{paginatedGroups.map((item, index) => (
<UserGroupCard key={item.identifier || index} {...item} />
))}
</Grid>
{showPagination && (
<Flexbox align={'center'} justify={'center'}>
<Pagination
current={currentPage}
onChange={(page) => setCurrentPage(page)}
pageSize={pageSize}
showSizeChanger={false}
total={agentGroups.length}
/>
</Flexbox>
)}
</Flexbox>
);
});
export default UserGroupList;

View File

@@ -58,11 +58,13 @@ const UserDetailPage = memo<UserDetailPageProps>(({ mobile }) => {
const contextConfig = useMemo(() => {
if (!data || !data.user) return null;
const { user, agents } = data;
const { user, agents, agentGroups } = data;
const totalInstalls = agents.reduce((sum, agent) => sum + (agent.installCount || 0), 0);
return {
agentCount: agents.length,
agentGroups: agentGroups || [],
agents,
groupCount: agentGroups?.length || 0,
isOwner,
mobile,
onEditProfile: handleEditProfile,

View File

@@ -1,4 +1,4 @@
import { Avatar, Block, Flexbox, Icon, Text } from '@lobehub/ui';
import { Avatar, Block, Flexbox, Icon, Tag, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { ClockIcon } from 'lucide-react';
import qs from 'query-string';
@@ -9,8 +9,8 @@ import urlJoin from 'url-join';
import PublishedTime from '@/components/PublishedTime';
import { useQuery } from '@/hooks/useQuery';
import { type AssistantMarketSource, type DiscoverAssistantItem } from '@/types/discover';
import { discoverService } from '@/services/discover';
import { type AssistantMarketSource, type DiscoverAssistantItem } from '@/types/discover';
import TokenTag from './TokenTag';
@@ -68,13 +68,16 @@ const AssistantItem = memo<DiscoverAssistantItem>(
installCount,
backgroundColor,
userName,
type,
}) => {
const navigate = useNavigate();
const { source } = useQuery() as { source?: AssistantMarketSource };
const isGroupAgent = type === 'agent-group';
const basePath = isGroupAgent ? '/community/group_agent' : '/community/assistant';
const link = qs.stringifyUrl(
{
query: { source },
url: urlJoin('/community/assistant', identifier),
url: urlJoin(basePath, identifier),
},
{ skipNull: true },
);
@@ -93,11 +96,13 @@ const AssistantItem = memo<DiscoverAssistantItem>(
);
const handleClick = useCallback(() => {
discoverService.reportAgentEvent({
event: 'click',
identifier,
source: location.pathname,
}).catch(() => {});
discoverService
.reportAgentEvent({
event: 'click',
identifier,
source: location.pathname,
})
.catch(() => {});
navigate(link);
}, [identifier, link, navigate]);
@@ -115,6 +120,19 @@ const AssistantItem = memo<DiscoverAssistantItem>(
variant={'outlined'}
width={'100%'}
>
{isGroupAgent && (
<Tag
color="info"
style={{
position: 'absolute',
right: 12,
top: 12,
zIndex: 1,
}}
>
{t('groupAgents.tag', { defaultValue: '群组' })}
</Tag>
)}
<Flexbox
align={'flex-start'}
gap={16}

View File

@@ -16,6 +16,7 @@ const AssistantPage = memo(() => {
const useAssistantList = useDiscoverStore((s) => s.useAssistantList);
const { data, isLoading } = useAssistantList({
category,
includeAgentGroup: true,
order,
page,
pageSize: 21,

View File

@@ -14,6 +14,7 @@ import { agentGroupSelectors } from '@/store/agentGroup/selectors';
import { useGroupProfileStore } from '@/store/groupProfile';
import AutoSaveHint from '../Header/AutoSaveHint';
import GroupPublishButton from '../Header/GroupPublishButton';
import GroupHeader from './GroupHeader';
const GroupProfile = memo(() => {
@@ -75,6 +76,7 @@ const GroupProfile = memo(() => {
>
{t('startConversation')}
</Button>
<GroupPublishButton />
</Flexbox>
</Flexbox>
<Divider />

View File

@@ -19,7 +19,8 @@ const PublishResultModal = memo<PublishResultModalProps>(({ identifier, onCancel
const handleGoToMarket = () => {
if (identifier) {
navigate(`/community/assistant/${identifier}`);
console.log('identifier', identifier);
navigate(`/community/group_agent/${identifier}`);
}
onCancel();
};

View File

@@ -0,0 +1,60 @@
'use client';
import { Avatar, Flexbox, Modal } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { OriginalGroupInfo } from './types';
interface GroupForkConfirmModalProps {
loading?: boolean;
onCancel: () => void;
onConfirm: () => void;
open: boolean;
originalGroup: OriginalGroupInfo | null;
}
const GroupForkConfirmModal = memo<GroupForkConfirmModalProps>(
({ open, onCancel, onConfirm, originalGroup, loading }) => {
const { t } = useTranslation('setting');
if (!originalGroup) return null;
const authorName = originalGroup.author?.name || originalGroup.author?.userName || 'Unknown';
return (
<Modal
cancelText={t('cancel', { ns: 'common' })}
centered
closable
confirmLoading={loading}
okText={t('marketPublish.forkConfirm.confirmGroup')}
onCancel={onCancel}
onOk={onConfirm}
open={open}
title={t('marketPublish.forkConfirm.titleGroup')}
width={480}
>
<Flexbox gap={16} style={{ marginTop: 16 }}>
<Flexbox align="center" gap={12} horizontal>
<Avatar avatar={originalGroup.avatar} size={48} style={{ flex: 'none' }} />
<Flexbox gap={4}>
<div style={{ fontWeight: 500 }}>{originalGroup.name}</div>
<div style={{ fontSize: 12, opacity: 0.6 }}>
{t('marketPublish.forkConfirm.by', { author: authorName })}
</div>
</Flexbox>
</Flexbox>
<p style={{ lineHeight: 1.6, margin: 0 }}>
{t('marketPublish.forkConfirm.descriptionGroup')}
</p>
</Flexbox>
</Modal>
);
},
);
GroupForkConfirmModal.displayName = 'GroupForkConfirmModal';
export default GroupForkConfirmModal;

View File

@@ -0,0 +1,62 @@
'use client';
import { FluentEmoji, Modal, Text } from '@lobehub/ui';
import { Result } from 'antd';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
interface GroupPublishResultModalProps {
identifier?: string;
onCancel: () => void;
open: boolean;
}
const GroupPublishResultModal = memo<GroupPublishResultModalProps>(
({ identifier, onCancel, open }) => {
const navigate = useNavigate();
const { t } = useTranslation('setting');
const { t: tCommon } = useTranslation('common');
const handleGoToMarket = () => {
if (identifier) {
navigate(`/community/group_agent/${identifier}`);
}
onCancel();
};
return (
<Modal
cancelText={tCommon('cancel')}
centered
okText={t('marketPublish.resultModal.view')}
onCancel={onCancel}
onOk={handleGoToMarket}
open={open}
title={null}
width={440}
>
<Result
icon={<FluentEmoji emoji={'🎉'} size={96} type={'anim'} />}
style={{
paddingBottom: 32,
paddingTop: 48,
width: '100%',
}}
subTitle={
<Text fontSize={14} type={'secondary'}>
{t('marketPublish.resultModal.messageGroup')}
</Text>
}
title={
<Text fontSize={28} weight={'bold'}>
{t('marketPublish.resultModal.title')}
</Text>
}
/>
</Modal>
);
},
);
export default GroupPublishResultModal;

View File

@@ -0,0 +1,122 @@
import { Button } from '@lobehub/ui';
import { ShapesUploadIcon } from '@lobehub/ui/icons';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { message } from '@/components/AntdStaticMethods';
import { useMarketAuth } from '@/layout/AuthProvider/MarketAuth';
import { resolveMarketAuthError } from '@/layout/AuthProvider/MarketAuth/errors';
import GroupForkConfirmModal from './GroupForkConfirmModal';
import type { MarketPublishAction, OriginalGroupInfo } from './types';
import { useMarketGroupPublish } from './useMarketGroupPublish';
interface GroupPublishButtonProps {
action: MarketPublishAction;
onPublishSuccess?: (identifier: string) => void;
}
const PublishButton = memo<GroupPublishButtonProps>(({ action, onPublishSuccess }) => {
const { t } = useTranslation(['setting', 'marketAuth']);
const { isAuthenticated, isLoading, signIn } = useMarketAuth();
const { checkOwnership, isCheckingOwnership, isPublishing, publish } = useMarketGroupPublish({
action,
onSuccess: onPublishSuccess,
});
// Fork confirmation modal state
const [showForkModal, setShowForkModal] = useState(false);
const [originalGroupInfo, setOriginalGroupInfo] = useState<OriginalGroupInfo | null>(null);
const buttonConfig = useMemo(() => {
if (action === 'upload') {
return {
authenticated: t('marketPublish.uploadGroup.tooltip'),
unauthenticated: t('marketPublish.uploadGroup.tooltip'),
} as const;
}
const submitText = t('submitGroupModal.tooltips');
return {
authenticated: submitText,
unauthenticated: t('marketPublish.submitGroup.tooltip'),
} as const;
}, [action, t]);
const doPublish = useCallback(async () => {
// Check ownership before publishing
const { needsForkConfirm, originalGroup } = await checkOwnership();
if (needsForkConfirm && originalGroup) {
// Show fork confirmation modal
setOriginalGroupInfo(originalGroup);
setShowForkModal(true);
return;
}
// No confirmation needed, proceed with publish
await publish();
}, [checkOwnership, publish]);
const handleButtonClick = useCallback(async () => {
if (!isAuthenticated) {
try {
await signIn();
// After authentication, proceed with ownership check and publish
await doPublish();
} catch (error) {
console.error(`[GroupPublishButton][${action}] Authorization failed:`, error);
const normalizedError = resolveMarketAuthError(error);
message.error({
content: t(`errors.${normalizedError.code}`, { ns: 'marketAuth' }),
key: 'market-auth',
});
}
return;
}
// User is authenticated, check ownership and publish
await doPublish();
}, [action, doPublish, isAuthenticated, signIn, t]);
const handleForkConfirm = useCallback(async () => {
setShowForkModal(false);
setOriginalGroupInfo(null);
// User confirmed, proceed with publish
await publish();
}, [publish]);
const handleForkCancel = useCallback(() => {
setShowForkModal(false);
setOriginalGroupInfo(null);
}, []);
const buttonTitle = isAuthenticated ? buttonConfig.authenticated : buttonConfig.unauthenticated;
const loading = isLoading || isCheckingOwnership || isPublishing;
return (
<>
<Button
icon={ShapesUploadIcon}
loading={loading}
onClick={handleButtonClick}
title={buttonTitle}
>
{t('publishToCommunity')}
</Button>
<GroupForkConfirmModal
loading={isPublishing}
onCancel={handleForkCancel}
onConfirm={handleForkConfirm}
open={showForkModal}
originalGroup={originalGroupInfo}
/>
</>
);
});
PublishButton.displayName = 'GroupPublishButton';
export default PublishButton;

View File

@@ -0,0 +1,46 @@
import isEqual from 'fast-deep-equal';
import { memo, useCallback, useState } from 'react';
import { useAgentGroupStore } from '@/store/agentGroup';
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
import GroupPublishResultModal from './GroupPublishResultModal';
import PublishButton from './PublishButton';
/**
* Group Publish Button Component
*
* Simplified version - backend handles ownership check automatically.
* The action type (submit vs upload) is determined by backend based on:
* 1. Whether the identifier exists
* 2. Whether the current user is the owner
*/
const GroupPublishButton = memo(() => {
const currentGroup = useAgentGroupStore(agentGroupSelectors.currentGroup, isEqual);
const [showResultModal, setShowResultModal] = useState(false);
const [publishedIdentifier, setPublishedIdentifier] = useState<string>();
const handlePublishSuccess = useCallback((identifier: string) => {
setPublishedIdentifier(identifier);
setShowResultModal(true);
}, []);
// Determine action based on whether we have an existing marketIdentifier
// Backend will verify ownership and decide to create new or update
// marketIdentifier is stored in editorData
const action = currentGroup?.editorData?.marketIdentifier ? 'upload' : 'submit';
return (
<>
<PublishButton action={action} onPublishSuccess={handlePublishSuccess} />
<GroupPublishResultModal
identifier={publishedIdentifier}
onCancel={() => setShowResultModal(false)}
open={showResultModal}
/>
</>
);
});
export default GroupPublishButton;

View File

@@ -0,0 +1,12 @@
export type MarketPublishAction = 'submit' | 'upload';
export interface OriginalGroupInfo {
author?: {
avatar?: string;
name?: string;
userName?: string;
};
avatar?: string;
identifier: string;
name: string;
}

View File

@@ -0,0 +1,211 @@
import isEqual from 'fast-deep-equal';
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { message } from '@/components/AntdStaticMethods';
import { useMarketAuth } from '@/layout/AuthProvider/MarketAuth';
import { lambdaClient } from '@/libs/trpc/client';
import { useAgentGroupStore } from '@/store/agentGroup';
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
import { useGlobalStore } from '@/store/global';
import { globalGeneralSelectors } from '@/store/global/selectors';
import type { MarketPublishAction, OriginalGroupInfo } from './types';
import { generateDefaultChangelog } from './utils';
interface UseMarketGroupPublishOptions {
action: MarketPublishAction;
onSuccess?: (identifier: string) => void;
}
export interface CheckOwnershipResult {
needsForkConfirm: boolean;
originalGroup: OriginalGroupInfo | null;
}
export const useMarketGroupPublish = ({ action, onSuccess }: UseMarketGroupPublishOptions) => {
const { t } = useTranslation('setting');
const [isPublishing, setIsPublishing] = useState(false);
const [isCheckingOwnership, setIsCheckingOwnership] = useState(false);
const isPublishingRef = useRef(false);
const { isAuthenticated } = useMarketAuth();
// Group data from store
const currentGroup = useAgentGroupStore(agentGroupSelectors.currentGroup);
const currentGroupMeta = useAgentGroupStore(agentGroupSelectors.currentGroupMeta, isEqual);
const currentGroupConfig = useAgentGroupStore(agentGroupSelectors.currentGroupConfig, isEqual);
const currentGroupAgents = useAgentGroupStore(agentGroupSelectors.currentGroupAgents);
const updateGroupMeta = useAgentGroupStore((s) => s.updateGroupMeta);
const language = useGlobalStore(globalGeneralSelectors.currentLanguage);
const isSubmit = action === 'submit';
/**
* Check ownership before publishing
* Returns whether fork confirmation is needed and original group info
*/
const checkOwnership = useCallback(async (): Promise<CheckOwnershipResult> => {
// marketIdentifier is stored in editorData
const identifier = currentGroup?.editorData?.marketIdentifier as string | undefined;
// No identifier means new group, no need to check
if (!identifier) {
return { needsForkConfirm: false, originalGroup: null };
}
try {
setIsCheckingOwnership(true);
const result = await lambdaClient.market.agentGroup.checkOwnership.query({ identifier });
// If group doesn't exist or user is owner, no confirmation needed
if (!result.exists || result.isOwner) {
return { needsForkConfirm: false, originalGroup: null };
}
// User is not owner, need fork confirmation
return {
needsForkConfirm: true,
originalGroup: result.originalGroup as OriginalGroupInfo,
};
} catch (error) {
console.error('[useMarketGroupPublish] Failed to check ownership:', error);
// On error, proceed without confirmation
return { needsForkConfirm: false, originalGroup: null };
} finally {
setIsCheckingOwnership(false);
}
}, [currentGroup]);
const publish = useCallback(async () => {
// Prevent duplicate publishing
if (isPublishingRef.current) {
return { success: false };
}
// Check authentication
if (!isAuthenticated) {
return { success: false };
}
if (!currentGroup) {
message.error({ content: t('marketPublish.modal.messages.noGroup') });
return { success: false };
}
const messageKey = isSubmit ? 'submit-group' : 'upload-group-version';
const loadingMessage = isSubmit
? t('marketPublish.modal.loading.submitGroup')
: t('marketPublish.modal.loading.uploadGroup');
const changelog = generateDefaultChangelog();
try {
isPublishingRef.current = true;
setIsPublishing(true);
message.loading({ content: loadingMessage, key: messageKey });
// Prepare member agents data
const memberAgents = currentGroupAgents.map((agent, index) => ({
// Only include avatar if it's not null/undefined
...(agent.avatar ? { avatar: agent.avatar } : {}),
config: {
// Include agent configuration
model: agent.model,
params: agent.params,
systemRole: agent.systemRole,
},
// Market requires at least 1 character for description
description: agent.description || 'No description provided',
displayOrder: index,
identifier: agent.id, // Use local agent ID as identifier
name: agent.title || 'Untitled Agent',
role: agent.isSupervisor ? ('supervisor' as const) : ('participant' as const),
// TODO: Construct proper A2A URL for the agent
url: `https://api.lobehub.com/a2a/agents/${agent.id}`,
}));
// Use tRPC publishOrCreate
const result = await lambdaClient.market.agentGroup.publishOrCreate.mutate({
// Only include avatar if it's not null/undefined
...(currentGroupMeta.avatar ? { avatar: currentGroupMeta.avatar } : {}),
// Only include backgroundColor if it's not null/undefined
...(currentGroup.backgroundColor ? { backgroundColor: currentGroup.backgroundColor } : {}),
category: 'productivity', // TODO: Allow user to select category
changelog,
// Include group-level config (systemPrompt from content, openingMessage, etc.)
config: {
// Group systemPrompt is stored in currentGroup.content
...(currentGroup.content !== undefined &&
currentGroup.content !== null && {
systemPrompt: currentGroup.content,
}),
...(currentGroupConfig.openingMessage !== undefined && {
openingMessage: currentGroupConfig.openingMessage,
}),
...(currentGroupConfig.openingQuestions !== undefined &&
currentGroupConfig.openingQuestions.length > 0 && {
openingQuestions: currentGroupConfig.openingQuestions,
}),
...(currentGroupConfig.allowDM !== undefined && { allowDM: currentGroupConfig.allowDM }),
...(currentGroupConfig.revealDM !== undefined && { revealDM: currentGroupConfig.revealDM }),
},
// Market requires at least 1 character for description
description: currentGroupMeta.description || 'No description provided',
// marketIdentifier is stored in editorData
identifier: currentGroup.editorData?.marketIdentifier as string | undefined,
memberAgents,
name: currentGroupMeta.title || 'Untitled Group',
visibility: 'public', // TODO: Allow user to select visibility
});
// Save marketIdentifier to editorData if new group
if (result.isNewGroup) {
await updateGroupMeta({
editorData: {
...currentGroup.editorData,
marketIdentifier: result.identifier,
},
});
}
message.success({
content: t('submitAgentModal.success'),
key: messageKey,
});
onSuccess?.(result.identifier);
return { identifier: result.identifier, success: true };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t('unknownError', { ns: 'common' });
message.error({
content: t('marketPublish.modal.messages.publishFailed', {
message: errorMessage,
}),
key: messageKey,
});
return { success: false };
} finally {
isPublishingRef.current = false;
setIsPublishing(false);
}
}, [
currentGroup,
currentGroupAgents,
currentGroupConfig,
currentGroupMeta,
isAuthenticated,
isSubmit,
language,
onSuccess,
t,
updateGroupMeta,
]);
return {
checkOwnership,
isCheckingOwnership,
isPublishing,
publish,
};
};

View File

@@ -0,0 +1,22 @@
import { customAlphabet } from 'nanoid/non-secure';
/**
* Generate a market identifier (8-character lowercase alphanumeric string)
* Format: [a-z0-9]{8}
* @returns A unique 8-character market identifier
*/
export const generateMarketIdentifier = () => {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
const generate = customAlphabet(alphabet, 8);
return generate();
};
/**
* Generate a default changelog based on current timestamp
* @returns A timestamp-based changelog string
*/
export const generateDefaultChangelog = () => {
const now = new Date();
const formattedDate = now.toISOString().split('T')[0];
return `Release ${formattedDate}`;
};

View File

@@ -168,6 +168,13 @@ export const desktopRoutes: RouteConfig[] = [
),
path: 'assistant/:slug',
},
{
element: dynamicElement(
() => import('../(main)/community/(detail)/group_agent'),
'Desktop > Discover > Detail > Group Agent',
),
path: 'group_agent/:slug',
},
{
element: dynamicElement(
() => import('../(main)/community/(detail)/model'),

View File

@@ -187,9 +187,13 @@ export default {
'llm.waitingForMoreLinkAriaLabel': 'Open the Provider request form',
'marketPublish.forkConfirm.by': 'by {{author}}',
'marketPublish.forkConfirm.confirm': 'Confirm Publish',
'marketPublish.forkConfirm.confirmGroup': 'Confirm Publish',
'marketPublish.forkConfirm.description':
'You are about to publish a derivative version based on an existing agent from the community. Your new agent will be created as a separate entry in the marketplace.',
'marketPublish.forkConfirm.descriptionGroup':
'You are about to publish a derivative version based on an existing group from the community. Your new group will be created as a separate entry in the marketplace.',
'marketPublish.forkConfirm.title': 'Publish Derivative Agent',
'marketPublish.forkConfirm.titleGroup': 'Publish Derivative Group',
'marketPublish.modal.changelog.extra':
'Describe the key changes and improvements in this version',
'marketPublish.modal.changelog.label': 'Changelog',
@@ -209,11 +213,14 @@ export default {
'marketPublish.modal.identifier.required': 'Please enter the agent identifier',
'marketPublish.modal.loading.fetchingRemote': 'Loading remote data...',
'marketPublish.modal.loading.submit': 'Submitting Agent...',
'marketPublish.modal.loading.submitGroup': 'Submitting Group...',
'marketPublish.modal.loading.upload': 'Publishing new version...',
'marketPublish.modal.loading.uploadGroup': 'Publishing new group version...',
'marketPublish.modal.messages.createVersionFailed': 'Failed to create version: {{message}}',
'marketPublish.modal.messages.fetchRemoteFailed': 'Failed to fetch remote agent data',
'marketPublish.modal.messages.missingIdentifier':
'This Agent doesnt have a Community identifier yet.',
'marketPublish.modal.messages.noGroup': 'No group selected',
'marketPublish.modal.messages.notAuthenticated': 'Sign in to your Community account first.',
'marketPublish.modal.messages.publishFailed': 'Publish failed: {{message}}',
'marketPublish.modal.submitButton': 'Publish',
@@ -221,12 +228,16 @@ export default {
'marketPublish.modal.title.upload': 'Publish New Version',
'marketPublish.resultModal.message':
'Your Agent has been submitted for review. Once approved, it will go live automatically.',
'marketPublish.resultModal.messageGroup':
'Your Group has been submitted for review. Once approved, it will go live automatically.',
'marketPublish.resultModal.title': 'Submission Successful',
'marketPublish.resultModal.view': 'View in Community',
'marketPublish.submit.button': 'Share to Community',
'marketPublish.submit.tooltip': 'Share this Agent to the Community',
'marketPublish.submitGroup.tooltip': 'Share this Group to the Community',
'marketPublish.upload.button': 'Publish New Version',
'marketPublish.upload.tooltip': 'Publish a new version to Agent Community',
'marketPublish.uploadGroup.tooltip': 'Publish a new version to Group Community',
'memory.enabled.desc':
'Allow LobeHub to extract preferences and info from conversations and use them later. You can view, edit, or clear memory anytime.',
'memory.enabled.title': 'Enable Memory',
@@ -551,6 +562,7 @@ export default {
'submitAgentModal.placeholder': 'Enter a unique identifier for the agent, e.g. web-development',
'submitAgentModal.success': 'Agent submitted successfully',
'submitAgentModal.tooltips': 'Share to Agent Community',
'submitGroupModal.tooltips': 'Share to Group Community',
'sync.device.deviceName.hint': 'Add a name for easy identification',
'sync.device.deviceName.placeholder': 'Enter device name',
'sync.device.deviceName.title': 'Device Name',

View File

@@ -0,0 +1,296 @@
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import { customAlphabet } from 'nanoid/non-secure';
import { z } from 'zod';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { marketSDK, marketUserInfo, serverDatabase } from '@/libs/trpc/lambda/middleware';
import { type TrustedClientUserInfo, generateTrustedClientToken } from '@/libs/trusted-client';
const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
interface MarketUserInfo {
accountId: number;
sub: string;
}
const log = debug('lambda-router:market:agent-group');
/**
* Generate a market identifier (8-character lowercase alphanumeric string)
* Format: [a-z0-9]{8}
*/
const generateMarketIdentifier = () => {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
const generate = customAlphabet(alphabet, 8);
return generate();
};
interface FetchMarketUserInfoOptions {
accessToken?: string;
userInfo?: TrustedClientUserInfo;
}
/**
* Fetch Market user info using either trustedClientToken or accessToken
* Returns the Market accountId which is different from LobeChat userId
*/
const fetchMarketUserInfo = async (
options: FetchMarketUserInfoOptions,
): Promise<MarketUserInfo | null> => {
const { userInfo, accessToken } = options;
try {
const userInfoUrl = `${MARKET_BASE_URL}/lobehub-oidc/userinfo`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (userInfo) {
const trustedClientToken = generateTrustedClientToken(userInfo);
if (trustedClientToken) {
headers['x-lobe-trust-token'] = trustedClientToken;
log('Using trustedClientToken for user info fetch');
}
}
if (!headers['x-lobe-trust-token'] && accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
log('Using accessToken for user info fetch');
}
if (!headers['x-lobe-trust-token'] && !headers['Authorization']) {
log('No authentication method available for fetching user info');
return null;
}
const response = await fetch(userInfoUrl, {
headers,
method: 'GET',
});
if (!response.ok) {
log('Failed to fetch Market user info: %s %s', response.status, response.statusText);
return null;
}
return (await response.json()) as MarketUserInfo;
} catch (error) {
log('Error fetching Market user info: %O', error);
return null;
}
};
// Authenticated procedure for agent group management
const agentGroupProcedure = authedProcedure
.use(serverDatabase)
.use(marketUserInfo)
.use(marketSDK)
.use(async ({ ctx, next }) => {
const { UserModel } = await import('@/database/models/user');
const userModel = new UserModel(ctx.serverDB, ctx.userId);
let marketOidcAccessToken: string | undefined;
try {
const userState = await userModel.getUserState(async () => ({}));
marketOidcAccessToken = userState.settings?.market?.accessToken;
log('marketOidcAccessToken from DB exists=%s', !!marketOidcAccessToken);
} catch (error) {
log('Failed to get marketOidcAccessToken from DB: %O', error);
}
return next({
ctx: {
marketOidcAccessToken,
},
});
});
// Schema definitions
const memberAgentSchema = z.object({
avatar: z.string().nullish(),
category: z.string().optional(),
config: z.record(z.any()),
description: z.string(),
displayOrder: z.number().optional(),
identifier: z.string(),
name: z.string(),
role: z.enum(['supervisor', 'participant']),
url: z.string(),
});
const publishOrCreateGroupSchema = z.object({
avatar: z.string().nullish(),
backgroundColor: z.string().nullish(),
category: z.string().optional(),
changelog: z.string().optional(),
config: z
.object({
allowDM: z.boolean().optional(),
openingMessage: z.string().optional(),
openingQuestions: z.array(z.string()).optional(),
revealDM: z.boolean().optional(),
systemPrompt: z.string().optional(),
})
.optional(),
description: z.string(),
identifier: z.string().optional(),
memberAgents: z.array(memberAgentSchema),
name: z.string(),
visibility: z.enum(['public', 'private', 'internal']).optional(),
});
export const agentGroupRouter = router({
/**
* Check if current user owns the specified group
*/
checkOwnership: agentGroupProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ input, ctx }) => {
log('checkOwnership input: %O', input);
try {
const groupDetail = await ctx.marketSDK.agentGroups.getAgentGroupDetail(input.identifier);
if (!groupDetail) {
return {
exists: false,
isOwner: false,
originalGroup: null,
};
}
const userInfo = ctx.marketUserInfo as TrustedClientUserInfo | undefined;
const accessToken = (ctx as { marketOidcAccessToken?: string }).marketOidcAccessToken;
let currentAccountId: number | null = null;
const marketUserInfoResult = await fetchMarketUserInfo({ accessToken, userInfo });
currentAccountId = marketUserInfoResult?.accountId ?? null;
const ownerId = groupDetail.group.ownerId;
const isOwner = currentAccountId !== null && `${ownerId}` === `${currentAccountId}`;
log(
'checkOwnership result: isOwner=%s, currentAccountId=%s, ownerId=%s',
isOwner,
currentAccountId,
ownerId,
);
return {
exists: true,
isOwner,
originalGroup: isOwner
? null
: {
// TODO: Add author info from group detail
author: undefined,
avatar: groupDetail.group.avatar,
identifier: groupDetail.group.identifier,
name: groupDetail.group.name,
},
};
} catch (error) {
log('Error checking ownership: %O', error);
return {
exists: false,
isOwner: false,
originalGroup: null,
};
}
}),
/**
* Unified publish or create agent group flow
* 1. Check if identifier exists and if current user is owner
* 2. If not owner or no identifier, create new group
* 3. Create new version for the group if updating
*/
publishOrCreate: agentGroupProcedure
.input(publishOrCreateGroupSchema)
.mutation(async ({ input, ctx }) => {
log('publishOrCreate input: %O', input);
const { identifier: inputIdentifier, name, memberAgents, ...groupData } = input;
let finalIdentifier = inputIdentifier;
let isNewGroup = false;
try {
// Step 1: Check ownership if identifier is provided
if (inputIdentifier) {
try {
const groupDetail =
await ctx.marketSDK.agentGroups.getAgentGroupDetail(inputIdentifier);
log('Group detail for ownership check: ownerId=%s', groupDetail?.group.ownerId);
const userInfo = ctx.marketUserInfo as TrustedClientUserInfo | undefined;
const accessToken = (ctx as { marketOidcAccessToken?: string }).marketOidcAccessToken;
let currentAccountId: number | null = null;
const marketUserInfoResult = await fetchMarketUserInfo({ accessToken, userInfo });
currentAccountId = marketUserInfoResult?.accountId ?? null;
log('Market user info: accountId=%s', currentAccountId);
const ownerId = groupDetail?.group.ownerId;
log('Ownership check: currentAccountId=%s, ownerId=%s', currentAccountId, ownerId);
if (!currentAccountId || `${ownerId}` !== `${currentAccountId}`) {
// Not the owner, need to create a new group
log('User is not owner, will create new group');
finalIdentifier = undefined;
isNewGroup = true;
}
} catch (detailError) {
// Group not found or error, create new
log('Group not found or error, will create new: %O', detailError);
finalIdentifier = undefined;
isNewGroup = true;
}
} else {
isNewGroup = true;
}
// Step 2: Create new group or update existing
if (!finalIdentifier || isNewGroup) {
// Generate a unique 8-character identifier
finalIdentifier = generateMarketIdentifier();
isNewGroup = true;
log('Creating new group with identifier: %s', finalIdentifier);
await ctx.marketSDK.agentGroups.createAgentGroup({
...groupData,
identifier: finalIdentifier,
// @ts-ignore
memberAgents,
name,
});
} else {
// Update existing group - create new version
log('Creating new version for group: %s', finalIdentifier);
await ctx.marketSDK.agentGroups.createAgentGroupVersion({
...groupData,
identifier: finalIdentifier,
// @ts-ignore
memberAgents,
name,
});
}
return {
identifier: finalIdentifier,
isNewGroup,
success: true,
};
} catch (error) {
log('Error in publishOrCreate: %O', error);
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: error instanceof Error ? error.message : 'Failed to publish group',
});
}
}),
});

View File

@@ -17,6 +17,7 @@ import {
} from '@/types/discover';
import { agentRouter } from './agent';
import { agentGroupRouter } from './agentGroup';
import { oidcRouter } from './oidc';
import { socialRouter } from './social';
import { userRouter } from './user';
@@ -44,6 +45,9 @@ export const marketRouter = router({
// ============================== Agent Management (authenticated) ==============================
agent: agentRouter,
// ============================== Agent Group Management (authenticated) ==============================
agentGroup: agentGroupRouter,
// ============================== Assistant Market ==============================
getAssistantCategories: marketProcedure
.input(
@@ -120,6 +124,7 @@ export const marketRouter = router({
.object({
category: z.string().optional(),
connectionType: z.nativeEnum(McpConnectionType).optional(),
includeAgentGroup: z.boolean().optional(),
locale: z.string().optional(),
order: z.enum(['asc', 'desc']).optional(),
ownerId: z.string().optional(),
@@ -145,6 +150,95 @@ export const marketRouter = router({
}
}),
// ============================== Group Agent Market (Discovery) ==============================
getGroupAgentCategories: marketProcedure
.input(
z
.object({
locale: z.string().optional(),
q: z.string().optional(),
})
.optional(),
)
.query(async ({ input, ctx }) => {
log('getGroupAgentCategories input: %O', input);
try {
return await ctx.discoverService.getGroupAgentCategories(input);
} catch (error) {
log('Error fetching group agent categories: %O', error);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to fetch group agent categories',
});
}
}),
getGroupAgentDetail: marketProcedure
.input(
z.object({
identifier: z.string(),
locale: z.string().optional(),
version: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
log('getGroupAgentDetail input: %O', input);
try {
return await ctx.discoverService.getGroupAgentDetail(input);
} catch (error) {
log('Error fetching group agent detail: %O', error);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to fetch group agent detail',
});
}
}),
getGroupAgentIdentifiers: marketProcedure.query(async ({ ctx }) => {
log('getGroupAgentIdentifiers called');
try {
return await ctx.discoverService.getGroupAgentIdentifiers();
} catch (error) {
log('Error fetching group agent identifiers: %O', error);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to fetch group agent identifiers',
});
}
}),
getGroupAgentList: marketProcedure
.input(
z
.object({
category: z.string().optional(),
locale: z.string().optional(),
order: z.enum(['asc', 'desc']).optional(),
ownerId: z.string().optional(),
page: z.number().optional(),
pageSize: z.number().optional(),
q: z.string().optional(),
sort: z.enum(['createdAt', 'updatedAt', 'name', 'recommended']).optional(),
})
.optional(),
)
.query(async ({ input, ctx }) => {
log('getGroupAgentList input: %O', input);
try {
return await ctx.discoverService.getGroupAgentList(input);
} catch (error) {
log('Error fetching group agent list: %O', error);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to fetch group agent list',
});
}
}),
getLegacyPluginList: marketProcedure
.input(
z
@@ -167,7 +261,7 @@ export const marketRouter = router({
}),
// ============================== MCP Market ==============================
getMcpCategories: marketProcedure
getMcpCategories: marketProcedure
.input(
z
.object({
@@ -351,7 +445,7 @@ getMcpCategories: marketProcedure
}),
// ============================== Plugin Market ==============================
getPluginCategories: marketProcedure
getPluginCategories: marketProcedure
.input(
z
.object({
@@ -439,7 +533,7 @@ getPluginCategories: marketProcedure
}),
// ============================== Providers ==============================
getProviderDetail: marketProcedure
getProviderDetail: marketProcedure
.input(
z.object({
identifier: z.string(),
@@ -503,7 +597,7 @@ getProviderDetail: marketProcedure
}),
// ============================== User Profile ==============================
getUserInfo: marketProcedure
getUserInfo: marketProcedure
.input(
z.object({
locale: z.string().optional(),
@@ -675,6 +769,42 @@ getUserInfo: marketProcedure
}
}),
reportGroupAgentEvent: marketProcedure
.input(
z.object({
event: z.enum(['add', 'chat', 'click']),
identifier: z.string(),
source: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
log('reportGroupAgentEvent input: %O', input);
try {
await ctx.discoverService.createGroupAgentEvent(input);
return { success: true };
} catch (error) {
console.error('Error reporting Group Agent event: %O', error);
return { success: false };
}
}),
reportGroupAgentInstall: marketProcedure
.input(
z.object({
identifier: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
log('reportGroupAgentInstall input: %O', input);
try {
await ctx.discoverService.increaseGroupAgentInstallCount(input.identifier);
return { success: true };
} catch (error) {
log('Error reporting group agent installation: %O', error);
return { success: false };
}
}),
reportMcpEvent: marketProcedure
.input(

View File

@@ -33,7 +33,7 @@ import {
type ModelQueryParams,
ModelSorts,
type PluginListResponse,
type PluginQueryParams as PluginQueryParams,
type PluginQueryParams,
PluginSorts,
type ProviderListResponse,
type ProviderQueryParams,
@@ -718,6 +718,7 @@ export class DiscoverService {
q,
sort = AssistantSorts.CreatedAt,
ownerId,
includeAgentGroup,
} = rest;
try {
@@ -740,9 +741,10 @@ export class DiscoverService {
}
}
// @ts-ignore
const data = await this.market.agents.getAgentList({
category,
// includeAgentGroup may not be in SDK type definition yet, using 'as any'
includeAgentGroup,
locale: normalizedLocale,
order,
ownerId,
@@ -752,7 +754,7 @@ export class DiscoverService {
sort: apiSort,
status: 'published',
visibility: 'public',
});
} as any);
const transformedItems: DiscoverAssistantItem[] = (data.items || []).map((item: any) => {
const normalizedAuthor = this.normalizeAuthorField(item.author);
@@ -773,6 +775,7 @@ export class DiscoverService {
tags: item.tags || [],
title: item.name || item.identifier,
tokenUsage: item.tokenUsage || 0,
type: item.type,
userName: normalizedAuthor.userName,
};
});
@@ -922,7 +925,6 @@ export class DiscoverService {
await this.market.plugins.createEvent(params);
};
/**
* report plugin call result to marketplace
*/
@@ -1735,14 +1737,18 @@ export class DiscoverService {
try {
// Call Market SDK to get user info
const response: UserInfoResponse = await this.market.user.getUserInfo(username, { locale });
const response = (await this.market.user.getUserInfo(username, {
locale,
})) as UserInfoResponse & {
agentGroups?: any[];
};
if (!response?.user) {
log('getUserInfo: user not found for username=%s', username);
return undefined;
}
const { user, agents } = response;
const { user, agents, agentGroups } = response;
// Transform agents to DiscoverAssistantItem format
const transformedAgents: DiscoverAssistantItem[] = (agents || []).map((agent: any) => ({
@@ -1763,7 +1769,27 @@ export class DiscoverService {
tokenUsage: agent.tokenUsage || 0,
}));
// Transform agentGroups to DiscoverGroupAgentItem format
const transformedAgentGroups = (agentGroups || []).map((group: any) => ({
author: user.displayName || user.userName || user.namespace || '',
avatar: group.avatar || '👥',
category: group.category as any,
createdAt: group.createdAt,
description: group.description || '',
homepage: `https://lobehub.com/discover/group_agent/${group.identifier}`,
identifier: group.identifier,
installCount: group.installCount || 0,
isFeatured: group.isFeatured || false,
isOfficial: group.isOfficial || false,
memberCount: 0, // Will be populated from memberAgents in detail view
schemaVersion: 1,
tags: group.tags || [],
title: group.name || group.identifier,
updatedAt: group.updatedAt,
}));
const result: DiscoverUserProfile = {
agentGroups: transformedAgentGroups,
agents: transformedAgents,
user: {
avatarUrl: user.avatarUrl || null,
@@ -1781,11 +1807,101 @@ export class DiscoverService {
},
};
log('getUserInfo: returning user profile with %d agents', result.agents.length);
log(
'getUserInfo: returning user profile with %d agents and %d groups',
result.agents.length,
result.agentGroups?.length || 0,
);
return result;
} catch (error) {
log('getUserInfo: error fetching user info: %O', error);
return undefined;
}
};
// ============================== Group Agent Market Methods ==============================
getGroupAgentCategories = async (params?: CategoryListQuery) => {
try {
// TODO: SDK method not yet available, using fallback
const response = await (this.market.agentGroups as any).getAgentGroupCategories?.(params);
return response || { items: [] };
} catch (error) {
log('getGroupAgentCategories: error: %O', error);
return { items: [] };
}
};
getGroupAgentDetail = async (params: {
identifier: string;
locale?: string;
version?: string;
}) => {
try {
const response = await this.market.agentGroups.getAgentGroupDetail(params.identifier, {
locale: params.locale,
version: params.version ? Number(params.version) : undefined,
});
return response;
} catch (error) {
log('getGroupAgentDetail: error: %O', error);
throw error;
}
};
getGroupAgentIdentifiers = async () => {
try {
// TODO: SDK method not yet available, using fallback
const response = await (this.market.agentGroups as any).getAgentGroupIdentifiers?.();
return response || { identifiers: [] };
} catch (error) {
log('getGroupAgentIdentifiers: error: %O', error);
return { identifiers: [] };
}
};
getGroupAgentList = async (params?: {
category?: string;
locale?: string;
order?: 'asc' | 'desc';
ownerId?: string;
page?: number;
pageSize?: number;
q?: string;
sort?: 'createdAt' | 'updatedAt' | 'name' | 'recommended';
}) => {
try {
const response = await this.market.agentGroups.getAgentGroupList({
...params,
status: 'published' as any,
visibility: 'public' as any,
});
return response;
} catch (error) {
log('getGroupAgentList: error: %O', error);
return { currentPage: 1, items: [], totalCount: 0, totalPages: 1 };
}
};
createGroupAgentEvent = async (params: {
event: 'add' | 'chat' | 'click';
identifier: string;
source?: string;
}) => {
try {
// TODO: SDK method not yet available
await (this.market.agentGroups as any).createAgentGroupEvent?.(params);
} catch (error) {
log('createGroupAgentEvent: error: %O', error);
}
};
increaseGroupAgentInstallCount = async (identifier: string) => {
try {
// TODO: SDK method not yet available
await (this.market.agentGroups as any).increaseInstallCount?.(identifier);
} catch (error) {
log('increaseGroupAgentInstallCount: error: %O', error);
}
};
}

View File

@@ -15,11 +15,14 @@ import {
type AssistantMarketSource,
type AssistantQueryParams,
type DiscoverAssistantDetail,
type DiscoverGroupAgentDetail,
type DiscoverMcpDetail,
type DiscoverModelDetail,
type DiscoverPluginDetail,
type DiscoverProviderDetail,
type DiscoverUserProfile,
type GroupAgentListResponse,
type GroupAgentQueryParams,
type IdentifiersResponse,
type McpListResponse,
type McpQueryParams,
@@ -448,6 +451,60 @@ class DiscoverService {
}
return null;
}
// ============================== Group Agent Market ==============================
getGroupAgentCategories = async (params: CategoryListQuery = {}): Promise<CategoryItem[]> => {
const locale = globalHelpers.getCurrentLanguage();
return lambdaClient.market.getGroupAgentCategories.query({
...params,
locale,
});
};
getGroupAgentDetail = async (params: {
identifier: string;
locale?: string;
version?: string;
}): Promise<any> => {
const locale = globalHelpers.getCurrentLanguage();
return lambdaClient.market.getGroupAgentDetail.query({
identifier: params.identifier,
locale,
version: params.version,
});
};
getGroupAgentIdentifiers = async (): Promise<IdentifiersResponse> => {
return lambdaClient.market.getGroupAgentIdentifiers.query();
};
getGroupAgentList = async (
params: GroupAgentQueryParams = {},
): Promise<any> => {
const locale = globalHelpers.getCurrentLanguage();
return lambdaClient.market.getGroupAgentList.query(
{
...params,
locale,
page: params.page ? Number(params.page) : 1,
pageSize: params.pageSize ? Number(params.pageSize) : 20,
},
{ context: { showNotification: false } },
);
};
reportGroupAgentEvent = async (params: {
event: 'add' | 'chat' | 'click';
identifier: string;
source?: string;
}): Promise<void> => {
await lambdaClient.market.reportGroupAgentEvent.mutate(params);
};
reportGroupAgentInstall = async (identifier: string): Promise<void> => {
await lambdaClient.market.reportGroupAgentInstall.mutate({ identifier });
};
}
export const discoverService = new DiscoverService();

View File

@@ -0,0 +1,80 @@
import { type CategoryItem, type CategoryListQuery } from '@lobehub/market-sdk';
import useSWR, { type SWRResponse } from 'swr';
import type { StateCreator } from 'zustand/vanilla';
import { discoverService } from '@/services/discover';
import { type DiscoverStore } from '@/store/discover';
import { globalHelpers } from '@/store/global/helpers';
import {
type DiscoverGroupAgentDetail,
type GroupAgentListResponse,
type GroupAgentQueryParams,
type IdentifiersResponse,
} from '@/types/discover';
export interface GroupAgentAction {
useGroupAgentCategories: (params?: CategoryListQuery) => SWRResponse<CategoryItem[]>;
useGroupAgentDetail: (params: {
identifier: string;
version?: string;
}) => SWRResponse<DiscoverGroupAgentDetail | undefined>;
useGroupAgentIdentifiers: () => SWRResponse<IdentifiersResponse>;
useGroupAgentList: (params?: GroupAgentQueryParams) => SWRResponse<GroupAgentListResponse>;
}
export const createGroupAgentSlice: StateCreator<
DiscoverStore,
[['zustand/devtools', never]],
[],
GroupAgentAction
> = () => ({
useGroupAgentCategories: (params = {}) => {
const locale = globalHelpers.getCurrentLanguage();
return useSWR(
['group-agent-categories', locale, ...Object.values(params)].filter(Boolean).join('-'),
async () => discoverService.getGroupAgentCategories(params),
{
revalidateOnFocus: false,
},
);
},
useGroupAgentDetail: (params) => {
const locale = globalHelpers.getCurrentLanguage();
return useSWR(
['group-agent-details', locale, params.identifier, params.version]
.filter(Boolean)
.join('-'),
async () => discoverService.getGroupAgentDetail(params),
{
revalidateOnFocus: false,
},
);
},
useGroupAgentIdentifiers: () => {
return useSWR(
'group-agent-identifiers',
async () => discoverService.getGroupAgentIdentifiers(),
{
revalidateOnFocus: false,
},
);
},
useGroupAgentList: (params = {}) => {
const locale = globalHelpers.getCurrentLanguage();
return useSWR(
['group-agent-list', locale, ...Object.values(params)].filter(Boolean).join('-'),
async () =>
discoverService.getGroupAgentList({
...params,
page: params.page ? Number(params.page) : 1,
pageSize: params.pageSize ? Number(params.pageSize) : 20,
}),
{
revalidateOnFocus: false,
},
);
},
});

View File

@@ -4,6 +4,7 @@ import { type StateCreator } from 'zustand/vanilla';
import { createDevtools } from '../middleware/createDevtools';
import { type AssistantAction, createAssistantSlice } from './slices/assistant/action';
import { type GroupAgentAction, createGroupAgentSlice } from './slices/groupAgent/action';
import { type MCPAction, createMCPSlice } from './slices/mcp';
import { type ModelAction, createModelSlice } from './slices/model/action';
import { type PluginAction, createPluginSlice } from './slices/plugin/action';
@@ -15,6 +16,7 @@ import { type UserAction, createUserSlice } from './slices/user';
export type DiscoverStore = MCPAction &
AssistantAction &
GroupAgentAction &
ProviderAction &
ModelAction &
PluginAction &
@@ -26,6 +28,7 @@ const createStore: StateCreator<DiscoverStore, [['zustand/devtools', never]]> =
) => ({
...createMCPSlice(...parameters),
...createAssistantSlice(...parameters),
...createGroupAgentSlice(...parameters),
...createProviderSlice(...parameters),
...createModelSlice(...parameters),
...createPluginSlice(...parameters),