feat(platform): add Agent Output Demo field to marketplace submission form (#11538)

## Summary
- Add Agent Output Demo field to marketplace agent submission form,
positioned below the Description field
- Store agent output demo URLs in database for future CoPilot
integration
- Implement proper video/image ordering on marketplace pages
- Add shared YouTube URL validation utility to eliminate code
duplication

## Changes Made

### Frontend
- **Agent submission form**: Added Agent Output Demo field with YouTube
URL validation
- **Edit agent form**: Added Agent Output Demo field for existing
submissions
- **Marketplace display**: Implemented proper video/image ordering:
  1. YouTube/Overview video (if exists)
  2. First image (hero)
  3. Agent Output Demo (if exists) 
  4. Additional images
- **Shared utilities**: Created `validateYouTubeUrl` function in
`src/lib/utils.ts`

### Backend
- **Database schema**: Added `agentOutputDemoUrl` field to
`StoreListingVersion` model
- **Database views**: Updated `StoreAgent` view to include
`agent_output_demo` field
- **API models**: Added `agent_output_demo_url` to submission requests
and `agent_output_demo` to responses
- **Database migration**: Added migration to create new column and
update view
- **Test files**: Updated all test files to include the new required
field

## Test Plan
- [x] Frontend form validation works correctly for YouTube URLs
- [x] Database migration applies successfully 
- [x] Backend API accepts and returns the new field
- [x] Marketplace displays videos in correct order
- [x] Both frontend and backend formatting/linting pass
- [x] All test files include required field to prevent failures

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Zamil Majdy
2025-12-05 18:40:12 +07:00
committed by GitHub
parent 8e476c3f8d
commit e4d0dbc283
17 changed files with 188 additions and 49 deletions

View File

@@ -432,6 +432,7 @@ async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]:
slug=agent.slug,
agent_name=agent.agent_name,
agent_video=agent.agent_video or "",
agent_output_demo=agent.agent_output_demo or "",
agent_image=agent.agent_image,
creator=agent.creator_username,
creator_avatar=agent.creator_avatar,

View File

