feat(platform): marketplace update notifications with enhanced publishing workflow (#11630)

## Summary
This PR implements a comprehensive marketplace update notification
system that allows users to discover and update to newer agent versions,
along with enhanced publishing workflows and UI improvements.

<img width="1500" height="533" alt="image"
src="https://github.com/user-attachments/assets/ee331838-d712-4718-b231-1f9ec21bcd8e"
/>

<img width="600" height="610" alt="image"
src="https://github.com/user-attachments/assets/b881a7b8-91a5-460d-a159-f64765b339f1"
/>

<img width="1500" height="416" alt="image"
src="https://github.com/user-attachments/assets/a2d61904-2673-4e44-bcc5-c47d36af7a38"
/>

<img width="1500" height="1015" alt="image"
src="https://github.com/user-attachments/assets/2dd978c7-20cc-4230-977e-9c62157b9f23"
/>


## Core Features

### 🔔 Marketplace Update Notifications
- **Update detection**: Automatically detects when marketplace has newer
agent versions than user's local copy
- **Creator notifications**: Shows banners for creators with unpublished
changes ready to publish
- **Non-creator support**: Enables regular users to discover and update
to newer marketplace versions
- **Version comparison**: Intelligent logic comparing `graph_version` vs
marketplace listing versions

### 📋 Enhanced Publishing Workflow  
- **Builder integration**: Added "Publish to Marketplace" button
directly in the builder actions
- **Unified banner system**: Consistent `MarketplaceBanners` component
across library and marketplace pages
- **Streamlined UX**: Fixed layout issues, improved button placement and
styling
- **Modal improvements**: Fixed thumbnail loading race conditions and
infinite loop bugs

### 📚 Version History & Changelog
- **Inline version history**: Added version changelog directly to
marketplace agent pages
- **Version comparison**: Clear display of available versions with
current version highlighting
- **Update mechanism**: Direct updates using `graph_version` parameter
for accuracy

## Technical Implementation

### Backend Changes
- **Database schema**: Added `agentGraphVersions` and `agentGraphId`
fields to `StoreAgent` model
- **API enhancement**: Updated store endpoints to expose graph version
data for version comparison
- **Data migration**: Fixed agent version field naming from `version` to
`agentGraphVersions`
- **Model updates**: Enhanced `LibraryAgentUpdateRequest` with
`graph_version` field

### Frontend Architecture
- **`useMarketplaceUpdate` hook**: Centralized marketplace update
detection and creator identification
- **`MarketplaceBanners` component**: Unified banner system with proper
vertical layout and styling
- **`AgentVersionChangelog` component**: Version history display for
marketplace pages
- **`PublishToMarketplace` component**: Builder integration with modal
workflow

### Key Bug Fixes
- **Thumbnail loading**: Fixed race condition where images wouldn't load
on first modal open
- **Infinite loops**: Used refs to prevent circular dependencies in
`useThumbnailImages` hook
- **Layout issues**: Fixed banner placement, removed duplicate
breadcrumbs, corrected vertical layout
- **Field naming**: Fixed `agent_version` vs `version` field
inconsistencies across APIs

## Files Changed

### Backend
- `autogpt_platform/backend/backend/server/v2/store/` - Enhanced store
API with graph version data
- `autogpt_platform/backend/backend/server/v2/library/` - Updated
library API models
- `autogpt_platform/backend/migrations/` - Database migrations for
version fields
- `autogpt_platform/backend/schema.prisma` - Schema updates for graph
versions

### Frontend
- `src/app/(platform)/components/MarketplaceBanners/` - New unified
banner component
- `src/app/(platform)/library/agents/[id]/components/` - Enhanced
library views with banners
- `src/app/(platform)/build/components/BuilderActions/` - Added
marketplace publish button
- `src/app/(platform)/marketplace/components/AgentInfo/` - Added inline
version history
- `src/components/contextual/PublishAgentModal/` - Fixed thumbnail
loading and modal workflow

## User Experience Impact
- **Better discovery**: Users automatically notified of newer agent
versions
- **Streamlined publishing**: Direct publish access from builder
interface
- **Reduced friction**: Fixed UI bugs, improved loading states,
consistent design
- **Enhanced transparency**: Inline version history on marketplace pages
- **Creator workflow**: Better notifications for creators with
unpublished changes

## Testing
-  Update banners appear correctly when marketplace has newer versions
-  Creator banners show for users with unpublished changes  
-  Version comparison logic works with graph_version vs marketplace
versions
-  Publish button in builder opens modal correctly with pre-populated
data
-  Thumbnail images load properly on first modal open without infinite
loops
-  Database migrations completed successfully with version field fixes
-  All existing tests updated and passing with new schema changes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Lluis Agusti <hi@llu.lu>
Co-authored-by: Ubbe <hi@ubbe.dev>
Co-authored-by: Reinier van der Leer <pwuts@agpt.co>
This commit is contained in:
Zamil Majdy
2025-12-22 12:13:06 +01:00
committed by GitHub
parent c3e407ef09
commit 88731b1f76
47 changed files with 1600 additions and 286 deletions

View File

@@ -538,6 +538,7 @@ async def update_library_agent(
library_agent_id: str,
user_id: str,
auto_update_version: Optional[bool] = None,
graph_version: Optional[int] = None,
is_favorite: Optional[bool] = None,
is_archived: Optional[bool] = None,
is_deleted: Optional[Literal[False]] = None,
@@ -550,6 +551,7 @@ async def update_library_agent(
library_agent_id: The ID of the LibraryAgent to update.
user_id: The owner of this LibraryAgent.
auto_update_version: Whether the agent should auto-update to active version.
graph_version: Specific graph version to update to.
is_favorite: Whether this agent is marked as a favorite.
is_archived: Whether this agent is archived.
settings: User-specific settings for this library agent.
@@ -563,8 +565,8 @@ async def update_library_agent(
"""
logger.debug(
f"Updating library agent {library_agent_id} for user {user_id} with "
f"auto_update_version={auto_update_version}, is_favorite={is_favorite}, "
f"is_archived={is_archived}, settings={settings}"
f"auto_update_version={auto_update_version}, graph_version={graph_version}, "
f"is_favorite={is_favorite}, is_archived={is_archived}, settings={settings}"
)
update_fields: prisma.types.LibraryAgentUpdateManyMutationInput = {}
if auto_update_version is not None:
@@ -581,10 +583,23 @@ async def update_library_agent(
update_fields["isDeleted"] = is_deleted
if settings is not None:
update_fields["settings"] = SafeJson(settings.model_dump())
if not update_fields:
raise ValueError("No values were passed to update")
try:
# If graph_version is provided, update to that specific version
if graph_version is not None:
# Get the current agent to find its graph_id
agent = await get_library_agent(id=library_agent_id, user_id=user_id)
# Update to the specified version using existing function
return await update_agent_version_in_library(
user_id=user_id,
agent_graph_id=agent.graph_id,
agent_graph_version=graph_version,
)
# Otherwise, just update the simple fields
if not update_fields:
raise ValueError("No values were passed to update")
n_updated = await prisma.models.LibraryAgent.prisma().update_many(
where={"id": library_agent_id, "userId": user_id},
data=update_fields,

View File

@@ -385,6 +385,9 @@ class LibraryAgentUpdateRequest(pydantic.BaseModel):
auto_update_version: Optional[bool] = pydantic.Field(
default=None, description="Auto-update the agent version"
)
graph_version: Optional[int] = pydantic.Field(
default=None, description="Specific graph version to update to"
)
is_favorite: Optional[bool] = pydantic.Field(
default=None, description="Mark the agent as a favorite"
)

View File

@@ -285,6 +285,7 @@ async def update_library_agent(
library_agent_id=library_agent_id,
user_id=user_id,
auto_update_version=payload.auto_update_version,
graph_version=payload.graph_version,
is_favorite=payload.is_favorite,
is_archived=payload.is_archived,
settings=payload.settings,

View File

@@ -43,10 +43,12 @@ async def _get_cached_store_agents(
# Cache individual agent details for 15 minutes
@cached(maxsize=200, ttl_seconds=300, shared_cache=True)
async def _get_cached_agent_details(username: str, agent_name: str):
async def _get_cached_agent_details(
username: str, agent_name: str, include_changelog: bool = False
):
"""Cached helper to get agent details."""
return await store_db.get_store_agent_details(
username=username, agent_name=agent_name
username=username, agent_name=agent_name, include_changelog=include_changelog
)

View File

@@ -257,7 +257,7 @@ async def log_search_term(search_query: str):
async def get_store_agent_details(
username: str, agent_name: str
username: str, agent_name: str, include_changelog: bool = False
) -> store_model.StoreAgentDetails:
"""Get PUBLIC store agent details from the StoreAgent view"""
logger.debug(f"Getting store agent details for {username}/{agent_name}")
@@ -322,6 +322,27 @@ async def get_store_agent_details(
else:
recommended_schedule_cron = None
# Fetch changelog data if requested
changelog_data = None
if include_changelog and store_listing:
changelog_versions = (
await prisma.models.StoreListingVersion.prisma().find_many(
where={
"storeListingId": store_listing.id,
"submissionStatus": prisma.enums.SubmissionStatus.APPROVED,
},
order=[{"version": "desc"}],
)
)
changelog_data = [
store_model.ChangelogEntry(
version=str(version.version),
changes_summary=version.changesSummary or "No changes recorded",
date=version.createdAt,
)
for version in changelog_versions
]
logger.debug(f"Found agent details for {username}/{agent_name}")
return store_model.StoreAgentDetails(
store_listing_version_id=agent.storeListingVersionId,
@@ -338,10 +359,13 @@ async def get_store_agent_details(
runs=agent.runs,
rating=agent.rating,
versions=agent.versions,
agentGraphVersions=agent.agentGraphVersions,
agentGraphId=agent.agentGraphId,
last_updated=agent.updated_at,
active_version_id=active_version_id,
has_approved_version=has_approved_version,
recommended_schedule_cron=recommended_schedule_cron,
changelog=changelog_data,
)
except store_exceptions.AgentNotFoundError:
raise
@@ -409,6 +433,8 @@ async def get_store_agent_by_version_id(
runs=agent.runs,
rating=agent.rating,
versions=agent.versions,
agentGraphVersions=agent.agentGraphVersions,
agentGraphId=agent.agentGraphId,
last_updated=agent.updated_at,
)
except store_exceptions.AgentNotFoundError:

View File

@@ -40,6 +40,8 @@ async def test_get_store_agents(mocker):
runs=10,
rating=4.5,
versions=["1.0"],
agentGraphVersions=["1"],
agentGraphId="test-graph-id",
updated_at=datetime.now(),
is_available=False,
useForOnboarding=False,
@@ -83,6 +85,8 @@ async def test_get_store_agent_details(mocker):
runs=10,
rating=4.5,
versions=["1.0"],
agentGraphVersions=["1"],
agentGraphId="test-graph-id",
updated_at=datetime.now(),
is_available=False,
useForOnboarding=False,
@@ -105,6 +109,8 @@ async def test_get_store_agent_details(mocker):
runs=15,
rating=4.8,
versions=["1.0", "2.0"],
agentGraphVersions=["1", "2"],
agentGraphId="test-graph-id-active",
updated_at=datetime.now(),
is_available=True,
useForOnboarding=False,

View File

@@ -7,6 +7,12 @@ import pydantic
from backend.util.models import Pagination
class ChangelogEntry(pydantic.BaseModel):
version: str
changes_summary: str
date: datetime.datetime
class MyAgent(pydantic.BaseModel):
agent_id: str
agent_version: int
@@ -55,12 +61,17 @@ class StoreAgentDetails(pydantic.BaseModel):
runs: int
rating: float
versions: list[str]
agentGraphVersions: list[str]
agentGraphId: str
last_updated: datetime.datetime
recommended_schedule_cron: str | None = None
active_version_id: str | None = None
has_approved_version: bool = False
# Optional changelog data when include_changelog=True
changelog: list[ChangelogEntry] | None = None
class Creator(pydantic.BaseModel):
name: str

View File

@@ -72,6 +72,8 @@ def test_store_agent_details():
runs=50,
rating=4.5,
versions=["1.0", "2.0"],
agentGraphVersions=["1", "2"],
agentGraphId="test-graph-id",
last_updated=datetime.datetime.now(),
)
assert details.slug == "test-agent"

View File

@@ -152,7 +152,11 @@ async def get_agents(
tags=["store", "public"],
response_model=store_model.StoreAgentDetails,
)
async def get_agent(username: str, agent_name: str):
async def get_agent(
username: str,
agent_name: str,
include_changelog: bool = fastapi.Query(default=False),
):
"""
This is only used on the AgentDetails Page.
@@ -162,7 +166,7 @@ async def get_agent(username: str, agent_name: str):
# URL decode the agent name since it comes from the URL path
agent_name = urllib.parse.unquote(agent_name).lower()
agent = await store_cache._get_cached_agent_details(
username=username, agent_name=agent_name
username=username, agent_name=agent_name, include_changelog=include_changelog
)
return agent

View File

@@ -374,6 +374,8 @@ def test_get_agent_details(
runs=100,
rating=4.5,
versions=["1.0.0", "1.1.0"],
agentGraphVersions=["1", "2"],
agentGraphId="test-graph-id",
last_updated=FIXED_NOW,
)
mock_db_call = mocker.patch("backend.api.features.store.db.get_store_agent_details")
@@ -387,7 +389,9 @@ def test_get_agent_details(
assert data.creator == "creator1"
snapshot.snapshot_dir = "snapshots"
snapshot.assert_match(json.dumps(response.json(), indent=2), "agt_details")
mock_db_call.assert_called_once_with(username="creator1", agent_name="test-agent")
mock_db_call.assert_called_once_with(
username="creator1", agent_name="test-agent", include_changelog=False
)
def test_get_creators_defaults(

View File

@@ -442,6 +442,8 @@ async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]:
runs=agent.runs,
rating=agent.rating,
versions=agent.versions,
agentGraphVersions=agent.agentGraphVersions,
agentGraphId=agent.agentGraphId,
last_updated=agent.updated_at,
)
for agent in recommended_agents

View File

@@ -0,0 +1,45 @@
-- Fix StoreSubmission view to use agentGraphVersion instead of version for agent_version field
-- This ensures that submission.agent_version returns the actual agent graph version, not the store listing version number
BEGIN;
-- Recreate the view with the corrected agent_version field (using agentGraphVersion instead of version)
CREATE OR REPLACE VIEW "StoreSubmission" AS
SELECT
sl.id AS listing_id,
sl."owningUserId" AS user_id,
slv."agentGraphId" AS agent_id,
slv."agentGraphVersion" AS agent_version,
sl.slug,
COALESCE(slv.name, '') AS name,
slv."subHeading" AS sub_heading,
slv.description,
slv.instructions,
slv."imageUrls" AS image_urls,
slv."submittedAt" AS date_submitted,
slv."submissionStatus" AS status,
COALESCE(ar.run_count, 0::bigint) AS runs,
COALESCE(avg(sr.score::numeric), 0.0)::double precision AS rating,
slv.id AS store_listing_version_id,
slv."reviewerId" AS reviewer_id,
slv."reviewComments" AS review_comments,
slv."internalComments" AS internal_comments,
slv."reviewedAt" AS reviewed_at,
slv."changesSummary" AS changes_summary,
slv."videoUrl" AS video_url,
slv.categories
FROM "StoreListing" sl
JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id
LEFT JOIN "StoreListingReview" sr ON sr."storeListingVersionId" = slv.id
LEFT JOIN (
SELECT "AgentGraphExecution"."agentGraphId", count(*) AS run_count
FROM "AgentGraphExecution"
GROUP BY "AgentGraphExecution"."agentGraphId"
) ar ON ar."agentGraphId" = slv."agentGraphId"
WHERE sl."isDeleted" = false
GROUP BY sl.id, sl."owningUserId", slv.id, slv."agentGraphId", slv."agentGraphVersion", sl.slug, slv.name,
slv."subHeading", slv.description, slv.instructions, slv."imageUrls", slv."submittedAt",
slv."submissionStatus", slv."reviewerId", slv."reviewComments", slv."internalComments",
slv."reviewedAt", slv."changesSummary", slv."videoUrl", slv.categories, ar.run_count;
COMMIT;

View File

@@ -0,0 +1,81 @@
-- Add agentGraphVersions field to StoreAgent view for consistent version comparison
-- This keeps the existing versions field unchanged and adds a new field with graph versions
-- This makes it safe for version comparison with LibraryAgent.graph_version
BEGIN;
-- Drop and recreate the StoreAgent view with new agentGraphVersions field
DROP VIEW IF EXISTS "StoreAgent";
CREATE OR REPLACE VIEW "StoreAgent" AS
WITH latest_versions AS (
SELECT
"storeListingId",
MAX(version) AS max_version
FROM "StoreListingVersion"
WHERE "submissionStatus" = 'APPROVED'
GROUP BY "storeListingId"
),
agent_versions AS (
SELECT
"storeListingId",
array_agg(DISTINCT version::text ORDER BY version::text) AS versions
FROM "StoreListingVersion"
WHERE "submissionStatus" = 'APPROVED'
GROUP BY "storeListingId"
),
agent_graph_versions AS (
SELECT
"storeListingId",
array_agg(DISTINCT "agentGraphVersion"::text ORDER BY "agentGraphVersion"::text) AS graph_versions
FROM "StoreListingVersion"
WHERE "submissionStatus" = 'APPROVED'
GROUP BY "storeListingId"
)
SELECT
sl.id AS listing_id,
slv.id AS "storeListingVersionId",
slv."createdAt" AS updated_at,
sl.slug,
COALESCE(slv.name, '') AS agent_name,
slv."videoUrl" AS agent_video,
slv."agentOutputDemoUrl" AS agent_output_demo,
COALESCE(slv."imageUrls", ARRAY[]::text[]) AS agent_image,
slv."isFeatured" AS featured,
p.username AS creator_username, -- Allow NULL for malformed sub-agents
p."avatarUrl" AS creator_avatar, -- Allow NULL for malformed sub-agents
slv."subHeading" AS sub_heading,
slv.description,
slv.categories,
slv.search,
COALESCE(ar.run_count, 0::bigint) AS runs,
COALESCE(rs.avg_rating, 0.0)::double precision AS rating,
COALESCE(av.versions, ARRAY[slv.version::text]) AS versions,
COALESCE(agv.graph_versions, ARRAY[slv."agentGraphVersion"::text]) AS "agentGraphVersions",
slv."agentGraphId",
slv."isAvailable" AS is_available,
COALESCE(sl."useForOnboarding", false) AS "useForOnboarding"
FROM "StoreListing" sl
JOIN latest_versions lv
ON sl.id = lv."storeListingId"
JOIN "StoreListingVersion" slv
ON slv."storeListingId" = lv."storeListingId"
AND slv.version = lv.max_version
AND slv."submissionStatus" = 'APPROVED'
JOIN "AgentGraph" a
ON slv."agentGraphId" = a.id
AND slv."agentGraphVersion" = a.version
LEFT JOIN "Profile" p
ON sl."owningUserId" = p."userId"
LEFT JOIN "mv_review_stats" rs
ON sl.id = rs."storeListingId"
LEFT JOIN "mv_agent_run_counts" ar
ON a.id = ar."agentGraphId"
LEFT JOIN agent_versions av
ON sl.id = av."storeListingId"
LEFT JOIN agent_graph_versions agv
ON sl.id = agv."storeListingId"
WHERE sl."isDeleted" = false
AND sl."hasApprovedVersion" = true;
COMMIT;

View File

@@ -734,11 +734,13 @@ view StoreAgent {
description String
categories String[]
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector"))
runs Int
rating Float
versions String[]
is_available Boolean @default(true)
useForOnboarding Boolean @default(false)
runs Int
rating Float
versions String[]
agentGraphVersions String[]
agentGraphId String
is_available Boolean @default(true)
useForOnboarding Boolean @default(false)
// Materialized views used (refreshed every 15 minutes via pg_cron):
// - mv_agent_run_counts - Pre-aggregated agent execution counts by agentGraphId

View File

@@ -23,8 +23,14 @@
"1.0.0",
"1.1.0"
],
"agentGraphVersions": [
"1",
"2"
],
"agentGraphId": "test-graph-id",
"last_updated": "2023-01-01T00:00:00",
"recommended_schedule_cron": null,
"active_version_id": null,
"has_approved_version": false
"has_approved_version": false,
"changelog": null
}

View File

@@ -102,7 +102,7 @@ export function ExpandableRow({
<TableRow>
<TableHead>Version</TableHead>
<TableHead>Status</TableHead>
{/* <TableHead>Changes</TableHead> */}
<TableHead>Changes</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Reviewed</TableHead>
<TableHead>External Comments</TableHead>
@@ -127,9 +127,9 @@ export function ExpandableRow({
)}
</TableCell>
<TableCell>{getStatusBadge(version.status)}</TableCell>
{/* <TableCell>
<TableCell>
{version.changes_summary || "No summary"}
</TableCell> */}
</TableCell>
<TableCell>
{version.date_submitted
? formatDistanceToNow(

View File

@@ -2,6 +2,7 @@ import { parseAsString, useQueryStates } from "nuqs";
import { AgentOutputs } from "./components/AgentOutputs/AgentOutputs";
import { RunGraph } from "./components/RunGraph/RunGraph";
import { ScheduleGraph } from "./components/ScheduleGraph/ScheduleGraph";
import { PublishToMarketplace } from "./components/PublishToMarketplace/PublishToMarketplace";
import { memo } from "react";
export const BuilderActions = memo(() => {
@@ -13,6 +14,7 @@ export const BuilderActions = memo(() => {
<AgentOutputs flowID={flowID} />
<RunGraph flowID={flowID} />
<ScheduleGraph flowID={flowID} />
<PublishToMarketplace flowID={flowID} />
</div>
);
});

View File

@@ -0,0 +1,36 @@
import { ShareIcon } from "@phosphor-icons/react";
import { BuilderActionButton } from "../BuilderActionButton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { usePublishToMarketplace } from "./usePublishToMarketplace";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
export const PublishToMarketplace = ({ flowID }: { flowID: string | null }) => {
const { handlePublishToMarketplace, publishState, handleStateChange } =
usePublishToMarketplace({ flowID });
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<BuilderActionButton
onClick={handlePublishToMarketplace}
disabled={!flowID}
>
<ShareIcon className="size-6 drop-shadow-sm" />
</BuilderActionButton>
</TooltipTrigger>
<TooltipContent>Publish to Marketplace</TooltipContent>
</Tooltip>
<PublishAgentModal
targetState={publishState}
onStateChange={handleStateChange}
preSelectedAgentId={flowID || undefined}
/>
</>
);
};

View File

@@ -0,0 +1,48 @@
import { useState, useCallback } from "react";
import type { StoreSubmission } from "@/app/api/__generated__/models/storeSubmission";
export type PublishStep = "select" | "info" | "review";
export type PublishState = {
isOpen: boolean;
step: PublishStep;
submissionData: StoreSubmission | null;
};
const defaultPublishState: PublishState = {
isOpen: false,
step: "select",
submissionData: null,
};
interface UsePublishToMarketplaceProps {
flowID: string | null;
}
export function usePublishToMarketplace({
flowID,
}: UsePublishToMarketplaceProps) {
const [publishState, setPublishState] =
useState<PublishState>(defaultPublishState);
const handlePublishToMarketplace = () => {
if (!flowID) return;
// Open the publish modal starting with the select step
setPublishState({
isOpen: true,
step: "select",
submissionData: null,
});
};
const handleStateChange = useCallback((newState: PublishState) => {
setPublishState(newState);
}, []);
return {
handlePublishToMarketplace,
publishState,
handleStateChange,
};
}

View File

@@ -5,8 +5,13 @@ import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { cn } from "@/lib/utils";
import { PlusIcon } from "@phosphor-icons/react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { RunAgentModal } from "./components/modals/RunAgentModal/RunAgentModal";
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { AgentVersionChangelog } from "./components/AgentVersionChangelog";
import { MarketplaceBanners } from "@/components/contextual/MarketplaceBanners/MarketplaceBanners";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import { AgentSettingsButton } from "./components/other/AgentSettingsButton";
import { AgentRunsLoading } from "./components/other/AgentRunsLoading";
import { EmptySchedules } from "./components/other/EmptySchedules";
import { EmptyTasks } from "./components/other/EmptyTasks";
@@ -16,9 +21,9 @@ import { SectionWrap } from "./components/other/SectionWrap";
import { LoadingSelectedContent } from "./components/selected-views/LoadingSelectedContent";
import { SelectedRunView } from "./components/selected-views/SelectedRunView/SelectedRunView";
import { SelectedScheduleView } from "./components/selected-views/SelectedScheduleView/SelectedScheduleView";
import { SelectedSettingsView } from "./components/selected-views/SelectedSettingsView/SelectedSettingsView";
import { SelectedTemplateView } from "./components/selected-views/SelectedTemplateView/SelectedTemplateView";
import { SelectedTriggerView } from "./components/selected-views/SelectedTriggerView/SelectedTriggerView";
import { SelectedSettingsView } from "./components/selected-views/SelectedSettingsView/SelectedSettingsView";
import { SelectedViewLayout } from "./components/selected-views/SelectedViewLayout";
import { SidebarRunsList } from "./components/sidebar/SidebarRunsList/SidebarRunsList";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "./helpers";
@@ -26,6 +31,7 @@ import { useNewAgentLibraryView } from "./useNewAgentLibraryView";
export function NewAgentLibraryView() {
const {
agentId,
agent,
ready,
activeTemplate,
@@ -39,18 +45,79 @@ export function NewAgentLibraryView() {
handleSelectRun,
handleCountsChange,
handleClearSelectedRun,
onRunInitiated,
handleSelectSettings,
onRunInitiated,
onTriggerSetup,
onScheduleCreated,
} = useNewAgentLibraryView();
const {
hasAgentMarketplaceUpdate,
hasMarketplaceUpdate,
latestMarketplaceVersion,
isUpdating,
modalOpen,
setModalOpen,
handlePublishUpdate,
handleUpdateToLatest,
} = useMarketplaceUpdate({ agent });
const [changelogOpen, setChangelogOpen] = useState(false);
useEffect(() => {
if (agent) {
document.title = `${agent.name} - Library - AutoGPT Platform`;
}
}, [agent]);
function renderMarketplaceUpdateBanner() {
return (
<MarketplaceBanners
hasUpdate={!!hasMarketplaceUpdate}
latestVersion={latestMarketplaceVersion}
hasUnpublishedChanges={!!hasAgentMarketplaceUpdate}
currentVersion={agent?.graph_version}
isUpdating={isUpdating}
onUpdate={handleUpdateToLatest}
onPublish={handlePublishUpdate}
onViewChanges={() => setChangelogOpen(true)}
/>
);
}
function renderPublishAgentModal() {
if (!modalOpen || !agent) return null;
return (
<PublishAgentModal
targetState={{
isOpen: true,
step: "info",
submissionData: { isMarketplaceUpdate: true } as any,
}}
preSelectedAgentId={agent.graph_id}
preSelectedAgentVersion={agent.graph_version}
onStateChange={(state) => {
if (!state.isOpen) {
setModalOpen(false);
}
}}
/>
);
}
function renderVersionChangelog() {
if (!agent) return null;
return (
<AgentVersionChangelog
agent={agent}
isOpen={changelogOpen}
onClose={() => setChangelogOpen(false)}
/>
);
}
if (error) {
return (
<ErrorCard
@@ -68,143 +135,160 @@ export function NewAgentLibraryView() {
if (!sidebarLoading && !hasAnyItems) {
return (
<div className="flex h-full flex-col">
<div className="mx-6 pt-4">
<div className="relative flex items-center gap-2">
<>
<div className="flex h-full flex-col">
<div className="mx-6 pt-4">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
{ name: agent.name },
{ name: agent.name, link: `/library/agents/${agentId}` },
]}
/>
</div>
<div className="flex min-h-0 flex-1">
<EmptyTasks
agent={agent}
onRun={onRunInitiated}
onTriggerSetup={onTriggerSetup}
onScheduleCreated={onScheduleCreated}
/>
</div>
</div>
<div className="flex min-h-0 flex-1">
<EmptyTasks
agent={agent}
onRun={onRunInitiated}
onTriggerSetup={onTriggerSetup}
onScheduleCreated={onScheduleCreated}
/>
</div>
</div>
{renderPublishAgentModal()}
{renderVersionChangelog()}
</>
);
}
return (
<div className="mx-4 grid h-full grid-cols-1 gap-0 pt-3 md:ml-4 md:mr-0 md:gap-4 lg:grid-cols-[25%_70%]">
<SectionWrap className="mb-3 block">
<div
className={cn(
"border-b border-zinc-100 pb-5",
AGENT_LIBRARY_SECTION_PADDING_X,
)}
>
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="large"
className="w-full"
disabled={isTemplateLoading && activeTab === "templates"}
>
<PlusIcon size={20} /> New task
</Button>
}
agent={agent}
onRunCreated={onRunInitiated}
onScheduleCreated={onScheduleCreated}
onTriggerSetup={onTriggerSetup}
initialInputValues={activeTemplate?.inputs}
initialInputCredentials={activeTemplate?.credentials}
/>
</div>
<>
<div className="mx-4 grid h-full grid-cols-1 gap-0 pt-3 md:ml-4 md:mr-0 md:gap-4 lg:grid-cols-[25%_70%]">
<SectionWrap className="mb-3 block">
<div
className={cn(
"border-b border-zinc-100 pb-5",
AGENT_LIBRARY_SECTION_PADDING_X,
)}
>
<div className="flex items-center gap-2">
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="large"
className="flex-1"
disabled={isTemplateLoading && activeTab === "templates"}
>
<PlusIcon size={20} /> New task
</Button>
}
agent={agent}
onRunCreated={onRunInitiated}
onScheduleCreated={onScheduleCreated}
onTriggerSetup={onTriggerSetup}
initialInputValues={activeTemplate?.inputs}
initialInputCredentials={activeTemplate?.credentials}
/>
<AgentSettingsButton
agent={agent}
onSelectSettings={handleSelectSettings}
selected={activeItem === "settings"}
/>
</div>
</div>
<SidebarRunsList
agent={agent}
selectedRunId={activeItem ?? undefined}
onSelectRun={handleSelectRun}
onClearSelectedRun={handleClearSelectedRun}
onTabChange={setActiveTab}
onCountsChange={handleCountsChange}
/>
</SectionWrap>
{activeItem ? (
activeItem === "settings" ? (
<SelectedSettingsView
<SidebarRunsList
agent={agent}
onClearSelectedRun={handleClearSelectedRun}
/>
) : activeTab === "scheduled" ? (
<SelectedScheduleView
agent={agent}
scheduleId={activeItem}
onClearSelectedRun={handleClearSelectedRun}
/>
) : activeTab === "templates" ? (
<SelectedTemplateView
agent={agent}
templateId={activeItem}
onClearSelectedRun={handleClearSelectedRun}
onRunCreated={(execution) => handleSelectRun(execution.id, "runs")}
onSwitchToRunsTab={() => setActiveTab("runs")}
/>
) : activeTab === "triggers" ? (
<SelectedTriggerView
agent={agent}
triggerId={activeItem}
onClearSelectedRun={handleClearSelectedRun}
onSwitchToRunsTab={() => setActiveTab("runs")}
/>
) : (
<SelectedRunView
agent={agent}
runId={activeItem}
selectedRunId={activeItem ?? undefined}
onSelectRun={handleSelectRun}
onClearSelectedRun={handleClearSelectedRun}
onSelectSettings={handleSelectSettings}
onTabChange={setActiveTab}
onCountsChange={handleCountsChange}
/>
)
) : sidebarLoading ? (
<LoadingSelectedContent
agent={agent}
onSelectSettings={handleSelectSettings}
/>
) : activeTab === "scheduled" ? (
<SelectedViewLayout
agent={agent}
onSelectSettings={handleSelectSettings}
>
<EmptySchedules />
</SelectedViewLayout>
) : activeTab === "templates" ? (
<SelectedViewLayout
agent={agent}
onSelectSettings={handleSelectSettings}
>
<EmptyTemplates />
</SelectedViewLayout>
) : activeTab === "triggers" ? (
<SelectedViewLayout
agent={agent}
onSelectSettings={handleSelectSettings}
>
<EmptyTriggers />
</SelectedViewLayout>
) : (
<SelectedViewLayout
agent={agent}
onSelectSettings={handleSelectSettings}
>
<EmptyTasks
</SectionWrap>
{activeItem ? (
activeItem === "settings" ? (
<SelectedSettingsView
agent={agent}
onClearSelectedRun={handleClearSelectedRun}
/>
) : activeTab === "scheduled" ? (
<SelectedScheduleView
agent={agent}
scheduleId={activeItem}
onClearSelectedRun={handleClearSelectedRun}
banner={renderMarketplaceUpdateBanner()}
/>
) : activeTab === "templates" ? (
<SelectedTemplateView
agent={agent}
templateId={activeItem}
onClearSelectedRun={handleClearSelectedRun}
onRunCreated={(execution) =>
handleSelectRun(execution.id, "runs")
}
onSwitchToRunsTab={() => setActiveTab("runs")}
banner={renderMarketplaceUpdateBanner()}
/>
) : activeTab === "triggers" ? (
<SelectedTriggerView
agent={agent}
triggerId={activeItem}
onClearSelectedRun={handleClearSelectedRun}
onSwitchToRunsTab={() => setActiveTab("runs")}
banner={renderMarketplaceUpdateBanner()}
/>
) : (
<SelectedRunView
agent={agent}
runId={activeItem}
onSelectRun={handleSelectRun}
onClearSelectedRun={handleClearSelectedRun}
banner={renderMarketplaceUpdateBanner()}
onSelectSettings={handleSelectSettings}
selectedSettings={activeItem === "settings"}
/>
)
) : sidebarLoading ? (
<LoadingSelectedContent agent={agent} />
) : activeTab === "scheduled" ? (
<SelectedViewLayout
agent={agent}
onRun={onRunInitiated}
onTriggerSetup={onTriggerSetup}
onScheduleCreated={onScheduleCreated}
/>
</SelectedViewLayout>
)}
</div>
banner={renderMarketplaceUpdateBanner()}
>
<EmptySchedules />
</SelectedViewLayout>
) : activeTab === "templates" ? (
<SelectedViewLayout
agent={agent}
banner={renderMarketplaceUpdateBanner()}
>
<EmptyTemplates />
</SelectedViewLayout>
) : activeTab === "triggers" ? (
<SelectedViewLayout
agent={agent}
banner={renderMarketplaceUpdateBanner()}
>
<EmptyTriggers />
</SelectedViewLayout>
) : (
<SelectedViewLayout
agent={agent}
banner={renderMarketplaceUpdateBanner()}
>
<EmptyTasks
agent={agent}
onRun={onRunInitiated}
onTriggerSetup={onTriggerSetup}
onScheduleCreated={onScheduleCreated}
/>
</SelectedViewLayout>
)}
</div>
{renderPublishAgentModal()}
{renderVersionChangelog()}
</>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { useGetV2GetSpecificAgent } from "@/app/api/__generated__/endpoints/store/store";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { okData } from "@/app/api/helpers";
import type { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
import React from "react";
interface AgentVersionChangelogProps {
agent: LibraryAgent;
isOpen: boolean;
onClose: () => void;
}
interface VersionInfo {
version: number;
isCurrentVersion: boolean;
}
export function AgentVersionChangelog({
agent,
isOpen,
onClose,
}: AgentVersionChangelogProps) {
// Get marketplace data if agent has marketplace listing
const { data: storeAgentData, isLoading } = useGetV2GetSpecificAgent(
agent?.marketplace_listing?.creator.slug || "",
agent?.marketplace_listing?.slug || "",
{},
{
query: {
enabled: !!(
agent?.marketplace_listing?.creator.slug &&
agent?.marketplace_listing?.slug
),
},
},
);
// Create version info from available graph versions
const storeData = okData(storeAgentData) as StoreAgentDetails | undefined;
const agentVersions: VersionInfo[] = storeData?.agentGraphVersions
? storeData.agentGraphVersions
.map((versionStr: string) => parseInt(versionStr, 10))
.sort((a: number, b: number) => b - a) // Sort descending (newest first)
.map((version: number) => ({
version,
isCurrentVersion: version === agent.graph_version,
}))
: [];
const renderVersionItem = (versionInfo: VersionInfo) => {
return (
<div
key={versionInfo.version}
className={`rounded-lg border p-4 ${
versionInfo.isCurrentVersion
? "border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950"
: "border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800"
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Text variant="body" className="font-semibold">
v{versionInfo.version}
</Text>
{versionInfo.isCurrentVersion && (
<span className="rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-100">
Current
</span>
)}
</div>
</div>
<Text
variant="small"
className="mt-1 text-neutral-600 dark:text-neutral-400"
>
Available marketplace version
</Text>
</div>
);
};
return (
<Dialog
title={`Version History - ${agent.name}`}
styling={{
maxWidth: "45rem",
}}
controlled={{
isOpen: isOpen,
set: (isOpen) => {
if (!isOpen) {
onClose();
}
},
}}
>
<Dialog.Content>
<div className="max-h-[70vh] overflow-y-auto">
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
) : agentVersions.length > 0 ? (
<div className="space-y-4">
<Text
variant="small"
className="text-neutral-600 dark:text-neutral-400"
>
View changes and updates across different versions of this
agent.
</Text>
{agentVersions.map(renderVersionItem)}
</div>
) : (
<div className="py-8 text-center">
<Text
variant="body"
className="text-neutral-600 dark:text-neutral-400"
>
No version history available for this agent.
</Text>
</div>
)}
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -6,9 +6,14 @@ import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
interface Props {
agent: LibraryAgent;
onSelectSettings: () => void;
selected?: boolean;
}
export function AgentSettingsButton({ agent, onSelectSettings }: Props) {
export function AgentSettingsButton({
agent,
onSelectSettings,
selected,
}: Props) {
const { hasHITLBlocks } = useAgentSafeMode(agent);
if (!hasHITLBlocks) {
@@ -17,13 +22,16 @@ export function AgentSettingsButton({ agent, onSelectSettings }: Props) {
return (
<Button
variant="ghost"
variant={selected ? "secondary" : "ghost"}
size="small"
className="m-0 min-w-0 rounded-full p-0 px-1"
onClick={onSelectSettings}
aria-label="Agent Settings"
>
<GearIcon size={18} className="text-zinc-600" />
<GearIcon
size={18}
className={selected ? "text-zinc-900" : "text-zinc-600"}
/>
</Button>
);
}

View File

@@ -32,6 +32,7 @@ interface Props {
runId: string;
onSelectRun?: (id: string) => void;
onClearSelectedRun?: () => void;
banner?: React.ReactNode;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
@@ -41,7 +42,9 @@ export function SelectedRunView({
runId,
onSelectRun,
onClearSelectedRun,
banner,
onSelectSettings,
selectedSettings,
}: Props) {
const { run, preset, isLoading, responseError, httpError } =
useSelectedRunView(agent.graph_id, runId);
@@ -81,7 +84,12 @@ export function SelectedRunView({
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agent={agent} onSelectSettings={onSelectSettings}>
<SelectedViewLayout
agent={agent}
banner={banner}
onSelectSettings={onSelectSettings}
selectedSettings={selectedSettings}
>
<div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={run} />
@@ -105,7 +113,7 @@ export function SelectedRunView({
)}
<ScrollableTabs
defaultValue="output"
defaultValue={withReviews ? "reviews" : "output"}
className="-mt-2 flex flex-col"
>
<ScrollableTabsList className="px-4">
@@ -130,20 +138,22 @@ export function SelectedRunView({
{/* Human-in-the-Loop Reviews Section */}
{withReviews && (
<ScrollableTabsContent value="reviews">
<div id="reviews" className="scroll-mt-4 px-4">
{reviewsLoading ? (
<LoadingSpinner size="small" />
) : pendingReviews.length > 0 ? (
<PendingReviewsList
reviews={pendingReviews}
onReviewComplete={refetchReviews}
emptyMessage="No pending reviews for this execution"
/>
) : (
<Text variant="body" className="text-zinc-600">
No pending reviews for this execution
</Text>
)}
<div className="scroll-mt-4">
<RunDetailCard>
{reviewsLoading ? (
<LoadingSpinner size="small" />
) : pendingReviews.length > 0 ? (
<PendingReviewsList
reviews={pendingReviews}
onReviewComplete={refetchReviews}
emptyMessage="No pending reviews for this execution"
/>
) : (
<Text variant="body" className="text-zinc-700">
No pending reviews for this execution
</Text>
)}
</RunDetailCard>
</div>
</ScrollableTabsContent>
)}

View File

@@ -15,7 +15,6 @@ import { SelectedActionsWrap } from "../../../SelectedActionsWrap";
import { ShareRunButton } from "../../../ShareRunButton/ShareRunButton";
import { CreateTemplateModal } from "../CreateTemplateModal/CreateTemplateModal";
import { useSelectedRunActions } from "./useSelectedRunActions";
import { SafeModeToggle } from "../SafeModeToggle";
type Props = {
agent: LibraryAgent;
@@ -113,7 +112,6 @@ export function SelectedRunActions({
shareToken={run.share_token}
/>
)}
<SafeModeToggle graph={agent} fullWidth={false} />
{canRunManually && (
<>
<Button

View File

@@ -20,6 +20,7 @@ interface Props {
agent: LibraryAgent;
scheduleId: string;
onClearSelectedRun?: () => void;
banner?: React.ReactNode;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
@@ -28,6 +29,7 @@ export function SelectedScheduleView({
agent,
scheduleId,
onClearSelectedRun,
banner,
onSelectSettings,
selectedSettings,
}: Props) {
@@ -76,6 +78,7 @@ export function SelectedScheduleView({
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout
agent={agent}
banner={banner}
onSelectSettings={onSelectSettings}
selectedSettings={selectedSettings}
>

View File

@@ -24,6 +24,7 @@ interface Props {
onClearSelectedRun?: () => void;
onRunCreated?: (execution: GraphExecutionMeta) => void;
onSwitchToRunsTab?: () => void;
banner?: React.ReactNode;
}
export function SelectedTemplateView({
@@ -32,6 +33,7 @@ export function SelectedTemplateView({
onClearSelectedRun,
onRunCreated,
onSwitchToRunsTab,
banner,
}: Props) {
const {
template,
@@ -100,7 +102,7 @@ export function SelectedTemplateView({
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agent={agent}>
<SelectedViewLayout agent={agent} banner={banner}>
<div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={undefined} />

View File

@@ -22,6 +22,7 @@ interface Props {
triggerId: string;
onClearSelectedRun?: () => void;
onSwitchToRunsTab?: () => void;
banner?: React.ReactNode;
}
export function SelectedTriggerView({
@@ -29,6 +30,7 @@ export function SelectedTriggerView({
triggerId,
onClearSelectedRun,
onSwitchToRunsTab,
banner,
}: Props) {
const {
trigger,
@@ -93,7 +95,7 @@ export function SelectedTriggerView({
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agent={agent}>
<SelectedViewLayout agent={agent} banner={banner}>
<div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={undefined} />

View File

@@ -7,6 +7,7 @@ import { SectionWrap } from "../other/SectionWrap";
interface Props {
agent: LibraryAgent;
children: React.ReactNode;
banner?: React.ReactNode;
additionalBreadcrumb?: { name: string; link?: string };
onSelectSettings?: () => void;
selectedSettings?: boolean;
@@ -18,13 +19,18 @@ export function SelectedViewLayout(props: Props) {
<div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} flex-shrink-0 border-b border-zinc-100 pb-0 lg:pb-4`}
>
{props.banner && <div className="mb-4">{props.banner}</div>}
<div className="relative flex w-fit items-center gap-2">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
{
name: props.agent.name,
link: `/library/agents/${props.agent.id}`,
},
...(props.additionalBreadcrumb
? [props.additionalBreadcrumb]
: []),
]}
/>
{props.agent && props.onSelectSettings && (
@@ -32,6 +38,7 @@ export function SelectedViewLayout(props: Props) {
<AgentSettingsButton
agent={props.agent}
onSelectSettings={props.onSelectSettings}
selected={props.selectedSettings}
/>
</div>
)}

View File

@@ -0,0 +1,163 @@
import {
useGetV2GetSpecificAgent,
useGetV2ListMySubmissions,
} from "@/app/api/__generated__/endpoints/store/store";
import {
usePatchV2UpdateLibraryAgent,
getGetV2GetLibraryAgentQueryKey,
} from "@/app/api/__generated__/endpoints/library/library";
import { useToast } from "@/components/molecules/Toast/use-toast";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useQueryClient } from "@tanstack/react-query";
import { useSupabaseStore } from "@/lib/supabase/hooks/useSupabaseStore";
import { okData } from "@/app/api/helpers";
import type { StoreSubmission } from "@/app/api/__generated__/models/storeSubmission";
import * as React from "react";
import { useState } from "react";
interface UseMarketplaceUpdateProps {
agent: LibraryAgent | null | undefined;
}
export function useMarketplaceUpdate({ agent }: UseMarketplaceUpdateProps) {
const [modalOpen, setModalOpen] = useState(false);
const { toast } = useToast();
const queryClient = useQueryClient();
const user = useSupabaseStore((state) => state.user);
// Get marketplace data if agent has marketplace listing
const { data: storeAgentData } = useGetV2GetSpecificAgent(
agent?.marketplace_listing?.creator.slug || "",
agent?.marketplace_listing?.slug || "",
{},
{
query: {
enabled: !!(
agent?.marketplace_listing?.creator.slug &&
agent?.marketplace_listing?.slug
),
},
},
);
// Get user's submissions to check for pending submissions
const { data: submissionsData } = useGetV2ListMySubmissions(
{ page: 1, page_size: 50 }, // Get enough to cover recent submissions
{
query: {
enabled: !!user?.id, // Only fetch if user is authenticated
},
},
);
const updateToLatestMutation = usePatchV2UpdateLibraryAgent({
mutation: {
onError: (err) => {
toast({
title: "Update Failed",
description: "Failed to update agent to latest version",
variant: "destructive",
});
console.error("Failed to update agent:", err);
},
onSuccess: () => {
toast({
title: "Agent Updated",
description: "Agent updated to latest version successfully",
});
// Invalidate to get the updated agent data from the server
if (agent?.id) {
queryClient.invalidateQueries({
queryKey: getGetV2GetLibraryAgentQueryKey(agent.id),
});
}
},
},
});
// Check if marketplace has a newer version than user's current version
const marketplaceUpdateInfo = React.useMemo(() => {
const storeAgent = okData(storeAgentData) as any;
if (!agent || !storeAgent) {
return {
hasUpdate: false,
latestVersion: undefined,
isUserCreator: false,
};
}
// Get the latest version from the marketplace
// agentGraphVersions array contains graph version numbers as strings, get the highest one
const latestMarketplaceVersion =
storeAgent.agentGraphVersions?.length > 0
? Math.max(
...storeAgent.agentGraphVersions.map((v: string) =>
parseInt(v, 10),
),
)
: undefined;
// Determine if the user is the creator of this agent
// Compare current user ID with the marketplace listing creator ID
const isUserCreator =
user?.id && agent.marketplace_listing?.creator.id === user.id;
// Check if there's a pending submission for this specific agent version
const submissionsResponse = okData(submissionsData) as any;
const hasPendingSubmissionForCurrentVersion =
isUserCreator &&
submissionsResponse?.submissions?.some(
(submission: StoreSubmission) =>
submission.agent_id === agent.graph_id &&
submission.agent_version === agent.graph_version &&
submission.status === "PENDING",
);
// If user is creator and their version is newer than marketplace, show publish update banner
// BUT only if there's no pending submission for this version
const hasPublishUpdate =
isUserCreator &&
!hasPendingSubmissionForCurrentVersion &&
latestMarketplaceVersion !== undefined &&
agent.graph_version > latestMarketplaceVersion;
// If marketplace version is newer than user's version, show update banner
// This applies to both creators and non-creators
const hasMarketplaceUpdate =
latestMarketplaceVersion !== undefined &&
latestMarketplaceVersion > agent.graph_version;
return {
hasUpdate: hasMarketplaceUpdate,
latestVersion: latestMarketplaceVersion,
isUserCreator,
hasPublishUpdate,
};
}, [agent, storeAgentData, user, submissionsData]);
const handlePublishUpdate = () => {
setModalOpen(true);
};
const handleUpdateToLatest = () => {
if (!agent || marketplaceUpdateInfo.latestVersion === undefined) return;
// Update to the specific marketplace version using the new graph_version parameter
updateToLatestMutation.mutate({
libraryAgentId: agent.id,
data: {
graph_version: marketplaceUpdateInfo.latestVersion,
},
});
};
return {
hasAgentMarketplaceUpdate: marketplaceUpdateInfo.hasPublishUpdate,
hasMarketplaceUpdate: marketplaceUpdateInfo.hasUpdate,
latestMarketplaceVersion: marketplaceUpdateInfo.latestVersion,
isUpdating: updateToLatestMutation.isPending,
modalOpen,
setModalOpen,
handlePublishUpdate,
handleUpdateToLatest,
};
}

View File

@@ -85,7 +85,10 @@ export function useNewAgentLibraryView() {
);
// Show sidebar layout while loading or when there are items or settings is selected
const showSidebarLayout = useEffect(() => {
const showSidebarLayout =
sidebarLoading || hasAnyItems || activeItem === "settings";
useEffect(() => {
if (agent) {
document.title = `${agent.name} - Library - AutoGPT Platform`;
}
@@ -204,8 +207,8 @@ export function useNewAgentLibraryView() {
handleClearSelectedRun,
handleCountsChange,
handleSelectRun,
onRunInitiated,
handleSelectSettings,
onRunInitiated,
onTriggerSetup,
onScheduleCreated,
};

View File

@@ -5,7 +5,14 @@ import { Separator } from "@/components/__legacy__/ui/separator";
import Link from "next/link";
import { User } from "@supabase/supabase-js";
import { cn } from "@/lib/utils";
import { okData } from "@/app/api/helpers";
import type { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
import type { ChangelogEntry } from "@/app/api/__generated__/models/changelogEntry";
import type { GetV2GetSpecificAgentParams } from "@/app/api/__generated__/models/getV2GetSpecificAgentParams";
import { useAgentInfo } from "./useAgentInfo";
import { useGetV2GetSpecificAgent } from "@/app/api/__generated__/endpoints/store/store";
import { Text } from "@/components/atoms/Text/Text";
import * as React from "react";
interface AgentInfoProps {
user: User | null;
@@ -21,6 +28,8 @@ interface AgentInfoProps {
version: string;
storeListingVersionId: string;
isAgentAddedToLibrary: boolean;
creatorSlug?: string;
agentSlug?: string;
}
export const AgentInfo = ({
@@ -37,6 +46,8 @@ export const AgentInfo = ({
version,
storeListingVersionId,
isAgentAddedToLibrary,
creatorSlug,
agentSlug,
}: AgentInfoProps) => {
const {
handleDownload,
@@ -45,6 +56,95 @@ export const AgentInfo = ({
isAddingAgentToLibrary,
} = useAgentInfo({ storeListingVersionId });
// State for expanding version list - start with 3, then show 3 more each time
const [visibleVersionCount, setVisibleVersionCount] = React.useState(3);
// Get store agent data for version history
const params: GetV2GetSpecificAgentParams = { include_changelog: true };
const { data: storeAgentData } = useGetV2GetSpecificAgent(
creatorSlug || "",
agentSlug || "",
params,
{
query: {
enabled: !!(creatorSlug && agentSlug),
},
},
);
// Calculate update information using simple helper functions
const storeData = okData(storeAgentData) as StoreAgentDetails | undefined;
// Process version data for display - use store listing versions (not agentGraphVersions)
const allVersions = storeData?.versions
? storeData.versions
.map((versionStr: string) => parseInt(versionStr, 10))
.sort((a: number, b: number) => b - a)
.map((versionNum: number) => ({
version: versionNum,
isCurrentVersion: false, // We'll update this logic if needed
}))
: [];
const agentVersions = allVersions.slice(0, visibleVersionCount);
const hasMoreVersions = allVersions.length > visibleVersionCount;
const renderVersionItem = (versionInfo: {
version: number;
isCurrentVersion: boolean;
}) => {
// Find real changelog data for this version
const changelogEntry = storeData?.changelog?.find(
(entry: ChangelogEntry) =>
entry.version === versionInfo.version.toString(),
);
return (
<div key={versionInfo.version} className="mb-6 last:mb-0">
{/* Version Header */}
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Text
variant="body"
className="font-semibold text-neutral-900 dark:text-neutral-100"
>
Version {versionInfo.version}.0
</Text>
{versionInfo.isCurrentVersion && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-100">
Current
</span>
)}
</div>
{changelogEntry && (
<Text
variant="small"
className="text-neutral-500 dark:text-neutral-400"
>
{new Date(changelogEntry.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</Text>
)}
</div>
{/* Real Changelog Content */}
{changelogEntry && (
<div className="space-y-2">
<Text
variant="body"
className="text-neutral-700 dark:text-neutral-300"
>
{changelogEntry.changes_summary}
</Text>
</div>
)}
</div>
);
};
return (
<div className="w-full max-w-[396px] px-4 sm:px-6 lg:w-[396px] lg:px-0">
{/* Title */}
@@ -158,17 +258,48 @@ export const AgentInfo = ({
</div>
</div>
{/* Version History */}
<div className="flex w-full flex-col gap-0.5 sm:gap-1">
{/* Changelog */}
<div className="flex w-full flex-col gap-1.5 sm:gap-2">
<div className="decoration-skip-ink-none mb-1.5 text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
Version history
Changelog
</div>
<div className="decoration-skip-ink-none text-base font-normal leading-6 text-neutral-600 underline-offset-[from-font] dark:text-neutral-400">
Last updated {lastUpdated}
</div>
<div className="text-xs text-neutral-600 dark:text-neutral-400 sm:text-sm">
Version {version}
</div>
{/* Version List */}
{agentVersions.length > 0 ? (
<div className="mt-4">
{agentVersions.map(renderVersionItem)}
{hasMoreVersions && (
<button
onClick={() => setVisibleVersionCount((prev) => prev + 3)}
className="mt-2 flex items-center gap-1 text-sm font-medium text-neutral-900 hover:text-neutral-700 dark:text-neutral-100 dark:hover:text-neutral-300"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
>
<path
d="M4 6l4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>Read more</span>
</button>
)}
</div>
) : (
<div className="text-xs text-neutral-600 dark:text-neutral-400 sm:text-sm">
Version {version}
</div>
)}
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { Separator } from "@/components/__legacy__/ui/separator";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { okData } from "@/app/api/helpers";
import { MarketplaceAgentPageParams } from "../../agent/[creator]/[slug]/page";
import { AgentImages } from "../AgentImages/AgentImage";
import { AgentInfo } from "../AgentInfo/AgentInfo";
@@ -10,24 +11,33 @@ import { AgentsSection } from "../AgentsSection/AgentsSection";
import { BecomeACreator } from "../BecomeACreator/BecomeACreator";
import { useMainAgentPage } from "./useMainAgentPage";
type MainAgentPageProps = {
interface Props {
params: MarketplaceAgentPageParams;
};
}
export const MainAgentPage = ({ params }: MainAgentPageProps) => {
export function MainAgentPage({ params }: Props) {
const {
agent,
otherAgents,
similarAgents,
libraryAgent,
user,
isLoading,
hasError,
user,
similarAgents,
otherAgents,
libraryAgent,
} = useMainAgentPage({ params });
if (isLoading) {
return <AgentPageLoading />;
return (
<div className="mx-auto w-full max-w-[1360px]">
<main className="px-4">
<div className="flex h-[600px] items-center justify-center">
<AgentPageLoading />
</div>
</main>
</div>
);
}
if (hasError) {
return (
<div className="mx-auto w-full max-w-[1360px]">
@@ -46,7 +56,8 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => {
);
}
if (!agent) {
const agentData = okData(agent);
if (!agentData) {
return (
<div className="mx-auto w-full max-w-[1360px]">
<main className="px-4">
@@ -55,8 +66,6 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => {
isSuccess={false}
responseError={{ message: "Agent not found" }}
context="agent page"
onRetry={() => window.location.reload()}
className="w-full max-w-md"
/>
</div>
</main>
@@ -67,10 +76,10 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => {
const breadcrumbs = [
{ name: "Marketplace", link: "/marketplace" },
{
name: agent.creator,
link: `/marketplace/creator/${encodeURIComponent(agent.creator)}`,
name: agentData.creator ?? "",
link: `/marketplace/creator/${encodeURIComponent(agentData.creator ?? "")}`,
},
{ name: agent.agent_name, link: "#" },
{ name: agentData.agent_name ?? "", link: "#" },
];
return (
@@ -82,18 +91,29 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => {
<div className="w-full md:w-auto md:shrink-0">
<AgentInfo
user={user}
agentId={agent.active_version_id ?? ""}
name={agent.agent_name}
creator={agent.creator}
shortDescription={agent.sub_heading}
longDescription={agent.description}
rating={agent.rating}
runs={agent.runs}
categories={agent.categories}
lastUpdated={agent.last_updated.toISOString()}
version={agent.versions[agent.versions.length - 1]}
storeListingVersionId={agent.store_listing_version_id}
agentId={agentData.active_version_id ?? ""}
name={agentData.agent_name ?? ""}
creator={agentData.creator ?? ""}
shortDescription={agentData.sub_heading ?? ""}
longDescription={agentData.description ?? ""}
rating={agentData.rating ?? 0}
runs={agentData.runs ?? 0}
categories={agentData.categories ?? []}
lastUpdated={
agentData.last_updated?.toISOString() ??
new Date().toISOString()
}
version={
agentData.versions
? Math.max(
...agentData.versions.map((v: string) => parseInt(v, 10)),
).toString()
: "1"
}
storeListingVersionId={agentData.store_listing_version_id ?? ""}
isAgentAddedToLibrary={Boolean(libraryAgent)}
creatorSlug={params.creator}
agentSlug={params.slug}
/>
</div>
<AgentImages
@@ -101,23 +121,23 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => {
const orderedImages: string[] = [];
// 1. YouTube/Overview video (if it exists)
if (agent.agent_video) {
orderedImages.push(agent.agent_video);
if (agentData.agent_video) {
orderedImages.push(agentData.agent_video);
}
// 2. First image (hero)
if (agent.agent_image.length > 0) {
orderedImages.push(agent.agent_image[0]);
if (agentData.agent_image?.length > 0) {
orderedImages.push(agentData.agent_image[0]);
}
// 3. Agent Output Demo (if it exists)
if ((agent as any).agent_output_demo) {
orderedImages.push((agent as any).agent_output_demo);
if (agentData.agent_output_demo) {
orderedImages.push(agentData.agent_output_demo);
}
// 4. Additional images
if (agent.agent_image.length > 1) {
orderedImages.push(...agent.agent_image.slice(1));
if (agentData.agent_image && agentData.agent_image.length > 1) {
orderedImages.push(...agentData.agent_image.slice(1));
}
return orderedImages;
@@ -129,7 +149,7 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => {
<AgentsSection
margin="32px"
agents={otherAgents.agents}
sectionTitle={`Other agents by ${agent.creator}`}
sectionTitle={`Other agents by ${agentData.creator ?? ""}`}
/>
)}
<Separator className="mb-[25px] mt-[60px]" />
@@ -140,13 +160,8 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => {
sectionTitle="Similar agents"
/>
)}
<Separator className="mb-[25px] mt-[60px]" />
<BecomeACreator
title="Become a Creator"
description="Join our ever-growing community of hackers and tinkerers"
buttonText="Become a Creator"
/>
<BecomeACreator />
</main>
</div>
);
};
}

View File

@@ -5,8 +5,8 @@ import {
import { MarketplaceAgentPageParams } from "../../agent/[creator]/[slug]/page";
import { useGetV2GetAgentByStoreId } from "@/app/api/__generated__/endpoints/library/library";
import { StoreAgentsResponse } from "@/app/api/__generated__/models/storeAgentsResponse";
import { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { okData } from "@/app/api/helpers";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
export const useMainAgentPage = ({
@@ -20,13 +20,7 @@ export const useMainAgentPage = ({
data: agent,
isLoading: isAgentLoading,
isError: isAgentError,
} = useGetV2GetSpecificAgent(creator_lower, params.slug, {
query: {
select: (x) => {
return x.data as StoreAgentDetails;
},
},
});
} = useGetV2GetSpecificAgent(creator_lower, params.slug);
const {
data: otherAgents,
isLoading: isOtherAgentsLoading,
@@ -59,12 +53,12 @@ export const useMainAgentPage = ({
data: libraryAgent,
isLoading: isLibraryAgentLoading,
isError: isLibraryAgentError,
} = useGetV2GetAgentByStoreId(agent?.active_version_id ?? "", {
} = useGetV2GetAgentByStoreId(okData(agent)?.active_version_id ?? "", {
query: {
select: (x) => {
return x.data as LibraryAgent;
},
enabled: !!user && !!agent?.active_version_id,
enabled: !!user && !!okData(agent)?.active_version_id,
},
});

View File

@@ -33,6 +33,7 @@ export interface AgentTableRowProps {
video_url?: string;
categories?: string[];
store_listing_version_id?: string;
changes_summary?: string;
onViewSubmission: (submission: StoreSubmission) => void;
onDeleteSubmission: (submission_id: string) => void;
onEditSubmission: (
@@ -58,6 +59,7 @@ export const AgentTableRow = ({
video_url,
categories,
store_listing_version_id,
changes_summary,
onViewSubmission,
onDeleteSubmission,
onEditSubmission,
@@ -80,6 +82,7 @@ export const AgentTableRow = ({
video_url,
categories,
store_listing_version_id,
changes_summary,
});
// Determine if we should show Edit or View button

View File

@@ -25,6 +25,7 @@ interface useAgentTableRowProps {
video_url?: string;
categories?: string[];
store_listing_version_id?: string;
changes_summary?: string;
}
export const useAgentTableRow = ({
@@ -44,6 +45,7 @@ export const useAgentTableRow = ({
video_url,
categories,
store_listing_version_id,
changes_summary,
}: useAgentTableRowProps) => {
const handleView = () => {
onViewSubmission({
@@ -72,7 +74,7 @@ export const useAgentTableRow = ({
image_urls: imageSrc,
video_url,
categories,
changes_summary: "Update Submission",
changes_summary: changes_summary || "Update Submission",
store_listing_version_id,
agent_id,
});

View File

@@ -98,6 +98,7 @@ export const MainDashboardPage = () => {
slug: submission.slug,
store_listing_version_id:
submission.store_listing_version_id || undefined,
changes_summary: submission.changes_summary || undefined,
}))}
onViewSubmission={onViewSubmission}
onDeleteSubmission={onDeleteSubmission}

View File

@@ -5113,6 +5113,16 @@
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Agent Name" }
},
{
"name": "include_changelog",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"default": false,
"title": "Include Changelog"
}
}
],
"responses": {
@@ -6510,6 +6520,16 @@
"required": ["file"],
"title": "Body_postV2Upload submission media"
},
"ChangelogEntry": {
"properties": {
"version": { "type": "string", "title": "Version" },
"changes_summary": { "type": "string", "title": "Changes Summary" },
"date": { "type": "string", "format": "date-time", "title": "Date" }
},
"type": "object",
"required": ["version", "changes_summary", "date"],
"title": "ChangelogEntry"
},
"ChatRequest": {
"properties": {
"query": { "type": "string", "title": "Query" },
@@ -7953,6 +7973,11 @@
"title": "Auto Update Version",
"description": "Auto-update the agent version"
},
"graph_version": {
"anyOf": [{ "type": "integer" }, { "type": "null" }],
"title": "Graph Version",
"description": "Specific graph version to update to"
},
"is_favorite": {
"anyOf": [{ "type": "boolean" }, { "type": "null" }],
"title": "Is Favorite",
@@ -9508,6 +9533,12 @@
"type": "array",
"title": "Versions"
},
"agentGraphVersions": {
"items": { "type": "string" },
"type": "array",
"title": "Agentgraphversions"
},
"agentGraphId": { "type": "string", "title": "Agentgraphid" },
"last_updated": {
"type": "string",
"format": "date-time",
@@ -9525,6 +9556,16 @@
"type": "boolean",
"title": "Has Approved Version",
"default": false
},
"changelog": {
"anyOf": [
{
"items": { "$ref": "#/components/schemas/ChangelogEntry" },
"type": "array"
},
{ "type": "null" }
],
"title": "Changelog"
}
},
"type": "object",
@@ -9543,6 +9584,8 @@
"runs",
"rating",
"versions",
"agentGraphVersions",
"agentGraphId",
"last_updated"
],
"title": "StoreAgentDetails"

View File

@@ -0,0 +1,102 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
interface MarketplaceBannersProps {
hasUpdate?: boolean;
latestVersion?: number;
hasUnpublishedChanges?: boolean;
currentVersion?: number;
isUpdating?: boolean;
onUpdate?: () => void;
onPublish?: () => void;
onViewChanges?: () => void;
}
export function MarketplaceBanners({
hasUpdate,
latestVersion,
hasUnpublishedChanges,
isUpdating,
onUpdate,
onPublish,
}: MarketplaceBannersProps) {
const renderUpdateBanner = () => {
if (hasUpdate && latestVersion) {
return (
<div className="mb-6 rounded-lg bg-gray-50 p-4 dark:bg-gray-900">
<div className="flex flex-col gap-3">
<div>
<Text
variant="large-medium"
className="mb-2 text-neutral-900 dark:text-neutral-100"
>
Update available
</Text>
<Text variant="body" className="text-gray-700 dark:text-gray-300">
You should update your agent in order to get the latest / best
results
</Text>
</div>
{onUpdate && (
<div className="flex justify-start">
<Button
size="small"
onClick={onUpdate}
disabled={isUpdating}
className="bg-neutral-800 text-white hover:bg-neutral-900 dark:bg-neutral-700 dark:hover:bg-neutral-800"
>
{isUpdating ? "Updating..." : "Update agent"}
</Button>
</div>
)}
</div>
</div>
);
}
return null;
};
const renderUnpublishedChangesBanner = () => {
if (hasUnpublishedChanges) {
return (
<div className="mb-6 rounded-lg bg-gray-50 p-4 dark:bg-gray-900">
<div className="flex flex-col gap-3">
<div>
<Text
variant="large-medium"
className="mb-2 text-neutral-900 dark:text-neutral-100"
>
Unpublished changes
</Text>
<Text variant="body" className="text-gray-700 dark:text-gray-300">
You&apos;ve made changes to this agent that aren&apos;t
published yet. Would you like to publish the latest version?
</Text>
</div>
{onPublish && (
<div className="flex justify-start">
<Button
size="small"
onClick={onPublish}
className="bg-neutral-800 text-white hover:bg-neutral-900 dark:bg-neutral-700 dark:hover:bg-neutral-800"
>
Publish changes
</Button>
</div>
)}
</div>
</div>
);
}
return null;
};
return (
<>
{renderUpdateBanner()}
{renderUnpublishedChangesBanner()}
</>
);
}

View File

@@ -18,6 +18,8 @@ export function PublishAgentModal({
trigger,
targetState,
onStateChange,
preSelectedAgentId,
preSelectedAgentVersion,
}: Props) {
const {
// State
@@ -34,7 +36,12 @@ export function PublishAgentModal({
handleGoToBuilder,
handleSuccessFromInfo,
handleBack,
} = usePublishAgentModal({ targetState, onStateChange });
} = usePublishAgentModal({
targetState,
onStateChange,
preSelectedAgentId,
preSelectedAgentVersion,
});
const { user, isUserLoading } = useSupabase();
@@ -65,6 +72,7 @@ export function PublishAgentModal({
selectedAgentId={selectedAgentId}
selectedAgentVersion={selectedAgentVersion}
initialData={initialData}
isMarketplaceUpdate={!!currentState.submissionData}
/>
);
case "review":

View File

@@ -19,6 +19,7 @@ export function AgentInfoStep({
selectedAgentId,
selectedAgentVersion,
initialData,
isMarketplaceUpdate,
}: Props) {
const {
form,
@@ -34,6 +35,7 @@ export function AgentInfoStep({
selectedAgentId,
selectedAgentVersion,
initialData,
isMarketplaceUpdate,
});
const [cronScheduleDialogOpen, setCronScheduleDialogOpen] =
@@ -65,6 +67,41 @@ export function AgentInfoStep({
<Form {...form}>
<form onSubmit={handleSubmit} className="flex-grow overflow-y-auto p-6">
{/* Changes summary field - only shown for updates */}
{isMarketplaceUpdate && (
<FormField
control={form.control}
name="changesSummary"
render={({ field }) => (
<div className="mb-6">
<Input
id={field.name}
label="What changed?"
type="textarea"
placeholder="Describe what's new or improved in this version..."
error={form.formState.errors.changesSummary?.message}
required
{...field}
/>
<Text variant="small" className="mt-1 text-gray-600">
This is required to help users understand what&apos;s
different in this update.
</Text>
</div>
)}
/>
)}
{/* Optional section label for updates */}
{isMarketplaceUpdate && (
<div className="mb-4">
<Text variant="body" className="font-medium text-gray-700">
Optional: Update any of the following details (or leave them
as-is)
</Text>
</div>
)}
<FormField
control={form.control}
name="title"

View File

@@ -25,6 +25,17 @@ export function useThumbnailImages({
const thumbnailsContainerRef = useRef<HTMLDivElement | null>(null);
const { toast } = useToast();
// Memoize the stringified version to detect actual changes
const initialImagesKey = JSON.stringify(initialImages);
// Update images when initialImages prop changes (by value, not reference)
useEffect(() => {
if (initialImages.length > 0) {
setImages(initialImages);
setSelectedImage(initialSelectedImage || initialImages[0]);
}
}, [initialImagesKey, initialSelectedImage]); // Use stringified key instead of array reference
// Notify parent when images change
useEffect(() => {
onImagesChange(images);

View File

@@ -1,45 +1,113 @@
import z from "zod";
import { validateYouTubeUrl } from "@/lib/utils";
export const publishAgentSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(100, "Title must be less than 100 characters"),
subheader: z
.string()
.min(1, "Subheader is required")
.max(200, "Subheader must be less than 200 characters"),
slug: z
.string()
.min(1, "Slug is required")
.max(50, "Slug must be less than 50 characters")
.regex(
/^[a-z0-9-]+$/,
"Slug can only contain lowercase letters, numbers, and hyphens",
),
youtubeLink: z
.string()
.refine(validateYouTubeUrl, "Please enter a valid YouTube URL"),
category: z.string().min(1, "Category is required"),
description: z
.string()
.min(1, "Description is required")
.max(1000, "Description must be less than 1000 characters"),
recommendedScheduleCron: z.string().optional(),
instructions: z
.string()
.optional()
.refine(
(val) => !val || val.length <= 2000,
"Instructions must be less than 2000 characters",
),
agentOutputDemo: z
.string()
.refine(validateYouTubeUrl, "Please enter a valid YouTube URL"),
});
// Create conditional schema that changes based on whether it's a marketplace update
export const publishAgentSchemaFactory = (
isMarketplaceUpdate: boolean = false,
) => {
const baseSchema = {
changesSummary: isMarketplaceUpdate
? z
.string()
.min(1, "Changes summary is required for updates")
.max(500, "Changes summary must be less than 500 characters")
: z.string().optional(),
title: isMarketplaceUpdate
? z
.string()
.optional()
.refine(
(val) => !val || val.length <= 100,
"Title must be less than 100 characters",
)
: z
.string()
.min(1, "Title is required")
.max(100, "Title must be less than 100 characters"),
subheader: isMarketplaceUpdate
? z
.string()
.optional()
.refine(
(val) => !val || val.length <= 200,
"Subheader must be less than 200 characters",
)
: z
.string()
.min(1, "Subheader is required")
.max(200, "Subheader must be less than 200 characters"),
slug: isMarketplaceUpdate
? z
.string()
.optional()
.refine(
(val) => !val || (val.length <= 50 && /^[a-z0-9-]+$/.test(val)),
"Slug can only contain lowercase letters, numbers, and hyphens",
)
: z
.string()
.min(1, "Slug is required")
.max(50, "Slug must be less than 50 characters")
.regex(
/^[a-z0-9-]+$/,
"Slug can only contain lowercase letters, numbers, and hyphens",
),
youtubeLink: isMarketplaceUpdate
? z
.string()
.optional()
.refine(
(val) => !val || validateYouTubeUrl(val),
"Please enter a valid YouTube URL",
)
: z
.string()
.refine(validateYouTubeUrl, "Please enter a valid YouTube URL"),
category: isMarketplaceUpdate
? z.string().optional()
: z.string().min(1, "Category is required"),
description: isMarketplaceUpdate
? z
.string()
.optional()
.refine(
(val) => !val || val.length <= 1000,
"Description must be less than 1000 characters",
)
: z
.string()
.min(1, "Description is required")
.max(1000, "Description must be less than 1000 characters"),
recommendedScheduleCron: z.string().optional(),
instructions: z
.string()
.optional()
.refine(
(val) => !val || val.length <= 2000,
"Instructions must be less than 2000 characters",
),
agentOutputDemo: isMarketplaceUpdate
? z
.string()
.optional()
.refine(
(val) => !val || validateYouTubeUrl(val),
"Please enter a valid YouTube URL",
)
: z
.string()
.refine(validateYouTubeUrl, "Please enter a valid YouTube URL"),
};
export type PublishAgentFormData = z.infer<typeof publishAgentSchema>;
return z.object(baseSchema);
};
// Default schema for backwards compatibility
export const publishAgentSchema = publishAgentSchemaFactory(false);
export type PublishAgentFormData = z.infer<
ReturnType<typeof publishAgentSchemaFactory>
>;
export interface PublishAgentInfoInitialData {
agent_id: string;
@@ -54,4 +122,5 @@ export interface PublishAgentInfoInitialData {
recommendedScheduleCron?: string;
instructions?: string;
agentOutputDemo?: string;
changesSummary?: string;
}

View File

@@ -9,7 +9,7 @@ import * as Sentry from "@sentry/nextjs";
import {
PublishAgentFormData,
PublishAgentInfoInitialData,
publishAgentSchema,
publishAgentSchemaFactory,
} from "./helpers";
export interface Props {
@@ -18,6 +18,7 @@ export interface Props {
selectedAgentId: string | null;
selectedAgentVersion: number | null;
initialData?: PublishAgentInfoInitialData;
isMarketplaceUpdate?: boolean;
}
export function useAgentInfoStep({
@@ -26,6 +27,7 @@ export function useAgentInfoStep({
selectedAgentId,
selectedAgentVersion,
initialData,
isMarketplaceUpdate = false,
}: Props) {
const [agentId, setAgentId] = useState<string | null>(null);
const [images, setImages] = useState<string[]>([]);
@@ -36,8 +38,9 @@ export function useAgentInfoStep({
const api = useBackendAPI();
const form = useForm<PublishAgentFormData>({
resolver: zodResolver(publishAgentSchema),
resolver: zodResolver(publishAgentSchemaFactory(isMarketplaceUpdate)),
defaultValues: {
changesSummary: "",
title: "",
subheader: "",
slug: "",
@@ -61,6 +64,7 @@ export function useAgentInfoStep({
// Update form with initial data
form.reset({
changesSummary: initialData.changesSummary || "",
title: initialData.title,
subheader: initialData.subheader,
slug: initialData.slug.toLocaleLowerCase().trim(),
@@ -104,9 +108,10 @@ export function useAgentInfoStep({
agent_output_demo_url: data.agentOutputDemo || "",
agent_id: selectedAgentId || "",
agent_version: selectedAgentVersion || 0,
slug: data.slug.replace(/\s+/g, "-"),
slug: (data.slug || "").replace(/\s+/g, "-"),
categories: filteredCategories,
recommended_schedule_cron: data.recommendedScheduleCron || null,
changes_summary: data.changesSummary || null,
} as any);
await queryClient.invalidateQueries({

View File

@@ -52,7 +52,7 @@ export function AgentReviewStep({
</Text>
<Text
variant="large"
className="line-clamp-1 text-ellipsis text-center !text-neutral-500"
className="line-clamp-1 text-ellipsis text-center text-neutral-500"
>
{subheader}
</Text>
@@ -80,7 +80,7 @@ export function AgentReviewStep({
{description ? (
<Text
variant="large"
className="line-clamp-1 text-ellipsis pt-2 text-center !text-neutral-500"
className="line-clamp-1 text-ellipsis pt-2 text-center text-neutral-500"
>
{description}
</Text>

View File

@@ -8,4 +8,8 @@ export const emptyModalState = {
category: "",
description: "",
recommendedScheduleCron: "",
instructions: "",
agentOutputDemo: "",
changesSummary: "",
additionalImages: [],
};

View File

@@ -3,6 +3,12 @@ import { useCallback, useEffect, useState } from "react";
import { PublishAgentInfoInitialData } from "./components/AgentInfoStep/helpers";
import { useRouter } from "next/navigation";
import { emptyModalState } from "./helpers";
import {
useGetV2GetMyAgents,
useGetV2ListMySubmissions,
} from "@/app/api/__generated__/endpoints/store/store";
import { okData } from "@/app/api/helpers";
import type { MyAgent } from "@/app/api/__generated__/models/myAgent";
const defaultTargetState: PublishState = {
isOpen: false,
@@ -22,9 +28,16 @@ export interface Props {
trigger?: React.ReactNode;
targetState?: PublishState;
onStateChange?: (state: PublishState) => void;
preSelectedAgentId?: string;
preSelectedAgentVersion?: number;
}
export function usePublishAgentModal({ targetState, onStateChange }: Props) {
export function usePublishAgentModal({
targetState,
onStateChange,
preSelectedAgentId,
preSelectedAgentVersion,
}: Props) {
const [currentState, setCurrentState] = useState<PublishState>(
targetState || defaultTargetState,
);
@@ -42,14 +55,20 @@ export function usePublishAgentModal({ targetState, onStateChange }: Props) {
const [_, setSelectedAgent] = useState<string | null>(null);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(
preSelectedAgentId || null,
);
const [selectedAgentVersion, setSelectedAgentVersion] = useState<
number | null
>(null);
>(preSelectedAgentVersion || null);
const router = useRouter();
// Fetch agent data for pre-populating form when agent is pre-selected
const { data: myAgents } = useGetV2GetMyAgents();
const { data: mySubmissions } = useGetV2ListMySubmissions();
// Sync currentState with targetState when it changes from outside
useEffect(() => {
if (targetState) {
@@ -60,13 +79,90 @@ export function usePublishAgentModal({ targetState, onStateChange }: Props) {
// Reset internal state when modal opens
useEffect(() => {
if (!targetState) return;
if (targetState.isOpen && targetState.step === "select") {
if (targetState.isOpen) {
setSelectedAgent(null);
setSelectedAgentId(null);
setSelectedAgentVersion(null);
setSelectedAgentId(preSelectedAgentId || null);
setSelectedAgentVersion(preSelectedAgentVersion || null);
setInitialData(emptyModalState);
}
}, [targetState]);
}, [targetState, preSelectedAgentId, preSelectedAgentVersion]);
// Pre-populate form data when modal opens with info step and pre-selected agent
useEffect(() => {
if (
!targetState?.isOpen ||
targetState.step !== "info" ||
!preSelectedAgentId ||
!preSelectedAgentVersion
)
return;
const agentsData = okData(myAgents) as any;
const submissionsData = okData(mySubmissions) as any;
if (!agentsData || !submissionsData) return;
// Find the agent data
const agent = agentsData.agents?.find(
(a: MyAgent) => a.agent_id === preSelectedAgentId,
);
if (!agent) return;
// Find published submission data for this agent (for updates)
const publishedSubmissionData = submissionsData.submissions
?.filter(
(s: StoreSubmission) =>
s.status === "APPROVED" && s.agent_id === preSelectedAgentId,
)
.sort(
(a: StoreSubmission, b: StoreSubmission) =>
b.agent_version - a.agent_version,
)[0];
// Populate initial data (same logic as handleNextFromSelect)
const initialFormData: PublishAgentInfoInitialData = publishedSubmissionData
? {
agent_id: preSelectedAgentId,
title: publishedSubmissionData.name,
subheader: publishedSubmissionData.sub_heading || "",
description: publishedSubmissionData.description,
instructions: publishedSubmissionData.instructions || "",
youtubeLink: publishedSubmissionData.video_url || "",
agentOutputDemo: publishedSubmissionData.agent_output_demo_url || "",
additionalImages: [
...new Set(publishedSubmissionData.image_urls || []),
].filter(Boolean) as string[],
category: publishedSubmissionData.categories?.[0] || "",
thumbnailSrc: agent.agent_image || "https://picsum.photos/300/200",
slug: publishedSubmissionData.slug,
recommendedScheduleCron: agent.recommended_schedule_cron || "",
changesSummary: publishedSubmissionData.changes_summary || "",
}
: {
...emptyModalState,
agent_id: preSelectedAgentId,
title: agent.agent_name,
description: agent.description || "",
thumbnailSrc: agent.agent_image || "https://picsum.photos/300/200",
slug: agent.agent_name.replace(/ /g, "-"),
recommendedScheduleCron: agent.recommended_schedule_cron || "",
};
setInitialData(initialFormData);
// Update the state with the submission data if this is an update
if (publishedSubmissionData) {
setCurrentState((prevState) => ({
...prevState,
submissionData: publishedSubmissionData,
}));
}
}, [
targetState,
preSelectedAgentId,
preSelectedAgentVersion,
myAgents,
mySubmissions,
]);
function handleClose() {
// Reset all internal state
@@ -97,20 +193,43 @@ export function usePublishAgentModal({ targetState, onStateChange }: Props) {
imageSrc: string;
recommendedScheduleCron: string | null;
},
publishedSubmissionData?: StoreSubmission | null,
) {
setInitialData({
...emptyModalState,
agent_id: agentId,
title: agentData.name,
description: agentData.description,
thumbnailSrc: agentData.imageSrc,
slug: agentData.name.replace(/ /g, "-"),
recommendedScheduleCron: agentData.recommendedScheduleCron || "",
});
// Pre-populate with published data if this is an update, otherwise use agent data
const initialFormData: PublishAgentInfoInitialData = publishedSubmissionData
? {
agent_id: agentId,
title: publishedSubmissionData.name,
subheader: publishedSubmissionData.sub_heading || "",
description: publishedSubmissionData.description,
instructions: publishedSubmissionData.instructions || "",
youtubeLink: publishedSubmissionData.video_url || "",
agentOutputDemo: publishedSubmissionData.agent_output_demo_url || "",
additionalImages: [
...new Set(publishedSubmissionData.image_urls || []),
].filter(Boolean) as string[],
category: publishedSubmissionData.categories?.[0] || "", // Take first category
thumbnailSrc: agentData.imageSrc, // Use current agent image
slug: publishedSubmissionData.slug,
recommendedScheduleCron: agentData.recommendedScheduleCron || "",
changesSummary: publishedSubmissionData.changes_summary || "", // Pre-populate with existing changes summary
}
: {
...emptyModalState,
agent_id: agentId,
title: agentData.name,
description: agentData.description,
thumbnailSrc: agentData.imageSrc,
slug: agentData.name.replace(/ /g, "-"),
recommendedScheduleCron: agentData.recommendedScheduleCron || "",
};
setInitialData(initialFormData);
updateState({
...currentState,
step: "info",
submissionData: publishedSubmissionData || null,
});
setSelectedAgentId(agentId);

View File

@@ -0,0 +1,57 @@
/**
* Marketplace-specific helper functions that can be reused across different marketplace screens
*/
/**
* Calculate the latest marketplace version from agent graph versions
*/
export function getLatestMarketplaceVersion(
agentGraphVersions?: string[],
): number | undefined {
if (!agentGraphVersions?.length) return undefined;
return Math.max(...agentGraphVersions.map((v: string) => parseInt(v, 10)));
}
/**
* Check if the current user is the creator of the agent
* Uses ID-based comparison for accurate matching
*/
export function isUserCreator(
creatorId: string | undefined,
currentUserId: string | undefined,
): boolean {
if (!creatorId || !currentUserId) return false;
return creatorId === currentUserId;
}
/**
* Calculate update status for an agent
*/
export function calculateUpdateStatus({
latestMarketplaceVersion,
currentVersion,
isUserCreator,
isAgentAddedToLibrary,
}: {
latestMarketplaceVersion?: number;
currentVersion: number;
isUserCreator: boolean;
isAgentAddedToLibrary: boolean;
}) {
if (!latestMarketplaceVersion) {
return { hasUpdate: false, hasUnpublishedChanges: false };
}
const hasUnpublishedChanges =
isUserCreator &&
isAgentAddedToLibrary &&
currentVersion > latestMarketplaceVersion;
const hasUpdate =
isAgentAddedToLibrary &&
!isUserCreator &&
latestMarketplaceVersion > currentVersion;
return { hasUpdate, hasUnpublishedChanges };
}