diff --git a/apps/rowboat/app/actions/project.actions.ts b/apps/rowboat/app/actions/project.actions.ts index 5f042694..54849645 100644 --- a/apps/rowboat/app/actions/project.actions.ts +++ b/apps/rowboat/app/actions/project.actions.ts @@ -2,7 +2,8 @@ import { z } from 'zod'; import { container } from "@/di/container"; import { redirect } from "next/navigation"; -import { templates } from "../lib/project_templates"; +// Fetch library templates from the unified assistant templates repository +import { MongoDBAssistantTemplatesRepository } from "@/src/infrastructure/repositories/mongodb.assistant-templates.repository"; import { authCheck } from "./auth.actions"; import { ApiKey } from "@/src/entities/models/api-key"; import { Project } from "@/src/entities/models/project"; @@ -40,14 +41,17 @@ const updateLiveWorkflowController = container.resolve('revertToLiveWorkflowController'); export async function listTemplates() { - const templatesArray = Object.entries(templates) - .filter(([key]) => key !== 'default') // Exclude the default template - .map(([key, template]) => ({ - id: key, - ...template - })); - - return templatesArray; + const repo = new MongoDBAssistantTemplatesRepository(); + const result = await repo.list({ source: 'library', isPublic: true }, undefined, 100); + // Map to the shape expected by callers (tools at top-level) + return result.items.map((item) => ({ + id: item.id, + name: item.name, + description: item.description, + category: item.category, + tools: (item as any).workflow?.tools || [], + copilotPrompt: item.copilotPrompt, + })); } export async function projectAuthCheck(projectId: string) { diff --git a/apps/rowboat/app/api/assistant-templates/[id]/like/route.ts b/apps/rowboat/app/api/assistant-templates/[id]/like/route.ts new file mode 100644 index 00000000..606f8dbc --- /dev/null +++ b/apps/rowboat/app/api/assistant-templates/[id]/like/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { MongoDBAssistantTemplatesRepository } from '@/src/infrastructure/repositories/mongodb.assistant-templates.repository'; + +const repo = new MongoDBAssistantTemplatesRepository(); + +const ToggleLikeSchema = z.object({ + guestId: z.string().min(1), +}); + +export async function POST(req: NextRequest, context: { params: Promise<{ id: string }> }) { + try { + // Prefer header like existing community route + const guestId = req.headers.get('x-guest-id') || undefined; + const body = !guestId ? await req.json().catch(() => ({})) : {}; + const parsed = ToggleLikeSchema.safeParse({ guestId: guestId || body.guestId }); + if (!parsed.success) { + return NextResponse.json({ error: 'Missing guestId' }, { status: 400 }); + } + + const { id } = await context.params; + const result = await repo.toggleLike(id, parsed.data.guestId); + return NextResponse.json(result); + } catch (error) { + console.error('Error toggling like:', error); + return NextResponse.json({ error: 'Failed to toggle like' }, { status: 500 }); + } +} + + diff --git a/apps/rowboat/app/api/assistant-templates/[id]/route.ts b/apps/rowboat/app/api/assistant-templates/[id]/route.ts new file mode 100644 index 00000000..d0f7217b --- /dev/null +++ b/apps/rowboat/app/api/assistant-templates/[id]/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { MongoDBAssistantTemplatesRepository } from '@/src/infrastructure/repositories/mongodb.assistant-templates.repository'; +import { authCheck } from '@/app/actions/auth.actions'; +import { USE_AUTH } from '@/app/lib/feature_flags'; + +const repo = new MongoDBAssistantTemplatesRepository(); + +export async function GET(_req: NextRequest, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + const item = await repo.fetch(id); + if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + return NextResponse.json(item); + } catch (error) { + console.error('Error fetching assistant template:', error); + return NextResponse.json({ error: 'Failed to fetch assistant template' }, { status: 500 }); + } +} + +export async function DELETE(_req: NextRequest, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + const item = await repo.fetch(id); + if (!item) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + // Disallow deleting library/prebuilt items + if ((item as any).source === 'library' || item.authorId === 'rowboat-system') { + return NextResponse.json({ error: 'Not allowed' }, { status: 403 }); + } + + let user; + if (USE_AUTH) { + user = await authCheck(); + } else { + user = { id: 'guest_user' } as any; // guest mode acts as a single user + } + + if (item.authorId !== user.id) { + // Do not reveal existence + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const ok = await repo.deleteByIdAndAuthor(id, user.id); + if (!ok) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting assistant template:', error); + return NextResponse.json({ error: 'Failed to delete assistant template' }, { status: 500 }); + } +} + + diff --git a/apps/rowboat/app/api/assistant-templates/categories/route.ts b/apps/rowboat/app/api/assistant-templates/categories/route.ts new file mode 100644 index 00000000..58e5e120 --- /dev/null +++ b/apps/rowboat/app/api/assistant-templates/categories/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { MongoDBAssistantTemplatesRepository } from '@/src/infrastructure/repositories/mongodb.assistant-templates.repository'; + +const repo = new MongoDBAssistantTemplatesRepository(); + +export async function GET(_req: NextRequest) { + try { + const categories = await repo.getCategories(); + return NextResponse.json({ items: categories }); + } catch (error) { + console.error('Error fetching categories:', error); + return NextResponse.json({ error: 'Failed to fetch categories' }, { status: 500 }); + } +} + + diff --git a/apps/rowboat/app/api/assistant-templates/route.ts b/apps/rowboat/app/api/assistant-templates/route.ts new file mode 100644 index 00000000..03389fae --- /dev/null +++ b/apps/rowboat/app/api/assistant-templates/route.ts @@ -0,0 +1,130 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { MongoDBAssistantTemplatesRepository } from '@/src/infrastructure/repositories/mongodb.assistant-templates.repository'; +import { ensureLibraryTemplatesSeeded } from '@/app/lib/assistant_templates_seed'; +import { authCheck } from '@/app/actions/auth.actions'; +import { auth0 } from '@/app/lib/auth0'; +import { USE_AUTH } from '@/app/lib/feature_flags'; + +const repo = new MongoDBAssistantTemplatesRepository(); + +const ListSchema = z.object({ + category: z.string().optional(), + search: z.string().optional(), + featured: z.boolean().optional(), + source: z.enum(['library','community']).optional(), + cursor: z.string().optional(), + limit: z.number().min(1).max(50).default(20), +}); + +const CreateSchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().min(1).max(500), + category: z.string().min(1), + tags: z.array(z.string()).max(10), + isAnonymous: z.boolean().default(false), + workflow: z.any(), + copilotPrompt: z.string().optional(), + thumbnailUrl: z.string().url().optional(), +}); + +export async function GET(req: NextRequest) { + try { + // Ensure library JSONs are seeded into the unified collection (idempotent) + await ensureLibraryTemplatesSeeded(); + const { searchParams } = new URL(req.url); + const params = ListSchema.parse({ + category: searchParams.get('category') || undefined, + search: searchParams.get('search') || undefined, + featured: searchParams.get('featured') ? searchParams.get('featured') === 'true' : undefined, + source: (searchParams.get('source') as 'library' | 'community') || undefined, + cursor: searchParams.get('cursor') || undefined, + limit: searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : 20, + }); + + // If source specified, query that subset; otherwise return combined from the unified collection + if (params.source === 'library' || params.source === 'community') { + const result = await repo.list({ + category: params.category, + search: params.search, + featured: params.featured, + isPublic: true, + source: params.source, + }, params.cursor, params.limit); + return NextResponse.json(result); + } + + // No source: combine both subsets from the unified collection + const [lib, com] = await Promise.all([ + repo.list({ category: params.category, search: params.search, featured: params.featured, isPublic: true, source: 'library' }, undefined, params.limit), + repo.list({ category: params.category, search: params.search, featured: params.featured, isPublic: true, source: 'community' }, undefined, params.limit), + ]); + return NextResponse.json({ items: [...lib.items, ...com.items], nextCursor: null }); + } catch (error) { + console.error('Error listing assistant templates:', error); + return NextResponse.json({ error: 'Failed to list assistant templates' }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + try { + let user; + if (USE_AUTH) { + user = await authCheck(); + } else { + user = { id: 'guest', email: 'guest@example.com' }; + } + + const body = await req.json(); + const data = CreateSchema.parse(body); + + let authorName = 'Anonymous'; + let authorEmail: string | undefined; + if (USE_AUTH) { + try { + const { user: auth0User } = await auth0.getSession() || {}; + if (auth0User) { + authorName = auth0User.name ?? auth0User.email ?? 'Anonymous'; + authorEmail = auth0User.email; + } + } catch (error) { + console.warn('Could not get Auth0 user info:', error); + } + } + + if (data.isAnonymous) { + authorName = 'Anonymous'; + authorEmail = undefined; + } + + const created = await repo.create({ + name: data.name, + description: data.description, + category: data.category, + authorId: user.id, + authorName, + authorEmail, + isAnonymous: data.isAnonymous, + workflow: data.workflow, + tags: data.tags, + copilotPrompt: data.copilotPrompt, + thumbnailUrl: data.thumbnailUrl, + downloadCount: 0, + likeCount: 0, + featured: false, + isPublic: true, + likes: [], + source: 'community', + }); + + return NextResponse.json(created); + } catch (error) { + console.error('Error creating assistant template:', error); + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request data', details: error.errors }, { status: 400 }); + } + return NextResponse.json({ error: 'Failed to create assistant template' }, { status: 500 }); + } +} + + diff --git a/apps/rowboat/app/api/me/route.ts b/apps/rowboat/app/api/me/route.ts new file mode 100644 index 00000000..b1591439 --- /dev/null +++ b/apps/rowboat/app/api/me/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { authCheck } from '@/app/actions/auth.actions'; +import { USE_AUTH } from '@/app/lib/feature_flags'; + +export async function GET(_req: NextRequest) { + try { + let user; + if (USE_AUTH) { + user = await authCheck(); + } else { + user = { id: 'guest_user' } as any; + } + return NextResponse.json({ id: user.id }); + } catch (error) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} + + diff --git a/apps/rowboat/app/api/templates/route.ts b/apps/rowboat/app/api/templates/route.ts deleted file mode 100644 index 3bba07fc..00000000 --- a/apps/rowboat/app/api/templates/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { NextResponse } from 'next/server'; -import { templates } from '@/app/lib/project_templates'; - -export async function GET() { - // The templates are now dynamically loaded from JSON files in the templates folder - return NextResponse.json(templates); -} diff --git a/apps/rowboat/app/lib/assistant_templates_seed.ts b/apps/rowboat/app/lib/assistant_templates_seed.ts new file mode 100644 index 00000000..d577f3a4 --- /dev/null +++ b/apps/rowboat/app/lib/assistant_templates_seed.ts @@ -0,0 +1,52 @@ +import { db } from "@/app/lib/mongodb"; +import { prebuiltTemplates } from "@/app/lib/prebuilt-cards"; + +// idempotent seed: creates library (prebuilt) templates in DB if missing +// Uses name+authorName match to avoid duplicates; tags include a stable prebuilt key +export async function ensureLibraryTemplatesSeeded(): Promise { + try { + const collection = db.collection("assistant_templates"); + const now = new Date().toISOString(); + + const entries = Object.entries(prebuiltTemplates); + for (const [prebuiltKey, tpl] of entries) { + // minimal guard; only ingest valid workflow-like objects + if (!(tpl as any)?.agents || !Array.isArray((tpl as any).agents)) continue; + + const name = (tpl as any).name || prebuiltKey; + + // check if already present (by name + authorName Rowboat and special tag) + const existing = await collection.findOne({ name, authorName: "Rowboat", tags: { $in: [ `prebuilt:${prebuiltKey}`, "__library__" ] } }); + if (existing) continue; + + const doc = { + name, + description: (tpl as any).description || "", + category: (tpl as any).category || "Other", + authorId: "rowboat-system", + authorName: "Rowboat", + authorEmail: undefined, + isAnonymous: false, + workflow: tpl as any, + tags: ["__library__", `prebuilt:${prebuiltKey}`].filter(Boolean), + publishedAt: now, + lastUpdatedAt: now, + downloadCount: 0, + likeCount: 0, + featured: false, + isPublic: true, + likes: [] as string[], + copilotPrompt: (tpl as any).copilotPrompt || undefined, + thumbnailUrl: undefined, + source: 'library' as const, + } as const; + + await collection.insertOne(doc as any); + } + } catch (err) { + // best-effort seed; do not throw to avoid breaking requests + console.error("ensureLibraryTemplatesSeeded error:", err); + } +} + + diff --git a/apps/rowboat/app/lib/project_templates.ts b/apps/rowboat/app/lib/project_templates.ts index 3226d1c6..62a89810 100644 --- a/apps/rowboat/app/lib/project_templates.ts +++ b/apps/rowboat/app/lib/project_templates.ts @@ -1,50 +1,33 @@ import { WorkflowTemplate } from "./types/workflow_types"; import { z } from 'zod'; -import { prebuiltTemplates } from './prebuilt-cards'; -const DEFAULT_MODEL = process.env.PROVIDER_DEFAULT_MODEL || "gpt-4.1"; - -// Build templates object using static imports so Vercel bundles them -function buildTemplates(): { [key: string]: z.infer } { - const templates: { [key: string]: z.infer } = {}; - - // Add default template - templates['default'] = { - name: 'Blank Template', - description: 'A blank canvas to build your agents.', - startAgent: "", - agents: [], - prompts: [], - tools: [ - { - name: "Generate Image", - description: "Generate an image using Google Gemini given a text prompt. Returns base64-encoded image data and any text parts.", - isGeminiImage: true, - parameters: { - type: 'object', - properties: { - prompt: { type: 'string', description: 'Text prompt describing the image to generate' }, - modelName: { type: 'string', description: 'Optional Gemini model override' }, - }, - required: ['prompt'], - additionalProperties: true, +// Provide a minimal default template to satisfy legacy code paths that +// still reference `templates.default`. Real templates are DB-backed. +const defaultTemplate: z.infer = { + name: 'Blank Template', + description: 'A blank canvas to build your assistant.', + startAgent: "", + agents: [], + prompts: [], + tools: [ + { + name: "Generate Image", + description: "Generate an image using Google Gemini given a text prompt. Returns base64-encoded image data and any text parts.", + isGeminiImage: true, + parameters: { + type: 'object', + properties: { + prompt: { type: 'string', description: 'Text prompt describing the image to generate' }, + modelName: { type: 'string', description: 'Optional Gemini model override' }, }, + required: ['prompt'], + additionalProperties: true, }, - ], - }; + }, + ], + pipelines: [], +}; - // Merge static prebuilt templates - Object.entries(prebuiltTemplates).forEach(([key, tpl]) => { - // Basic guard to avoid bad entries - if ((tpl as any)?.agents && Array.isArray((tpl as any).agents)) { - templates[key] = tpl as z.infer; - } - }); - - return templates; -} - -export const templates: { [key: string]: z.infer } = buildTemplates(); - -// Note: Prebuilt cards are now loaded from app/lib/prebuilt-cards/ directory -// starting_copilot_prompts has been removed as it was unused +export const templates: Record> = { + default: defaultTemplate, +}; diff --git a/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx b/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx index 312dc7b4..b535829a 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx @@ -1,10 +1,12 @@ -"use client"; + "use client"; import React from "react"; -import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Input, ButtonGroup, Checkbox, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react"; +import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Input, ButtonGroup, Checkbox, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure, Textarea, Select, SelectItem, Chip, Radio, RadioGroup } from "@heroui/react"; import { Button as CustomButton } from "@/components/ui/button"; import { RadioIcon, RedoIcon, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, SettingsIcon, ChevronDownIcon, ZapIcon, Clock, Plug, MessageCircleIcon, ShareIcon } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { ProgressBar, ProgressStep } from "@/components/ui/progress-bar"; +import { useUser } from '@auth0/nextjs-auth0'; +import { useState, useEffect } from "react"; interface TopBarProps { localProjectName: string; @@ -42,6 +44,20 @@ interface TopBarProps { onShareWorkflow: () => void; shareUrl: string | null; onCopyShareUrl: () => void; + shareMode: 'url' | 'community'; + setShareMode: (mode: 'url' | 'community') => void; + communityData: { + name: string; + description: string; + category: string; + tags: string[]; + isAnonymous: boolean; + copilotPrompt: string; + }; + setCommunityData: (data: any) => void; + onCommunityPublish: () => void; + communityPublishing: boolean; + communityPublishSuccess: boolean; } export function TopBar({ @@ -80,6 +96,13 @@ export function TopBar({ onShareWorkflow, shareUrl, onCopyShareUrl, + shareMode, + setShareMode, + communityData, + setCommunityData, + onCommunityPublish, + communityPublishing, + communityPublishSuccess, }: TopBarProps) { const router = useRouter(); const params = useParams(); @@ -87,11 +110,39 @@ export function TopBar({ // Share modal state const { isOpen: isShareModalOpen, onOpen: onShareModalOpen, onClose: onShareModalClose } = useDisclosure(); + const { isOpen: isConfirmOpen, onOpen: onConfirmOpen, onClose: onConfirmClose } = useDisclosure(); + const [acknowledged, setAcknowledged] = useState(false); + const [copyButtonText, setCopyButtonText] = useState('Copy'); const handleShareClick = () => { onShareWorkflow(); // Call the original share function to generate URL onShareModalOpen(); // Open the modal }; + + const handleCopyUrl = () => { + onCopyShareUrl(); // Call the original copy function + setCopyButtonText('Copied!'); + setTimeout(() => { + setCopyButtonText('Copy'); + }, 2000); // Reset after 2 seconds + }; + + // After successful community publish, briefly show success and then close modal + useEffect(() => { + if (communityPublishSuccess) { + const timer = setTimeout(() => { + onShareModalClose(); + }, 1200); + return () => clearTimeout(timer); + } + }, [communityPublishSuccess, onShareModalClose]); + + const { user } = useUser(); + + const getUserDisplayName = () => { + if (!user) return 'Anonymous'; + return user.name ?? user.email ?? 'Anonymous'; + }; // Progress bar steps with completion logic and current step detection const step1Complete = hasAgentInstructionChanges; @@ -596,46 +647,261 @@ export function TopBar({ {/* Share Modal */} - + - Share Assistant +

Share Assistant

+

Choose how you'd like to share your assistant

-
-

- Share this assistant with others using the URL below: -

- {shareUrl ? ( -
- - +
+ {/* Quick Share Section */} +
+
+
+ +
+
+

Quick Share

+

Share with a direct link

+
- ) : ( -
- - - Generating share URL... - + + {shareUrl ? ( +
+
+ +
+ +
+ ) : ( +
+ + + Generating share URL... + +
+ )} +
+ + {/* Divider */} +
+
+
- )} +
+ or +
+
+ + {/* Community Publishing Section */} +
+
+
+ +
+
+

Publish to Community

+

Make it discoverable by others

+
+
+ +
+ {/* Assistant Name */} +
+ + setCommunityData({ ...communityData, name: e.target.value })} + classNames={{ + input: "text-sm focus:outline-none !focus:ring-0 !focus:ring-offset-0 !ring-0 !ring-offset-0", + inputWrapper: "border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 focus-within:border-gray-300 dark:focus-within:border-gray-500 !focus-within:ring-0 !focus-within:ring-offset-0 !ring-0 !ring-offset-0" + }} + /> +
+ + {/* Description */} +
+ +