@@ -327,6 +327,7 @@ async def get_store_agent_details(
slug=agent.slug,
agent_name=agent.agent_name,
agent_video=agent.agent_video or "",
agent_output_demo=agent.agent_output_demo or "",
agent_image=agent.agent_image,
creator=agent.creator_username or "",
creator_avatar=agent.creator_avatar or "",
@@ -397,6 +398,7 @@ async def get_store_agent_by_version_id(
slug=agent.slug,
agent_name=agent.agent_name,
agent_video=agent.agent_video or "",
agent_output_demo=agent.agent_output_demo or "",
agent_image=agent.agent_image,
creator=agent.creator_username or "",
creator_avatar=agent.creator_avatar or "",
@@ -683,6 +685,7 @@ async def create_store_submission(
slug: str,
name: str,
video_url: str | None = None,
agent_output_demo_url: str | None = None,
image_urls: list[str] = [],
description: str = "",
instructions: str | None = None,
@@ -777,6 +780,7 @@ async def create_store_submission(
agentGraphVersion=agent_version,
name=name,
videoUrl=video_url,
agentOutputDemoUrl=agent_output_demo_url,
imageUrls=image_urls,
description=description,
instructions=instructions,
@@ -849,6 +853,7 @@ async def edit_store_submission(
store_listing_version_id: str,
name: str,
video_url: str | None = None,
agent_output_demo_url: str | None = None,
image_urls: list[str] = [],
description: str = "",
sub_heading: str = "",
@@ -930,6 +935,7 @@ async def edit_store_submission(
store_listing_id=current_version.storeListingId,
name=name,
video_url=video_url,
agent_output_demo_url=agent_output_demo_url,
image_urls=image_urls,
description=description,
sub_heading=sub_heading,
@@ -947,6 +953,7 @@ async def edit_store_submission(
data=prisma.types.StoreListingVersionUpdateInput(
name=name,
videoUrl=video_url,
agentOutputDemoUrl=agent_output_demo_url,
imageUrls=image_urls,
description=description,
categories=categories,
@@ -1008,6 +1015,7 @@ async def create_store_version(
store_listing_id: str,
name: str,
video_url: str | None = None,
agent_output_demo_url: str | None = None,
image_urls: list[str] = [],
description: str = "",
instructions: str | None = None,
@@ -1077,6 +1085,7 @@ async def create_store_version(
agentGraphVersion=agent_version,
name=name,
videoUrl=video_url,
agentOutputDemoUrl=agent_output_demo_url,
imageUrls=image_urls,
description=description,
instructions=instructions,

View File

@@ -44,6 +44,7 @@ class StoreAgentDetails(pydantic.BaseModel):
slug: str
agent_name: str
agent_video: str
agent_output_demo: str
agent_image: list[str]
creator: str
creator_avatar: str
@@ -121,6 +122,7 @@ class StoreSubmission(pydantic.BaseModel):
# Additional fields for editing
video_url: str | None = None
agent_output_demo_url: str | None = None
categories: list[str] = []
@@ -157,6 +159,7 @@ class StoreSubmissionRequest(pydantic.BaseModel):
name: str
sub_heading: str
video_url: str | None = None
agent_output_demo_url: str | None = None
image_urls: list[str] = []
description: str = ""
instructions: str | None = None
@@ -169,6 +172,7 @@ class StoreSubmissionEditRequest(pydantic.BaseModel):
name: str
sub_heading: str
video_url: str | None = None
agent_output_demo_url: str | None = None
image_urls: list[str] = []
description: str = ""
instructions: str | None = None

View File

@@ -62,6 +62,7 @@ def test_store_agent_details():
slug="test-agent",
agent_name="Test Agent",
agent_video="video.mp4",
agent_output_demo="demo.mp4",
agent_image=["image1.jpg", "image2.jpg"],
creator="creator1",
creator_avatar="avatar.jpg",

View File

@@ -438,6 +438,7 @@ async def create_submission(
slug=submission_request.slug,
name=submission_request.name,
video_url=submission_request.video_url,
agent_output_demo_url=submission_request.agent_output_demo_url,
image_urls=submission_request.image_urls,
description=submission_request.description,
instructions=submission_request.instructions,
@@ -481,6 +482,7 @@ async def edit_submission(
store_listing_version_id=store_listing_version_id,
name=submission_request.name,
video_url=submission_request.video_url,
agent_output_demo_url=submission_request.agent_output_demo_url,
image_urls=submission_request.image_urls,
description=submission_request.description,
instructions=submission_request.instructions,

View File

@@ -378,6 +378,7 @@ def test_get_agent_details(
slug="test-agent",
agent_name="Test Agent",
agent_video="video.mp4",
agent_output_demo="demo.mp4",
agent_image=["image1.jpg", "image2.jpg"],
creator="creator1",
creator_avatar="avatar1.jpg",

View File

@@ -0,0 +1,64 @@
-- AlterTable
ALTER TABLE "StoreListingVersion" ADD COLUMN "agentOutputDemoUrl" TEXT;
-- Drop and recreate the StoreAgent view with agentOutputDemoUrl 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"
)
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,
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"
WHERE sl."isDeleted" = false
AND sl."hasApprovedVersion" = true;

View File

@@ -701,10 +701,11 @@ view StoreAgent {
storeListingVersionId String
updated_at DateTime
slug String
agent_name String
agent_video String?
agent_image String[]
slug String
agent_name String
agent_video String?
agent_output_demo String?
agent_image String[]
featured Boolean @default(false)
creator_username String?
@@ -833,13 +834,14 @@ model StoreListingVersion {
AgentGraph AgentGraph @relation(fields: [agentGraphId, agentGraphVersion], references: [id, version])
// Content fields
name String
subHeading String
videoUrl String?
imageUrls String[]
description String
instructions String?
categories String[]
name String
subHeading String
videoUrl String?
agentOutputDemoUrl String?
imageUrls String[]
description String
instructions String?
categories String[]
isFeatured Boolean @default(false)

View File

@@ -3,6 +3,7 @@
"slug": "test-agent",
"agent_name": "Test Agent",
"agent_video": "video.mp4",
"agent_output_demo": "demo.mp4",
"agent_image": [
"image1.jpg",
"image2.jpg"

View File

@@ -23,6 +23,7 @@
"reviewed_at": null,
"changes_summary": null,
"video_url": "test.mp4",
"agent_output_demo_url": null,
"categories": [
"test-category"
]

View File

@@ -97,11 +97,31 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => {
/>
</div>
<AgentImages
images={
agent.agent_video
? [agent.agent_video, ...agent.agent_image]
: agent.agent_image
}
images={(() => {
const orderedImages: string[] = [];
// 1. YouTube/Overview video (if it exists)
if (agent.agent_video) {
orderedImages.push(agent.agent_video);
}
// 2. First image (hero)
if (agent.agent_image.length > 0) {
orderedImages.push(agent.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);
}
// 4. Additional images
if (agent.agent_image.length > 1) {
orderedImages.push(...agent.agent_image.slice(1));
}
return orderedImages;
})()}
/>
</div>
<Separator className="mb-[25px] mt-[60px]" />

View File

@@ -8685,6 +8685,10 @@
"slug": { "type": "string", "title": "Slug" },
"agent_name": { "type": "string", "title": "Agent Name" },
"agent_video": { "type": "string", "title": "Agent Video" },
"agent_output_demo": {
"type": "string",
"title": "Agent Output Demo"
},
"agent_image": {
"items": { "type": "string" },
"type": "array",
@@ -8735,6 +8739,7 @@
"slug",
"agent_name",
"agent_video",
"agent_output_demo",
"agent_image",
"creator",
"creator_avatar",
@@ -8902,6 +8907,10 @@
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Video Url"
},
"agent_output_demo_url": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Agent Output Demo Url"
},
"categories": {
"items": { "type": "string" },
"type": "array",
@@ -8933,6 +8942,10 @@
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Video Url"
},
"agent_output_demo_url": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Agent Output Demo Url"
},
"image_urls": {
"items": { "type": "string" },
"type": "array",
@@ -8978,6 +8991,10 @@
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Video Url"
},
"agent_output_demo_url": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Agent Output Demo Url"
},
"image_urls": {
"items": { "type": "string" },
"type": "array",

View File

@@ -6,6 +6,7 @@ import {
import { StoreSubmission } from "@/app/api/__generated__/models/storeSubmission";
import { StoreSubmissionEditRequest } from "@/app/api/__generated__/models/storeSubmissionEditRequest";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { validateYouTubeUrl } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import React from "react";
@@ -35,22 +36,7 @@ export const useEditAgentForm = ({
.max(200, "Subheader must be less than 200 characters"),
youtubeLink: z
.string()
.optional()
.refine((val) => {
if (!val) return true;
try {
const url = new URL(val);
const allowedHosts = [
"youtube.com",
"www.youtube.com",
"youtu.be",
"www.youtu.be",
];
return allowedHosts.includes(url.hostname);
} catch {
return false;
}
}, "Please enter a valid YouTube URL"),
.refine(validateYouTubeUrl, "Please enter a valid YouTube URL"),
category: z.string().min(1, "Category is required"),
description: z
.string()
@@ -60,6 +46,9 @@ export const useEditAgentForm = ({
.string()
.min(1, "Changes summary is required")
.max(200, "Changes summary must be less than 200 characters"),
agentOutputDemo: z
.string()
.refine(validateYouTubeUrl, "Please enter a valid YouTube URL"),
});
type EditAgentFormData = z.infer<typeof editAgentSchema>;
@@ -91,6 +80,7 @@ export const useEditAgentForm = ({
category: submission.categories?.[0] || "",
description: submission.description,
changes_summary: submission.changes_summary || "",
agentOutputDemo: submission.agent_output_demo_url || "",
},
});
@@ -134,6 +124,7 @@ export const useEditAgentForm = ({
description: data.description,
image_urls: images,
video_url: data.youtubeLink || "",
agent_output_demo_url: data.agentOutputDemo || "",
categories: filteredCategories,
changes_summary: data.changes_summary,
},

View File

@@ -163,6 +163,21 @@ export function AgentInfoStep({
)}
/>
<FormField
control={form.control}
name="agentOutputDemo"
render={({ field }) => (
<Input
id={field.name}
label="Agent Output Demo"
type="url"
placeholder="Add a short video showing the agent's results in action."
error={form.formState.errors.agentOutputDemo?.message}
{...field}
/>
)}
/>
<FormField
control={form.control}
name="instructions"

View File

@@ -1,4 +1,5 @@
import z from "zod";
import { validateYouTubeUrl } from "@/lib/utils";
export const publishAgentSchema = z.object({
title: z
@@ -19,22 +20,7 @@ export const publishAgentSchema = z.object({
),
youtubeLink: z
.string()
.optional()
.refine((val) => {
if (!val) return true;
try {
const url = new URL(val);
const allowedHosts = [
"youtube.com",
"www.youtube.com",
"youtu.be",
"www.youtu.be",
];
return allowedHosts.includes(url.hostname);
} catch {
return false;
}
}, "Please enter a valid YouTube URL"),
.refine(validateYouTubeUrl, "Please enter a valid YouTube URL"),
category: z.string().min(1, "Category is required"),
description: z
.string()
@@ -48,6 +34,9 @@ export const publishAgentSchema = z.object({
(val) => !val || val.length <= 2000,
"Instructions must be less than 2000 characters",
),
agentOutputDemo: z
.string()
.refine(validateYouTubeUrl, "Please enter a valid YouTube URL"),
});
export type PublishAgentFormData = z.infer<typeof publishAgentSchema>;
@@ -64,4 +53,5 @@ export interface PublishAgentInfoInitialData {
additionalImages?: string[];
recommendedScheduleCron?: string;
instructions?: string;
agentOutputDemo?: string;
}

View File

@@ -46,6 +46,7 @@ export function useAgentInfoStep({
description: "",
recommendedScheduleCron: "",
instructions: "",
agentOutputDemo: "",
},
});
@@ -68,6 +69,7 @@ export function useAgentInfoStep({
description: initialData.description,
recommendedScheduleCron: initialData.recommendedScheduleCron || "",
instructions: initialData.instructions || "",
agentOutputDemo: initialData.agentOutputDemo || "",
});
}
}, [initialData, form]);
@@ -99,12 +101,13 @@ export function useAgentInfoStep({
instructions: data.instructions || null,
image_urls: images,
video_url: data.youtubeLink || "",
agent_output_demo_url: data.agentOutputDemo || "",
agent_id: selectedAgentId || "",
agent_version: selectedAgentVersion || 0,
slug: data.slug.replace(/\s+/g, "-"),
categories: filteredCategories,
recommended_schedule_cron: data.recommendedScheduleCron || null,
});
} as any);
await queryClient.invalidateQueries({
queryKey: getGetV2ListMySubmissionsQueryKey(),

View File

@@ -428,3 +428,20 @@ export function isEmpty(value: any): boolean {
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Validate YouTube URL */
export function validateYouTubeUrl(val: string): boolean {
if (!val) return true;
try {
const url = new URL(val);
const allowedHosts = [
"youtube.com",
"www.youtube.com",
"youtu.be",
"www.youtu.be",
];
return allowedHosts.includes(url.hostname);
} catch {
return false;
}
}