feat: support agent group unpublish agents (#11687)

feat: support agent group unpublish agents
This commit is contained in:
Shinji-Li
2026-01-22 10:58:15 +08:00
committed by GitHub
parent b13bb8a839
commit 4e060be8e4
7 changed files with 397 additions and 45 deletions

View File

@@ -19,7 +19,11 @@ export interface UserDetailContextConfig {
isOwner: boolean;
mobile?: boolean;
onEditProfile?: (onSuccess?: (profile: MarketUserProfile) => void) => void;
onStatusChange?: (identifier: string, action: 'publish' | 'unpublish' | 'deprecate') => void;
onStatusChange?: (
identifier: string,
action: 'publish' | 'unpublish' | 'deprecate',
type?: 'agent' | 'group',
) => void;
totalInstalls: number;
user: DiscoverUserInfo;
}

View File

@@ -78,6 +78,7 @@ const styles = createStaticStyles(({ css, cssVar }) => {
`,
moreButton: css`
position: absolute;
z-index: 10;
inset-block-start: 12px;
inset-inline-end: 12px;
@@ -259,14 +260,13 @@ const UserAgentCard = memo<UserAgentCardProps>(
width={'100%'}
>
{isOwner && (
<DropdownMenu items={menuItems}>
<div
className={cx('more-button', styles.moreButton)}
onClick={(e) => e.stopPropagation()}
>
<Icon icon={MoreVerticalIcon} size={16} style={{ cursor: 'pointer' }} />
</div>
</DropdownMenu>
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu items={menuItems as any}>
<div className={cx('more-button', styles.moreButton)}>
<Icon icon={MoreVerticalIcon} size={16} style={{ cursor: 'pointer' }} />
</div>
</DropdownMenu>
</div>
)}
<Flexbox
align={'flex-start'}

View File

@@ -1,8 +1,28 @@
'use client';
import { Avatar, Block, Flexbox, Icon, Tag, Text, Tooltip, TooltipGroup } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { ClockIcon, DownloadIcon, UsersIcon } from 'lucide-react';
import {
Tag as AntTag,
Avatar,
Block,
DropdownMenu,
Flexbox,
Icon,
Tag,
Text,
Tooltip,
TooltipGroup,
} from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import {
AlertTriangle,
ClockIcon,
DownloadIcon,
Eye,
EyeOff,
MoreVerticalIcon,
Pencil,
UsersIcon,
} from 'lucide-react';
import qs from 'query-string';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -10,9 +30,31 @@ import { Link, useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import PublishedTime from '@/components/PublishedTime';
import { type DiscoverGroupAgentItem } from '@/types/discover';
import { type DiscoverGroupAgentItem, type GroupAgentStatus } from '@/types/discover';
import { formatIntergerNumber } from '@/utils/format';
import { useUserDetailContext } from './DetailProvider';
const getStatusTagColor = (status?: GroupAgentStatus) => {
switch (status) {
case 'published': {
return 'green';
}
case 'unpublished': {
return 'orange';
}
case 'deprecated': {
return 'red';
}
case 'archived': {
return 'default';
}
default: {
return 'default';
}
}
};
const styles = createStaticStyles(({ css, cssVar }) => {
return {
desc: css`
@@ -25,6 +67,16 @@ const styles = createStaticStyles(({ css, cssVar }) => {
border-block-start: 1px dashed ${cssVar.colorBorder};
background: ${cssVar.colorBgContainer};
`,
moreButton: css`
position: absolute;
z-index: 10;
inset-block-start: 12px;
inset-inline-end: 12px;
opacity: 0;
transition: opacity 0.2s;
`,
secondaryDesc: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
@@ -47,15 +99,31 @@ const styles = createStaticStyles(({ css, cssVar }) => {
color: ${cssVar.colorLink};
}
`,
wrapper: css`
&:hover .more-button {
opacity: 1;
}
`,
};
});
type UserGroupCardProps = DiscoverGroupAgentItem;
const UserGroupCard = memo<UserGroupCardProps>(
({ avatar, title, description, createdAt, category, installCount, identifier, memberCount }) => {
const { t } = useTranslation(['discover']);
({
avatar,
title,
description,
createdAt,
category,
installCount,
identifier,
memberCount,
status,
}) => {
const { t } = useTranslation(['discover', 'setting']);
const navigate = useNavigate();
const { isOwner, onStatusChange } = useUserDetailContext();
const link = qs.stringifyUrl(
{
@@ -65,12 +133,55 @@ const UserGroupCard = memo<UserGroupCardProps>(
{ skipNull: true },
);
const isPublished = status === 'published';
const handleCardClick = useCallback(() => {
navigate(link);
}, [link, navigate]);
const handleEdit = useCallback(() => {
navigate(urlJoin('/group', identifier, 'profile'));
}, [identifier, navigate]);
const handleStatusAction = useCallback(
(action: 'publish' | 'unpublish' | 'deprecate') => {
onStatusChange?.(identifier, action, 'group');
},
[identifier, onStatusChange],
);
const menuItems = isOwner
? [
{
icon: <Icon icon={Pencil} />,
key: 'edit',
label: t('setting:myAgents.actions.edit'),
onClick: handleEdit,
},
{
type: 'divider' as const,
},
{
icon: <Icon icon={isPublished ? EyeOff : Eye} />,
key: 'togglePublish',
label: isPublished
? t('setting:myAgents.actions.unpublish')
: t('setting:myAgents.actions.publish'),
onClick: () => handleStatusAction(isPublished ? 'unpublish' : 'publish'),
},
{
danger: true,
icon: <Icon icon={AlertTriangle} />,
key: 'deprecate',
label: t('setting:myAgents.actions.deprecate'),
onClick: () => handleStatusAction('deprecate'),
},
]
: [];
return (
<Block
className={styles.wrapper}
clickable
height={'100%'}
onClick={handleCardClick}
@@ -82,6 +193,15 @@ const UserGroupCard = memo<UserGroupCardProps>(
variant={'outlined'}
width={'100%'}
>
{isOwner && (
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu items={menuItems as any}>
<div className={cx('more-button', styles.moreButton)}>
<Icon icon={MoreVerticalIcon} size={16} style={{ cursor: 'pointer' }} />
</div>
</DropdownMenu>
</div>
)}
<Flexbox
align={'flex-start'}
gap={16}
@@ -105,15 +225,22 @@ const UserGroupCard = memo<UserGroupCardProps>(
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 align={'center'} gap={8} horizontal>
<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>
{isOwner && status && (
<AntTag color={getStatusTagColor(status)} style={{ flexShrink: 0, margin: 0 }}>
{t(`setting:myAgents.status.${status}`)}
</AntTag>
)}
</Flexbox>
</Flexbox>
</Flexbox>
</Flexbox>

View File

@@ -6,8 +6,10 @@ import { useTranslation } from 'react-i18next';
import { useMarketAuth } from '@/layout/AuthProvider/MarketAuth';
import { marketApiService } from '@/services/marketApi';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
export type AgentStatusAction = 'publish' | 'unpublish' | 'deprecate';
export type EntityType = 'agent' | 'group';
interface UseUserDetailOptions {
onMutate?: () => void;
@@ -17,43 +19,67 @@ export const useUserDetail = ({ onMutate }: UseUserDetailOptions = {}) => {
const { t } = useTranslation('setting');
const { message, modal } = App.useApp();
const { session } = useMarketAuth();
const enableMarketTrustedClient = useServerConfigStore(
serverConfigSelectors.enableMarketTrustedClient,
);
const handleStatusChange = useCallback(
async (identifier: string, action: AgentStatusAction) => {
if (!session?.accessToken) {
async (identifier: string, action: AgentStatusAction, type: EntityType = 'agent') => {
if (!enableMarketTrustedClient && !session?.accessToken) {
message.error(t('myAgents.errors.notAuthenticated'));
return;
}
const messageKey = `agent-status-${action}`;
const messageKey = `${type}-status-${action}`;
const loadingText = t(`myAgents.actions.${action}Loading` as any);
const successText = t(`myAgents.actions.${action}Success` as any);
const errorText = t(`myAgents.actions.${action}Error` as any);
async function executeStatusChange(identifier: string, action: AgentStatusAction) {
async function executeStatusChange(
identifier: string,
action: AgentStatusAction,
type: EntityType,
) {
try {
message.loading({ content: loadingText, key: messageKey });
marketApiService.setAccessToken(session!.accessToken);
switch (action) {
case 'publish': {
await marketApiService.publishAgent(identifier);
break;
if (type === 'group') {
switch (action) {
case 'publish': {
await marketApiService.publishAgentGroup(identifier);
break;
}
case 'unpublish': {
await marketApiService.unpublishAgentGroup(identifier);
break;
}
case 'deprecate': {
await marketApiService.deprecateAgentGroup(identifier);
break;
}
}
case 'unpublish': {
await marketApiService.unpublishAgent(identifier);
break;
}
case 'deprecate': {
await marketApiService.deprecateAgent(identifier);
break;
} else {
switch (action) {
case 'publish': {
await marketApiService.publishAgent(identifier);
break;
}
case 'unpublish': {
await marketApiService.unpublishAgent(identifier);
break;
}
case 'deprecate': {
await marketApiService.deprecateAgent(identifier);
break;
}
}
}
message.success({ content: successText, key: messageKey });
onMutate?.();
} catch (error) {
console.error(`[useUserDetail] ${action} agent error:`, error);
console.error(`[useUserDetail] ${action} ${type} error:`, error);
message.error({
content: `${errorText}: ${error instanceof Error ? error.message : 'Unknown error'}`,
key: messageKey,
@@ -61,7 +87,6 @@ export const useUserDetail = ({ onMutate }: UseUserDetailOptions = {}) => {
}
}
// For deprecate action, show confirmation dialog first
if (action === 'deprecate') {
modal.confirm({
cancelText: t('myAgents.actions.cancel'),
@@ -69,16 +94,16 @@ export const useUserDetail = ({ onMutate }: UseUserDetailOptions = {}) => {
okButtonProps: { danger: true },
okText: t('myAgents.actions.confirmDeprecate'),
onOk: async () => {
await executeStatusChange(identifier, action);
await executeStatusChange(identifier, action, type);
},
title: t('myAgents.actions.deprecateConfirmTitle'),
});
return;
}
await executeStatusChange(identifier, action);
await executeStatusChange(identifier, action, type);
},
[session?.accessToken, message, modal, t, onMutate],
[enableMarketTrustedClient, session?.accessToken, message, modal, t, onMutate],
);
return {

View File

@@ -202,6 +202,66 @@ export const agentGroupRouter = router({
/**
* Deprecate agent group
* POST /market/agent-group/:identifier/deprecate
*/
deprecateAgentGroup: agentGroupProcedure
.input(z.object({ identifier: z.string() }))
.mutation(async ({ input, ctx }) => {
log('deprecateAgentGroup input: %O', input);
try {
const deprecateUrl = `${MARKET_BASE_URL}/api/v1/agent-groups/${input.identifier}/deprecate`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const userInfo = ctx.marketUserInfo as TrustedClientUserInfo | undefined;
const accessToken = (ctx as { marketOidcAccessToken?: string }).marketOidcAccessToken;
if (userInfo) {
const trustedClientToken = generateTrustedClientToken(userInfo);
if (trustedClientToken) {
headers['x-lobe-trust-token'] = trustedClientToken;
}
}
if (!headers['x-lobe-trust-token'] && accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
const response = await fetch(deprecateUrl, {
headers,
method: 'POST',
});
if (!response.ok) {
const errorText = await response.text();
log(
'Deprecate agent group failed: %s %s - %s',
response.status,
response.statusText,
errorText,
);
throw new Error(`Failed to deprecate agent group: ${response.statusText}`);
}
log('Deprecate agent group success');
return { success: true };
} catch (error) {
log('Error deprecating agent group: %O', error);
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: error instanceof Error ? error.message : 'Failed to deprecate agent group',
});
}
}),
/**
* Fork an agent group
* POST /market/agent-group/:identifier/fork
*/
@@ -340,7 +400,6 @@ getAgentGroupForkSource: agentGroupProcedure
/**
* Get all forks of an agent group
* GET /market/agent-group/:identifier/forks
@@ -401,6 +460,66 @@ getAgentGroupForks: agentGroupProcedure
/**
* Publish agent group
* POST /market/agent-group/:identifier/publish
*/
publishAgentGroup: agentGroupProcedure
.input(z.object({ identifier: z.string() }))
.mutation(async ({ input, ctx }) => {
log('publishAgentGroup input: %O', input);
try {
const publishUrl = `${MARKET_BASE_URL}/api/v1/agent-groups/${input.identifier}/publish`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const userInfo = ctx.marketUserInfo as TrustedClientUserInfo | undefined;
const accessToken = (ctx as { marketOidcAccessToken?: string }).marketOidcAccessToken;
if (userInfo) {
const trustedClientToken = generateTrustedClientToken(userInfo);
if (trustedClientToken) {
headers['x-lobe-trust-token'] = trustedClientToken;
}
}
if (!headers['x-lobe-trust-token'] && accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
const response = await fetch(publishUrl, {
headers,
method: 'POST',
});
if (!response.ok) {
const errorText = await response.text();
log(
'Publish agent group failed: %s %s - %s',
response.status,
response.statusText,
errorText,
);
throw new Error(`Failed to publish agent group: ${response.statusText}`);
}
log('Publish agent group success');
return { success: true };
} catch (error) {
log('Error publishing agent group: %O', error);
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: error instanceof Error ? error.message : 'Failed to publish agent group',
});
}
}),
/**
* Unified publish or create agent group flow
* 1. Check if identifier exists and if current user is owner
@@ -494,6 +613,65 @@ publishOrCreate: agentGroupProcedure
});
}
}),
/**
* Unpublish agent group
* POST /market/agent-group/:identifier/unpublish
*/
unpublishAgentGroup: agentGroupProcedure
.input(z.object({ identifier: z.string() }))
.mutation(async ({ input, ctx }) => {
log('unpublishAgentGroup input: %O', input);
try {
const unpublishUrl = `${MARKET_BASE_URL}/api/v1/agent-groups/${input.identifier}/unpublish`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const userInfo = ctx.marketUserInfo as TrustedClientUserInfo | undefined;
const accessToken = (ctx as { marketOidcAccessToken?: string }).marketOidcAccessToken;
if (userInfo) {
const trustedClientToken = generateTrustedClientToken(userInfo);
if (trustedClientToken) {
headers['x-lobe-trust-token'] = trustedClientToken;
}
}
if (!headers['x-lobe-trust-token'] && accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
const response = await fetch(unpublishUrl, {
headers,
method: 'POST',
});
if (!response.ok) {
const errorText = await response.text();
log(
'Unpublish agent group failed: %s %s - %s',
response.status,
response.statusText,
errorText,
);
throw new Error(`Failed to unpublish agent group: ${response.statusText}`);
}
log('Unpublish agent group success');
return { success: true };
} catch (error) {
log('Error unpublishing agent group: %O', error);
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: error instanceof Error ? error.message : 'Failed to unpublish agent group',
});
}
}),
});
export type AgentGroupRouter = typeof agentGroupRouter;

View File

@@ -1761,6 +1761,7 @@ export class DiscoverService {
knowledgeCount: agent.knowledgeCount || 0,
pluginCount: agent.pluginCount || 0,
schemaVersion: 1,
status: agent.status,
tags: agent.tags || [],
title: agent.name || agent.identifier,
tokenUsage: agent.tokenUsage || 0,
@@ -1780,6 +1781,7 @@ export class DiscoverService {
isOfficial: group.isOfficial || false,
memberCount: 0, // Will be populated from memberAgents in detail view
schemaVersion: 1,
status: group.status,
tags: group.tags || [],
title: group.name || group.identifier,
updatedAt: group.updatedAt,
@@ -1802,6 +1804,7 @@ export class DiscoverService {
knowledgeCount: agent.knowledgeCount || 0,
pluginCount: agent.pluginCount || 0,
schemaVersion: 1,
status: agent.status,
tags: agent.tags || [],
title: agent.name || agent.identifier,
tokenUsage: agent.tokenUsage || 0,
@@ -1824,6 +1827,7 @@ export class DiscoverService {
isOfficial: group.isOfficial || false,
memberCount: 0, // Will be populated from memberAgents in detail view
schemaVersion: 1,
status: group.status,
tags: group.tags || [],
title: group.name || group.identifier,
updatedAt: group.updatedAt,

View File

@@ -146,6 +146,20 @@ export class MarketApiService {
return lambdaClient.market.agent.getAgentForkSource.query({ identifier });
}
// ==================== Agent Group Status Management ====================
async publishAgentGroup(identifier: string): Promise<void> {
await lambdaClient.market.agentGroup.publishAgentGroup.mutate({ identifier });
}
async unpublishAgentGroup(identifier: string): Promise<void> {
await lambdaClient.market.agentGroup.unpublishAgentGroup.mutate({ identifier });
}
async deprecateAgentGroup(identifier: string): Promise<void> {
await lambdaClient.market.agentGroup.deprecateAgentGroup.mutate({ identifier });
}
// ==================== Fork Agent Group API ====================
/**