mirror of
https://github.com/langgenius/dify.git
synced 2026-07-30 00:39:34 +08:00
337 lines
14 KiB
TypeScript
337 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import type { AgentAppPartial, AgentIconType } from '@dify/contracts/api/console/agent/types.gen'
|
|
import { Button } from '@langgenius/dify-ui/button'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@langgenius/dify-ui/dropdown-menu'
|
|
import { toast } from '@langgenius/dify-ui/toast'
|
|
import { useId, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import AppIcon from '@/app/components/base/app-icon'
|
|
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
|
import useTimestamp from '@/hooks/use-timestamp'
|
|
import Link from '@/next/link'
|
|
import { exportAppConfig } from '@/service/apps'
|
|
import { downloadBlob } from '@/utils/download'
|
|
import { AgentWorkflowReferencesDropdown } from './agent-workflow-references-dropdown'
|
|
import { DeleteAgentDialog } from './delete-agent-dialog'
|
|
import { DuplicateAgentDialog } from './duplicate-agent-dialog'
|
|
import { EditAgentDialog } from './edit-agent-dialog'
|
|
|
|
type AgentRosterListProps = {
|
|
agents: AgentAppPartial[]
|
|
hasMore: boolean
|
|
isEmptySearch: boolean
|
|
isError: boolean
|
|
isFetching: boolean
|
|
isFetchingNextPage: boolean
|
|
isPending: boolean
|
|
label: string
|
|
onLoadMore: () => void
|
|
}
|
|
|
|
const skeletonRows = ['primary', 'secondary', 'tertiary'] as const
|
|
const emptyPlaceholderCardIds = Array.from(
|
|
{ length: 16 },
|
|
(_, index) => `agent-roster-placeholder-card-${index}`,
|
|
)
|
|
|
|
function AgentRosterSkeleton() {
|
|
return (
|
|
<>
|
|
{skeletonRows.map((row) => (
|
|
<div
|
|
key={row}
|
|
className="relative h-36.5 rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3"
|
|
>
|
|
<div className="flex items-center gap-3 pt-3.5 pr-4 pb-2 pl-3.5">
|
|
<SkeletonRectangle className="my-0 size-12 shrink-0 rounded-full opacity-20" />
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5 py-1">
|
|
<SkeletonRectangle className="my-0 h-3 w-36 max-w-full rounded-md opacity-20" />
|
|
<SkeletonRectangle className="my-0 h-2 w-20 max-w-full rounded-md opacity-12" />
|
|
</div>
|
|
</div>
|
|
<div className="px-4 py-1">
|
|
<div className="flex min-h-8 flex-col gap-2 py-0.5">
|
|
<SkeletonRectangle className="my-0 h-2 w-full rounded-md opacity-12" />
|
|
<SkeletonRectangle className="my-0 h-2 w-3/4 rounded-md opacity-10" />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center pt-2 pr-3 pb-3 pl-4">
|
|
<SkeletonRectangle className="my-0 h-3 w-6 rounded-md opacity-12" />
|
|
<SkeletonRectangle className="my-0 ml-2.5 h-3 w-28 rounded-md opacity-10" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function AgentRosterPlaceholderState({ title }: { title: string }) {
|
|
return (
|
|
<section
|
|
aria-labelledby="agent-roster-placeholder-title"
|
|
className="relative col-span-full min-h-[calc(100vh-142px)] overflow-hidden"
|
|
>
|
|
<div className="pointer-events-none absolute inset-0 grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] grid-rows-4 gap-3">
|
|
{emptyPlaceholderCardIds.map((id) => (
|
|
<div key={id} className="rounded-xl bg-background-default-lighter opacity-75" />
|
|
))}
|
|
</div>
|
|
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-background-body/0 to-background-body" />
|
|
<div className="absolute inset-0 flex items-center justify-center overflow-hidden p-2">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="flex size-14 items-center justify-center rounded-[10px]">
|
|
<div className="flex size-full min-w-px items-center justify-center overflow-hidden rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-1 backdrop-blur-md">
|
|
<span aria-hidden className="i-ri-robot-2-line size-6 text-text-tertiary" />
|
|
</div>
|
|
</div>
|
|
<h2
|
|
id="agent-roster-placeholder-title"
|
|
className="system-sm-regular whitespace-nowrap text-text-tertiary"
|
|
>
|
|
{title}
|
|
</h2>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
|
|
const { t } = useTranslation('agentV2')
|
|
const { t: tCommon } = useTranslation('common')
|
|
const { t: tApp } = useTranslation('app')
|
|
const { formatTime } = useTimestamp()
|
|
const nameId = useId()
|
|
const descriptionId = useId()
|
|
const [isEditOpen, setIsEditOpen] = useState(false)
|
|
const [editSessionKey, setEditSessionKey] = useState(0)
|
|
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false)
|
|
const [duplicateSessionKey, setDuplicateSessionKey] = useState(0)
|
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
|
|
const updatedAt =
|
|
agent.updated_at != null
|
|
? formatTime(
|
|
agent.updated_at,
|
|
t(($) => $['roster.dateTimeFormat']),
|
|
)
|
|
: null
|
|
const referenceCount = agent.published_reference_count ?? 0
|
|
const publishedReferences = agent.published_references ?? []
|
|
const hasPublishedReferences = publishedReferences.length > 0
|
|
const isDraft = agent.active_config_is_published !== true
|
|
const imageUrl =
|
|
agent.icon_type === 'image' || agent.icon_type === 'link' ? agent.icon : undefined
|
|
const iconType = (imageUrl ? 'image' : agent.icon_type) as AgentIconType | null | undefined
|
|
|
|
const handleEditOpen = () => {
|
|
setEditSessionKey((key) => key + 1)
|
|
setIsEditOpen(true)
|
|
}
|
|
|
|
const handleDuplicateOpen = () => {
|
|
setDuplicateSessionKey((key) => key + 1)
|
|
setIsDuplicateOpen(true)
|
|
}
|
|
|
|
const handleExport = async () => {
|
|
if (!agent.app_id) {
|
|
toast.error(tApp(($) => $.exportFailed))
|
|
return
|
|
}
|
|
|
|
try {
|
|
const { data } = await exportAppConfig({
|
|
appID: agent.app_id,
|
|
include: false,
|
|
})
|
|
downloadBlob({
|
|
data: new Blob([data], { type: 'application/yaml' }),
|
|
fileName: `${agent.name}.yml`,
|
|
})
|
|
} catch {
|
|
toast.error(tApp(($) => $.exportFailed))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<article
|
|
aria-labelledby={nameId}
|
|
className="group relative col-span-1 h-36.5 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 ease-in-out after:pointer-events-none after:absolute after:inset-0 after:rounded-xl after:content-[''] hover:shadow-lg has-[>div>a:focus-visible]:after:inset-ring-2 has-[>div>a:focus-visible]:after:inset-ring-state-accent-solid"
|
|
>
|
|
<div className="flex h-full min-w-0 flex-col">
|
|
<Link
|
|
href={`/agents/${agent.id}/configure`}
|
|
aria-labelledby={nameId}
|
|
aria-describedby={agent.description ? descriptionId : undefined}
|
|
className="block shrink-0 cursor-pointer touch-manipulation outline-hidden"
|
|
>
|
|
<div className="flex items-center gap-3 pt-3.5 pr-4 pb-2 pl-3.5">
|
|
<span aria-hidden className="shrink-0">
|
|
<AppIcon
|
|
size="xl"
|
|
rounded
|
|
iconType={iconType}
|
|
icon={agent.icon ?? undefined}
|
|
background={agent.icon_background}
|
|
imageUrl={imageUrl}
|
|
/>
|
|
</span>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5 py-px">
|
|
<h2 id={nameId} className="truncate system-md-semibold text-text-secondary">
|
|
{agent.name}
|
|
</h2>
|
|
<p className="truncate system-xs-regular text-text-tertiary">{agent.role}</p>
|
|
</div>
|
|
</div>
|
|
<div className="px-4 py-1 system-xs-regular text-text-tertiary">
|
|
<div id={descriptionId} className="line-clamp-2 min-h-8">
|
|
{agent.description}
|
|
</div>
|
|
</div>
|
|
{isDraft && (
|
|
<div className="absolute top-[-0.5px] right-0 flex h-5 items-start overflow-hidden">
|
|
<div className="h-5 w-3 bg-background-section-burn [clip-path:polygon(0_0,100%_0,100%_100%)]" />
|
|
<div className="flex h-5 items-center bg-background-section-burn pr-2 pl-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
|
{t(($) => $['roster.usageStatus.draft'])}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Link>
|
|
<div className="flex min-w-0 shrink-0 items-center pt-2 pr-3 pb-3 pl-4 system-xs-regular text-text-tertiary">
|
|
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
|
{hasPublishedReferences ? (
|
|
<AgentWorkflowReferencesDropdown
|
|
agentName={agent.name}
|
|
publishedReferences={publishedReferences}
|
|
referenceCount={referenceCount}
|
|
/>
|
|
) : (
|
|
<div className="flex h-4 shrink-0 items-center gap-1">
|
|
<span
|
|
aria-hidden
|
|
className="i-custom-vender-agent-v2-plan size-3 shrink-0 text-text-tertiary"
|
|
/>
|
|
<span className="system-xs-regular text-text-tertiary">{referenceCount}</span>
|
|
</div>
|
|
)}
|
|
{updatedAt && (
|
|
<>
|
|
<span aria-hidden className="shrink-0 text-text-quaternary">
|
|
·
|
|
</span>
|
|
<span className="min-w-0 truncate">{updatedAt}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="pointer-events-none absolute top-2 right-2 z-20 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 opacity-0 shadow-lg backdrop-blur-xs transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 has-data-popup-open:pointer-events-auto has-data-popup-open:opacity-100">
|
|
<DropdownMenu modal={false}>
|
|
<DropdownMenuTrigger
|
|
aria-label={t(($) => $['roster.moreActions'], { name: agent.name })}
|
|
className="flex size-8 cursor-pointer items-center justify-center rounded-lg p-1.5 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
|
|
>
|
|
<span className="sr-only">
|
|
{t(($) => $['roster.moreActions'], { name: agent.name })}
|
|
</span>
|
|
<span aria-hidden className="i-ri-more-fill size-4.5 text-text-tertiary" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-40">
|
|
<DropdownMenuItem className="gap-2" onClick={handleEditOpen}>
|
|
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
|
<span>{t(($) => $['roster.editInfo'])}</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem className="gap-2" onClick={handleDuplicateOpen}>
|
|
<span
|
|
aria-hidden
|
|
className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary"
|
|
/>
|
|
<span>{tCommon(($) => $['operation.duplicate'])}</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem className="gap-2" onClick={handleExport}>
|
|
<span aria-hidden className="i-ri-download-line size-4 shrink-0 text-text-tertiary" />
|
|
<span>{tApp(($) => $.export)}</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
className="gap-2"
|
|
onClick={() => setIsDeleteOpen(true)}
|
|
>
|
|
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
|
<span>{tCommon(($) => $['operation.delete'])}</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
<EditAgentDialog
|
|
agent={agent}
|
|
formKey={editSessionKey}
|
|
open={isEditOpen}
|
|
onOpenChange={setIsEditOpen}
|
|
/>
|
|
<DuplicateAgentDialog
|
|
agent={agent}
|
|
formKey={duplicateSessionKey}
|
|
open={isDuplicateOpen}
|
|
onOpenChange={setIsDuplicateOpen}
|
|
/>
|
|
<DeleteAgentDialog
|
|
agentId={agent.id}
|
|
agentName={agent.name}
|
|
open={isDeleteOpen}
|
|
onOpenChange={setIsDeleteOpen}
|
|
/>
|
|
</article>
|
|
)
|
|
}
|
|
|
|
export function AgentRosterList({
|
|
agents,
|
|
hasMore,
|
|
isEmptySearch,
|
|
isError,
|
|
isFetching,
|
|
isFetchingNextPage,
|
|
isPending,
|
|
label,
|
|
onLoadMore,
|
|
}: AgentRosterListProps) {
|
|
const { t } = useTranslation('agentV2')
|
|
|
|
return (
|
|
<section
|
|
aria-label={label}
|
|
className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5"
|
|
aria-busy={isFetching || undefined}
|
|
>
|
|
{isPending && <AgentRosterSkeleton />}
|
|
{!isPending && isError && (
|
|
<AgentRosterPlaceholderState title={t(($) => $['roster.loadingError'])} />
|
|
)}
|
|
{!isPending && !isError && agents.length === 0 && (
|
|
<AgentRosterPlaceholderState
|
|
title={isEmptySearch ? t(($) => $['roster.emptySearch']) : t(($) => $['roster.empty'])}
|
|
/>
|
|
)}
|
|
{!isPending &&
|
|
!isError &&
|
|
agents.map((agent) => <AgentRosterItem key={agent.id} agent={agent} />)}
|
|
{!isPending && !isError && hasMore && (
|
|
<div className="col-span-full flex justify-center pt-1">
|
|
<Button loading={isFetchingNextPage} disabled={isFetchingNextPage} onClick={onLoadMore}>
|
|
{t(($) => $['roster.loadMore'])}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|