Compare commits

...

1 Commits

Author SHA1 Message Date
abhi1992002
516097469e feat(platform): add integration link image for agents without custom images
Compute top integrations (providers + categories) from agent graphs and
display them as a composite icon image instead of the animated gradient
fallback when no custom image exists.
2026-03-17 12:24:07 +05:30
10 changed files with 299 additions and 24 deletions

View File

@@ -453,6 +453,9 @@ async def create_library_agent(
sensitive_action_safe_mode=sensitive_action_safe_mode,
).model_dump()
),
topIntegrations=SafeJson(
library_model._compute_top_integrations(graph_entry)
),
**(
{"Folder": {"connect": {"id": folder_id}}}
if folder_id and graph_entry is graph
@@ -621,6 +624,15 @@ async def update_library_agent_version_and_settings(
user_id=user_id,
settings=updated_settings,
)
# Recompute top integrations on version update
top_integrations = library_model._compute_top_integrations(agent_graph)
await prisma.models.LibraryAgent.prisma().update(
where={"id": library.id},
data={"topIntegrations": SafeJson(top_integrations)},
)
library.top_integrations = top_integrations
return library

View File

@@ -1,3 +1,4 @@
import collections
import datetime
from enum import Enum
from typing import TYPE_CHECKING, Any, Optional
@@ -6,6 +7,7 @@ import prisma.enums
import prisma.models
import pydantic
from backend.blocks._base import BlockCategory
from backend.data.graph import GraphModel, GraphSettings, GraphTriggerInfo
from backend.data.model import (
CredentialsMetaInput,
@@ -144,6 +146,15 @@ class RecentExecution(pydantic.BaseModel):
activity_summary: str | None = None
def _parse_top_integrations(
raw: object, graph: GraphModel
) -> list[dict[str, str]]:
"""Parse topIntegrations from database, falling back to on-the-fly computation."""
if raw and isinstance(raw, list) and len(raw) > 0:
return [dict(item) for item in raw]
return _compute_top_integrations(graph)
def _parse_settings(settings: dict | str | None) -> GraphSettings:
"""Parse settings from database, handling both dict and string formats."""
if settings is None:
@@ -156,6 +167,62 @@ def _parse_settings(settings: dict | str | None) -> GraphSettings:
return GraphSettings()
# Priority order for category-based integration entries
_CATEGORY_PRIORITY: list[BlockCategory] = [
BlockCategory.AI,
BlockCategory.SOCIAL,
BlockCategory.COMMUNICATION,
BlockCategory.DEVELOPER_TOOLS,
BlockCategory.DATA,
BlockCategory.CRM,
BlockCategory.PRODUCTIVITY,
BlockCategory.ISSUE_TRACKING,
BlockCategory.TEXT,
BlockCategory.SEARCH,
BlockCategory.MULTIMEDIA,
BlockCategory.MARKETING,
BlockCategory.LOGIC,
BlockCategory.BASIC,
BlockCategory.INPUT,
BlockCategory.OUTPUT,
]
def _compute_top_integrations(
graph: GraphModel,
) -> list[dict[str, str]]:
"""Compute the top integrations used by an agent's graph.
Returns up to 5 entries: providers first (by frequency), then categories.
"""
provider_counter: collections.Counter[str] = collections.Counter()
category_counter: collections.Counter[BlockCategory] = collections.Counter()
for g in [graph, *graph.sub_graphs]:
for node in g.nodes:
for info in node.block.input_schema.get_credentials_fields_info().values():
for provider in info.provider:
provider_counter[provider] += 1
if node.block.categories:
for cat in node.block.categories:
category_counter[cat] += 1
result: list[dict[str, str]] = [
{"name": name, "type": "provider"}
for name, _ in provider_counter.most_common(5)
]
if len(result) < 5:
for cat in _CATEGORY_PRIORITY:
if len(result) >= 5:
break
if category_counter.get(cat, 0) > 0:
result.append({"name": cat.name, "type": "category"})
return result
class LibraryAgent(pydantic.BaseModel):
"""
Represents an agent in the library, including metadata for display and
@@ -215,6 +282,7 @@ class LibraryAgent(pydantic.BaseModel):
recommended_schedule_cron: str | None = None
settings: GraphSettings = pydantic.Field(default_factory=GraphSettings)
top_integrations: list[dict[str, str]] = pydantic.Field(default_factory=list)
marketplace_listing: Optional["MarketplaceListing"] = None
@staticmethod
@@ -355,6 +423,9 @@ class LibraryAgent(pydantic.BaseModel):
folder_name=agent.Folder.name if agent.Folder else None,
recommended_schedule_cron=agent.AgentGraph.recommendedScheduleCron,
settings=_parse_settings(agent.settings),
top_integrations=_parse_top_integrations(
agent.topIntegrations, graph
),
marketplace_listing=marketplace_listing_data,
)

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "platform"."LibraryAgent" ADD COLUMN "topIntegrations" JSONB NOT NULL DEFAULT '[]';

View File

@@ -440,6 +440,8 @@ model LibraryAgent {
settings Json @default("{}")
topIntegrations Json @default("[]")
@@unique([userId, agentGraphId, agentGraphVersion])
@@index([agentGraphId, agentGraphVersion])
@@index([creatorId])

View File

@@ -12,6 +12,7 @@ import Avatar, {
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
import { Link } from "@/components/atoms/Link/Link";
import { IntegrationLinkImage } from "@/components/molecules/IntegrationLinkImage/IntegrationLinkImage";
import { AgentCardMenu } from "./components/AgentCardMenu";
import { FavoriteButton } from "./components/FavoriteButton";
import { useLibraryAgentCard } from "./useLibraryAgentCard";
@@ -102,20 +103,17 @@ export function LibraryAgentCard({ agent, draggable = true }: Props) {
</Text>
{!image_url ? (
<div
className={`h-[3.64rem] w-[6.70rem] flex-shrink-0 rounded-small ${
[
"bg-gradient-to-r from-green-200 to-blue-200",
"bg-gradient-to-r from-pink-200 to-purple-200",
"bg-gradient-to-r from-yellow-200 to-orange-200",
"bg-gradient-to-r from-blue-200 to-cyan-200",
"bg-gradient-to-r from-indigo-200 to-purple-200",
][parseInt(id.slice(0, 8), 16) % 5]
}`}
style={{
backgroundSize: "200% 200%",
animation: "gradient 15s ease infinite",
}}
<IntegrationLinkImage
integrations={
"top_integrations" in agent
? (agent.top_integrations as Array<{
name: string;
type: "provider" | "category";
}>)
: []
}
size="sm"
className="h-[3.64rem] w-[6.70rem] flex-shrink-0 rounded-small"
/>
) : (
<Image

View File

@@ -10,6 +10,7 @@ import {
} from "@/components/__legacy__/ui/card";
import { useState } from "react";
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
import { IntegrationLinkImage } from "@/components/molecules/IntegrationLinkImage/IntegrationLinkImage";
interface FeaturedStoreCardProps {
agent: StoreAgent;
@@ -40,15 +41,32 @@ export const FeaturedAgentCard = ({
</CardHeader>
<CardContent className="flex-1 p-4">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-xl">
<Image
src={agent.agent_image || "/autogpt-logo-dark-bg.png"}
alt={`${agent.agent_name} preview`}
fill
sizes="100%"
className={`object-cover transition-opacity duration-200 ${
isHovered ? "opacity-0" : "opacity-100"
}`}
/>
{agent.agent_image ? (
<Image
src={agent.agent_image}
alt={`${agent.agent_name} preview`}
fill
sizes="100%"
className={`object-cover transition-opacity duration-200 ${
isHovered ? "opacity-0" : "opacity-100"
}`}
/>
) : (
<IntegrationLinkImage
integrations={
"top_integrations" in agent
? (agent.top_integrations as Array<{
name: string;
type: "provider" | "category";
}>)
: []
}
size="lg"
className={`absolute inset-0 h-full w-full transition-opacity duration-200 ${
isHovered ? "opacity-0" : "opacity-100"
}`}
/>
)}
<div
className={`absolute inset-0 overflow-y-auto p-4 transition-opacity duration-200 ${
isHovered ? "opacity-100" : "opacity-0"

View File

@@ -4,6 +4,7 @@ import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
import { IntegrationLinkImage } from "@/components/molecules/IntegrationLinkImage/IntegrationLinkImage";
interface StoreCardProps {
agentName: string;
@@ -15,6 +16,7 @@ interface StoreCardProps {
avatarSrc: string;
hideAvatar?: boolean;
creatorName?: string;
topIntegrations?: Array<{ name: string; type: "provider" | "category" }>;
}
export const StoreCard: React.FC<StoreCardProps> = ({
@@ -27,6 +29,7 @@ export const StoreCard: React.FC<StoreCardProps> = ({
avatarSrc,
hideAvatar = false,
creatorName,
topIntegrations,
}) => {
const handleClick = () => {
onClick();
@@ -48,13 +51,19 @@ export const StoreCard: React.FC<StoreCardProps> = ({
>
{/* First Section: Image with Avatar */}
<div className="relative aspect-[2/1.2] w-full overflow-hidden rounded-3xl md:aspect-[2.17/1]">
{agentImage && (
{agentImage ? (
<Image
src={agentImage}
alt={`${agentName} preview image`}
fill
className="object-cover"
/>
) : (
<IntegrationLinkImage
integrations={topIntegrations ?? []}
size="md"
className="absolute inset-0 h-full w-full"
/>
)}
{!hideAvatar && (
<div className="absolute bottom-4 left-4">

View File

@@ -0,0 +1,112 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import {
getCategoryIcon,
getProviderIconPath,
RobotIcon,
PlugIcon,
} from "./helpers";
interface Props {
integrations: Array<{ name: string; type: "provider" | "category" }>;
size?: "sm" | "md" | "lg";
className?: string;
}
const SIZE_CONFIG = {
sm: { icon: 20, gap: 8, lineWidth: 12 },
md: { icon: 28, gap: 12, lineWidth: 16 },
lg: { icon: 36, gap: 16, lineWidth: 20 },
} as const;
function ProviderIcon({ name, iconSize }: { name: string; iconSize: number }) {
const [hasError, setHasError] = useState(false);
if (hasError) {
return <PlugIcon size={iconSize} className="text-zinc-400" />;
}
return (
<Image
src={getProviderIconPath(name)}
alt={name}
width={iconSize}
height={iconSize}
className="rounded-sm object-contain"
onError={() => setHasError(true)}
/>
);
}
function ConnectingLine({ width, height }: { width: number; height: number }) {
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="flex-shrink-0"
>
<line
x1={0}
y1={height / 2}
x2={width}
y2={height / 2}
stroke="currentColor"
strokeWidth={1.5}
className="text-zinc-300"
/>
<polygon
points={`${width - 4},${height / 2 - 3} ${width},${height / 2} ${width - 4},${height / 2 + 3}`}
fill="currentColor"
className="text-zinc-300"
/>
</svg>
);
}
export function IntegrationLinkImage({
integrations,
size = "sm",
className = "",
}: Props) {
const config = SIZE_CONFIG[size];
const items = integrations.slice(0, 3);
if (items.length === 0) {
return (
<div
className={`flex items-center justify-center rounded-small bg-zinc-50 ${className}`}
>
<RobotIcon size={config.icon} className="text-zinc-400" />
</div>
);
}
return (
<div
className={`flex items-center justify-center gap-0 rounded-small bg-zinc-50 ${className}`}
>
{items.map((item, i) => (
<div key={`${item.name}-${i}`} className="flex items-center">
{i > 0 && (
<ConnectingLine width={config.lineWidth} height={config.icon} />
)}
<div className="flex items-center justify-center">
{item.type === "provider" ? (
<ProviderIcon name={item.name} iconSize={config.icon} />
) : (
(() => {
const CategoryIcon = getCategoryIcon(item.name);
return (
<CategoryIcon size={config.icon} className="text-zinc-500" />
);
})()
)}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import {
BrainIcon,
UsersThreeIcon,
ChatCircleIcon,
CodeIcon,
DatabaseIcon,
TextTIcon,
MagnifyingGlassIcon,
GitBranchIcon,
CubeIcon,
ArrowSquareInIcon,
ArrowSquareOutIcon,
AddressBookIcon,
FilmStripIcon,
CheckSquareIcon,
MegaphoneIcon,
BugIcon,
RobotIcon,
PlugIcon,
} from "@phosphor-icons/react";
import type { Icon } from "@phosphor-icons/react";
const CATEGORY_ICON_MAP: Record<string, Icon> = {
AI: BrainIcon,
SOCIAL: UsersThreeIcon,
COMMUNICATION: ChatCircleIcon,
DEVELOPER_TOOLS: CodeIcon,
DATA: DatabaseIcon,
TEXT: TextTIcon,
SEARCH: MagnifyingGlassIcon,
LOGIC: GitBranchIcon,
BASIC: CubeIcon,
INPUT: ArrowSquareInIcon,
OUTPUT: ArrowSquareOutIcon,
CRM: AddressBookIcon,
MULTIMEDIA: FilmStripIcon,
PRODUCTIVITY: CheckSquareIcon,
MARKETING: MegaphoneIcon,
ISSUE_TRACKING: BugIcon,
};
export function getCategoryIcon(categoryName: string): Icon {
return CATEGORY_ICON_MAP[categoryName] ?? CubeIcon;
}
export function getProviderIconPath(providerName: string): string {
return `/integrations/${providerName}.png`;
}
export { RobotIcon, PlugIcon };

View File

@@ -525,6 +525,7 @@ export type LibraryAgent = {
is_favorite: boolean;
is_latest_version: boolean;
recommended_schedule_cron: string | null;
top_integrations: Array<{ name: string; type: "provider" | "category" }>;
} & (
| {
has_external_trigger: true;