mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
Merge community cards and pre-built templates
This commit is contained in:
parent
21f39000c0
commit
19da994ac1
22 changed files with 982 additions and 684 deletions
|
|
@ -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<IUpdateLiveWorkflowContro
|
|||
const revertToLiveWorkflowController = container.resolve<IRevertToLiveWorkflowController>('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) {
|
||||
|
|
|
|||
30
apps/rowboat/app/api/assistant-templates/[id]/like/route.ts
Normal file
30
apps/rowboat/app/api/assistant-templates/[id]/like/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
18
apps/rowboat/app/api/assistant-templates/[id]/route.ts
Normal file
18
apps/rowboat/app/api/assistant-templates/[id]/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
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, 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
apps/rowboat/app/api/assistant-templates/categories/route.ts
Normal file
16
apps/rowboat/app/api/assistant-templates/categories/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,81 +1,85 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { MongoDBCommunityAssistantsRepository } from '@/src/infrastructure/repositories/mongodb.community-assistants.repository';
|
||||
import { CommunityAssistant } from '@/src/entities/models/community-assistant';
|
||||
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 communityAssistantsRepo = new MongoDBCommunityAssistantsRepository();
|
||||
const repo = new MongoDBAssistantTemplatesRepository();
|
||||
|
||||
// Schema for creating a community assistant
|
||||
const CreateCommunityAssistantSchema = z.object({
|
||||
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(), // Will be validated against Workflow schema
|
||||
workflow: z.any(),
|
||||
copilotPrompt: z.string().optional(),
|
||||
thumbnailUrl: z.string().url().optional(),
|
||||
estimatedComplexity: z.enum(['beginner', 'intermediate', 'advanced']).default('beginner'),
|
||||
});
|
||||
|
||||
// Schema for listing community assistants
|
||||
const ListCommunityAssistantsSchema = z.object({
|
||||
category: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
featured: z.boolean().optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().min(1).max(50).default(20),
|
||||
});
|
||||
|
||||
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 = ListCommunityAssistantsSchema.parse({
|
||||
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,
|
||||
});
|
||||
|
||||
const result = await communityAssistantsRepo.list({
|
||||
category: params.category,
|
||||
search: params.search,
|
||||
featured: params.featured,
|
||||
isPublic: true, // Only show public assistants
|
||||
}, params.cursor, params.limit);
|
||||
// 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);
|
||||
}
|
||||
|
||||
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 community assistants:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list community assistants' },
|
||||
{ status: 500 }
|
||||
);
|
||||
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 {
|
||||
// Get authenticated user
|
||||
let user;
|
||||
if (USE_AUTH) {
|
||||
user = await authCheck();
|
||||
} else {
|
||||
// For development/testing without auth
|
||||
user = { id: 'guest', email: 'guest@example.com' };
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const data = CreateCommunityAssistantSchema.parse(body);
|
||||
const data = CreateSchema.parse(body);
|
||||
|
||||
// Get user display name from Auth0 session
|
||||
let authorName = 'Anonymous';
|
||||
let authorEmail: string | undefined;
|
||||
|
||||
if (USE_AUTH) {
|
||||
try {
|
||||
const { user: auth0User } = await auth0.getSession() || {};
|
||||
|
|
@ -88,15 +92,12 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
// Override with user choice
|
||||
if (!data.isAnonymous) {
|
||||
authorName = data.isAnonymous ? 'Anonymous' : authorName;
|
||||
} else {
|
||||
if (data.isAnonymous) {
|
||||
authorName = 'Anonymous';
|
||||
authorEmail = undefined;
|
||||
}
|
||||
|
||||
const communityAssistant = await communityAssistantsRepo.create({
|
||||
const created = await repo.create({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
|
|
@ -108,28 +109,22 @@ export async function POST(req: NextRequest) {
|
|||
tags: data.tags,
|
||||
copilotPrompt: data.copilotPrompt,
|
||||
thumbnailUrl: data.thumbnailUrl,
|
||||
estimatedComplexity: data.estimatedComplexity,
|
||||
publishedAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
downloadCount: 0,
|
||||
likeCount: 0,
|
||||
featured: false,
|
||||
isPublic: true,
|
||||
likes: [],
|
||||
source: 'community',
|
||||
});
|
||||
|
||||
return NextResponse.json(communityAssistant);
|
||||
return NextResponse.json(created);
|
||||
} catch (error) {
|
||||
console.error('Error creating community assistant:', 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: 'Invalid request data', details: error.errors }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create community assistant' },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ error: 'Failed to create assistant template' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { MongoDBCommunityAssistantsRepository } from '@/src/infrastructure/repositories/mongodb.community-assistants.repository';
|
||||
import { authCheck } from '@/app/actions/auth.actions';
|
||||
import { auth0 } from '@/app/lib/auth0';
|
||||
import { USE_AUTH } from '@/app/lib/feature_flags';
|
||||
|
||||
const communityAssistantsRepo = new MongoDBCommunityAssistantsRepository();
|
||||
|
||||
const ToggleLikeSchema = z.object({
|
||||
liked: z.boolean(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// Try to parse body, but don't require it for toggle functionality
|
||||
let body = {};
|
||||
try {
|
||||
const text = await req.text();
|
||||
if (text) {
|
||||
body = JSON.parse(text);
|
||||
}
|
||||
} catch (error) {
|
||||
// If no body or invalid JSON, continue with empty body
|
||||
}
|
||||
|
||||
// Get user ID (works for both authenticated and guest users)
|
||||
let userId: string;
|
||||
let userEmail: string | undefined;
|
||||
|
||||
if (USE_AUTH) {
|
||||
try {
|
||||
const user = await authCheck();
|
||||
userId = user.id;
|
||||
userEmail = user.email;
|
||||
} catch (error) {
|
||||
// If not authenticated, use a consistent guest ID from headers or generate one
|
||||
const guestId = req.headers.get('x-guest-id') || `guest-${crypto.randomUUID()}`;
|
||||
userId = guestId;
|
||||
}
|
||||
} else {
|
||||
// For development/testing without auth, use a consistent guest ID
|
||||
const guestId = req.headers.get('x-guest-id') || `guest-${crypto.randomUUID()}`;
|
||||
userId = guestId;
|
||||
}
|
||||
|
||||
// Verify the assistant exists and is public
|
||||
const assistant = await communityAssistantsRepo.fetch(id);
|
||||
if (!assistant || !assistant.isPublic) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Community assistant not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle the like
|
||||
const result = await communityAssistantsRepo.toggleLike(id, userId, userEmail);
|
||||
|
||||
return NextResponse.json({
|
||||
liked: result.liked,
|
||||
likeCount: result.likeCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error toggling like:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to toggle like' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// Get user ID (works for both authenticated and guest users)
|
||||
let userId: string;
|
||||
|
||||
if (USE_AUTH) {
|
||||
try {
|
||||
const user = await authCheck();
|
||||
userId = user.id;
|
||||
} catch (error) {
|
||||
// If not authenticated, generate a guest ID
|
||||
userId = `guest-${crypto.randomUUID()}`;
|
||||
}
|
||||
} else {
|
||||
// For development/testing without auth
|
||||
userId = `guest-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
// Get like count and user's like status
|
||||
const [likeCount, userLiked] = await Promise.all([
|
||||
communityAssistantsRepo.getLikeCount(id),
|
||||
communityAssistantsRepo.getUserLikes(id, userId),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
likeCount,
|
||||
liked: userLiked,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting like status:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get like status' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { MongoDBCommunityAssistantsRepository } from '@/src/infrastructure/repositories/mongodb.community-assistants.repository';
|
||||
import { authCheck } from '@/app/actions/auth.actions';
|
||||
import { USE_AUTH } from '@/app/lib/feature_flags';
|
||||
|
||||
const communityAssistantsRepo = new MongoDBCommunityAssistantsRepository();
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const assistant = await communityAssistantsRepo.fetch(id);
|
||||
|
||||
if (!assistant) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Community assistant not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Only return public assistants
|
||||
if (!assistant.isPublic) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Community assistant not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(assistant);
|
||||
} catch (error) {
|
||||
console.error('Error fetching community assistant:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch community assistant' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// Get authenticated user
|
||||
let user;
|
||||
if (USE_AUTH) {
|
||||
user = await authCheck();
|
||||
} else {
|
||||
// For development/testing without auth
|
||||
user = { id: 'guest', email: 'guest@example.com' };
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const { action } = body;
|
||||
|
||||
if (action === 'import') {
|
||||
// Import the community assistant as a new project
|
||||
const assistant = await communityAssistantsRepo.fetch(id);
|
||||
|
||||
if (!assistant || !assistant.isPublic) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Community assistant not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Increment download count
|
||||
await communityAssistantsRepo.incrementDownloadCount(id);
|
||||
|
||||
// Return the workflow data for project creation
|
||||
return NextResponse.json({
|
||||
workflow: assistant.workflow,
|
||||
name: assistant.name,
|
||||
description: assistant.description,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error processing community assistant action:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process action' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { MongoDBCommunityAssistantsRepository } from '@/src/infrastructure/repositories/mongodb.community-assistants.repository';
|
||||
|
||||
const communityAssistantsRepo = new MongoDBCommunityAssistantsRepository();
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const categories = await communityAssistantsRepo.getCategories();
|
||||
|
||||
return NextResponse.json({ categories });
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch categories' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
52
apps/rowboat/app/lib/assistant_templates_seed.ts
Normal file
52
apps/rowboat/app/lib/assistant_templates_seed.ts
Normal file
|
|
@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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<typeof WorkflowTemplate> } {
|
||||
const templates: { [key: string]: z.infer<typeof WorkflowTemplate> } = {};
|
||||
|
||||
// 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<typeof WorkflowTemplate> = {
|
||||
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<typeof WorkflowTemplate>;
|
||||
}
|
||||
});
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
export const templates: { [key: string]: z.infer<typeof WorkflowTemplate> } = 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<string, z.infer<typeof WorkflowTemplate>> = {
|
||||
default: defaultTemplate,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ interface TopBarProps {
|
|||
tags: string[];
|
||||
isAnonymous: boolean;
|
||||
copilotPrompt: string;
|
||||
estimatedComplexity: 'beginner' | 'intermediate' | 'advanced';
|
||||
};
|
||||
setCommunityData: (data: any) => void;
|
||||
onCommunityPublish: () => void;
|
||||
|
|
|
|||
|
|
@ -1642,7 +1642,6 @@ export function WorkflowEditor({
|
|||
tags: [] as string[],
|
||||
isAnonymous: false,
|
||||
copilotPrompt: '',
|
||||
estimatedComplexity: 'beginner' as 'beginner' | 'intermediate' | 'advanced',
|
||||
});
|
||||
const [communityPublishing, setCommunityPublishing] = useState(false);
|
||||
const [communityPublishSuccess, setCommunityPublishSuccess] = useState(false);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { z } from "zod";
|
|||
import Link from 'next/link';
|
||||
import { CommunitySection } from '@/components/community/CommunitySection';
|
||||
import { AssistantSection } from '@/components/common/AssistantSection';
|
||||
import { UnifiedTemplatesSection } from '@/components/common/UnifiedTemplatesSection';
|
||||
|
||||
const SHOW_PREBUILT_CARDS = process.env.NEXT_PUBLIC_SHOW_PREBUILT_CARDS !== 'false';
|
||||
|
||||
|
|
@ -53,6 +54,9 @@ export function BuildAssistantSection() {
|
|||
const [templates, setTemplates] = useState<any[]>([]);
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false);
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null);
|
||||
const [communityTemplates, setCommunityTemplates] = useState<any[]>([]);
|
||||
const [communityTemplatesLoading, setCommunityTemplatesLoading] = useState(false);
|
||||
const [communityTemplatesError, setCommunityTemplatesError] = useState<string | null>(null);
|
||||
const [projects, setProjects] = useState<z.infer<typeof Project>[]>([]);
|
||||
const [projectsLoading, setProjectsLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
|
@ -104,27 +108,127 @@ export function BuildAssistantSection() {
|
|||
}
|
||||
};
|
||||
|
||||
const fetchCommunityTemplates = async () => {
|
||||
setCommunityTemplatesLoading(true);
|
||||
setCommunityTemplatesError(null);
|
||||
try {
|
||||
const response = await fetch('/api/assistant-templates?source=community');
|
||||
if (!response.ok) throw new Error('Failed to fetch community templates');
|
||||
const data = await response.json();
|
||||
setCommunityTemplates(data.items || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching community templates:', error);
|
||||
setCommunityTemplatesError(error instanceof Error ? error.message : 'Failed to load community templates');
|
||||
} finally {
|
||||
setCommunityTemplatesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle template selection
|
||||
const handleTemplateSelect = async (template: any) => {
|
||||
// Show a small non-blocking spinner on the clicked card
|
||||
setLoadingTemplateId(template.id);
|
||||
try {
|
||||
await createProjectWithOptions({
|
||||
template: template.id,
|
||||
// Prefer a card-specific copilot prompt if present on the template JSON
|
||||
prompt: template.copilotPrompt || 'Explain this workflow',
|
||||
router,
|
||||
onError: () => {
|
||||
// Clear loading state if creation fails
|
||||
setLoadingTemplateId(null);
|
||||
},
|
||||
});
|
||||
if (template.type === 'prebuilt') {
|
||||
// Fetch full workflow from unified API, then create from JSON
|
||||
const res = await fetch(`/api/assistant-templates/${template.id}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch template details');
|
||||
const data = await res.json();
|
||||
await createProjectFromJsonWithOptions({
|
||||
workflowJson: JSON.stringify(data.workflow),
|
||||
router,
|
||||
onSuccess: (_projectId) => {},
|
||||
onError: () => {
|
||||
setLoadingTemplateId(null);
|
||||
}
|
||||
});
|
||||
} else if (template.type === 'community') {
|
||||
// Handle community template import
|
||||
await createProjectFromJsonWithOptions({
|
||||
workflowJson: JSON.stringify(template.workflow),
|
||||
router,
|
||||
onSuccess: (projectId) => {
|
||||
router.push(`/projects/${projectId}/workflow`);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error creating project from community template:', error);
|
||||
setLoadingTemplateId(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
// In case of unexpected error, clear loading state
|
||||
setLoadingTemplateId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Stable guest id for like toggles
|
||||
const getGuestId = () => {
|
||||
try {
|
||||
let guestId = sessionStorage.getItem('guestId');
|
||||
if (!guestId) {
|
||||
guestId = `guest-${crypto.randomUUID()}`;
|
||||
sessionStorage.setItem('guestId', guestId);
|
||||
}
|
||||
return guestId;
|
||||
} catch (_e) {
|
||||
return `guest-${crypto.randomUUID()}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle template like (unified for library and community)
|
||||
const handleTemplateLike = async (template: any) => {
|
||||
try {
|
||||
const guestId = getGuestId();
|
||||
const response = await fetch(`/api/assistant-templates/${template.id}/like`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-guest-id': guestId },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (template.type === 'community') {
|
||||
setCommunityTemplates(prev => prev.map(t =>
|
||||
t.id === template.id
|
||||
? { ...t, likeCount: data.likeCount, isLiked: data.liked }
|
||||
: t
|
||||
));
|
||||
} else {
|
||||
setTemplates(prev => prev.map(t =>
|
||||
t.id === template.id
|
||||
? { ...t, likeCount: data.likeCount, isLiked: data.liked } as any
|
||||
: t
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error toggling like:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle template share (for both library and community)
|
||||
const handleTemplateShare = async (template: any) => {
|
||||
try {
|
||||
// Fetch workflow for the template and create a shared snapshot
|
||||
const res = await fetch(`/api/assistant-templates/${template.id}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch template for sharing');
|
||||
const data = await res.json();
|
||||
|
||||
const shareResp = await fetch('/api/shared-workflow', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ workflow: data.workflow }),
|
||||
});
|
||||
if (!shareResp.ok) throw new Error('Failed to create shared workflow');
|
||||
const shareData = await shareResp.json();
|
||||
const url = `${window.location.origin}/projects?shared=${shareData.id}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
console.log('URL copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy shared URL:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle prompt card selection
|
||||
const handlePromptSelect = (promptText: string) => {
|
||||
setUserPrompt(promptText);
|
||||
|
|
@ -148,6 +252,7 @@ export function BuildAssistantSection() {
|
|||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
fetchCommunityTemplates();
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
|
|
@ -186,19 +291,40 @@ export function BuildAssistantSection() {
|
|||
|
||||
if (urlPrompt || urlTemplate) {
|
||||
setAutoCreateLoading(true);
|
||||
createProjectWithOptions({
|
||||
template: urlTemplate || undefined,
|
||||
prompt: urlPrompt || undefined,
|
||||
router,
|
||||
onError: (error) => {
|
||||
console.error('Error auto-creating project:', error);
|
||||
setAutoCreateLoading(false);
|
||||
// Fall back to showing the form with the prompt pre-filled
|
||||
if (urlPrompt) {
|
||||
setUserPrompt(urlPrompt);
|
||||
}
|
||||
try {
|
||||
const isMongoId = !!urlTemplate && /^[a-f0-9]{24}$/i.test(urlTemplate);
|
||||
if (urlTemplate && isMongoId) {
|
||||
// New-style share: template is an assistant-templates id
|
||||
const res = await fetch(`/api/assistant-templates/${urlTemplate}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch shared template');
|
||||
const data = await res.json();
|
||||
await createProjectFromJsonWithOptions({
|
||||
workflowJson: JSON.stringify(data.workflow),
|
||||
router,
|
||||
onError: (error) => {
|
||||
console.error('Error auto-creating project from template id:', error);
|
||||
setAutoCreateLoading(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Legacy share using static key
|
||||
await createProjectWithOptions({
|
||||
template: urlTemplate || undefined,
|
||||
prompt: urlPrompt || undefined,
|
||||
router,
|
||||
onError: (error) => {
|
||||
console.error('Error auto-creating project:', error);
|
||||
setAutoCreateLoading(false);
|
||||
if (urlPrompt) {
|
||||
setUserPrompt(urlPrompt);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error handling template auto-create:', err);
|
||||
setAutoCreateLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -464,34 +590,46 @@ export function BuildAssistantSection() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pre-built Assistants Section - Only show for New Assistant tab */}
|
||||
{/* Unified Templates Section - Only show for New Assistant tab */}
|
||||
{selectedTab === 'new' && SHOW_PREBUILT_CARDS && (
|
||||
<div className="max-w-5xl mx-auto mt-16">
|
||||
<AssistantSection
|
||||
title="Prebuilt Assistants"
|
||||
description="Start quickly and let Skipper adapt it to your needs."
|
||||
items={templates.map(template => ({
|
||||
<UnifiedTemplatesSection
|
||||
prebuiltTemplates={templates.map(template => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category || 'Other',
|
||||
tools: template.tools,
|
||||
estimatedComplexity: template.estimatedComplexity
|
||||
type: 'prebuilt' as const,
|
||||
likeCount: (template as any).likeCount || 0,
|
||||
isLiked: (template as any).isLiked || false,
|
||||
}))}
|
||||
loading={templatesLoading}
|
||||
error={templatesError}
|
||||
onItemClick={handleTemplateSelect}
|
||||
communityTemplates={communityTemplates.map(template => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
authorName: template.authorName,
|
||||
isAnonymous: template.isAnonymous,
|
||||
likeCount: template.likeCount,
|
||||
createdAt: template.publishedAt,
|
||||
isLiked: template.isLiked,
|
||||
type: 'community' as const,
|
||||
}))}
|
||||
loading={templatesLoading || communityTemplatesLoading}
|
||||
error={templatesError || communityTemplatesError}
|
||||
onTemplateClick={handleTemplateSelect}
|
||||
onRetry={() => {
|
||||
fetchTemplates();
|
||||
fetchCommunityTemplates();
|
||||
}}
|
||||
loadingItemId={loadingTemplateId}
|
||||
emptyMessage="No pre-built assistants available"
|
||||
onLike={handleTemplateLike}
|
||||
onShare={handleTemplateShare}
|
||||
getUniqueTools={getUniqueTools}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Community Assistants Section */}
|
||||
<div className="max-w-5xl mx-auto mt-16">
|
||||
<CommunitySection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ interface AssistantCardProps {
|
|||
onLike?: () => void;
|
||||
onShare?: () => void;
|
||||
isLiked?: boolean;
|
||||
// Pre-built specific props
|
||||
estimatedComplexity?: string;
|
||||
// Template type indicator
|
||||
templateType?: 'prebuilt' | 'community';
|
||||
// Common props
|
||||
onClick?: () => void;
|
||||
loading?: boolean;
|
||||
|
|
@ -83,14 +83,35 @@ export function AssistantCard({
|
|||
onLike,
|
||||
onShare,
|
||||
isLiked = false,
|
||||
estimatedComplexity,
|
||||
templateType,
|
||||
onClick,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
getUniqueTools
|
||||
}: AssistantCardProps) {
|
||||
const displayTools = getUniqueTools ? getUniqueTools({ tools }) : tools;
|
||||
const isCommunity = authorName !== undefined;
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = React.useState(false);
|
||||
const [showDescriptionToggle, setShowDescriptionToggle] = React.useState(false);
|
||||
const descriptionRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
let t: any;
|
||||
if (copied) {
|
||||
t = setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
return () => t && clearTimeout(t);
|
||||
}, [copied]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = descriptionRef.current;
|
||||
if (!el) return;
|
||||
// Measure if truncated (only when collapsed)
|
||||
if (!isDescriptionExpanded) {
|
||||
setShowDescriptionToggle(el.scrollHeight > el.clientHeight + 1);
|
||||
} else {
|
||||
setShowDescriptionToggle(true);
|
||||
}
|
||||
}, [description, isDescriptionExpanded]);
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
const lowerCategory = category.toLowerCase();
|
||||
|
|
@ -124,11 +145,41 @@ export function AssistantCard({
|
|||
<div className="space-y-3">
|
||||
{/* Title and Description */}
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors line-clamp-1">
|
||||
{name}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors line-clamp-1 flex-1">
|
||||
{name}
|
||||
</div>
|
||||
{/* Template Type Badge */}
|
||||
{templateType && (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium flex-shrink-0",
|
||||
templateType === 'prebuilt'
|
||||
? "bg-blue-50 text-blue-700 dark:bg-blue-400/10 dark:text-blue-300"
|
||||
: "bg-purple-50 text-purple-700 dark:bg-purple-400/10 dark:text-purple-300"
|
||||
)}>
|
||||
{templateType === 'prebuilt' ? 'Library' : 'Community'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mt-1">
|
||||
{description}
|
||||
<div className="mt-1">
|
||||
<div
|
||||
ref={descriptionRef}
|
||||
className={clsx(
|
||||
"text-sm leading-5 text-gray-600 dark:text-gray-400 relative min-h-[3.75rem]",
|
||||
!isDescriptionExpanded && "line-clamp-2"
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
{showDescriptionToggle && (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setIsDescriptionExpanded(!isDescriptionExpanded); }}
|
||||
className="mt-1 text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
aria-label={isDescriptionExpanded ? "Show less" : "Read more"}
|
||||
>
|
||||
{isDescriptionExpanded ? 'Show less' : 'Read more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -147,87 +198,79 @@ export function AssistantCard({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Community-specific info */}
|
||||
{isCommunity && (
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{isAnonymous ? 'Anonymous' : (authorName || 'Unknown')}</span>
|
||||
{createdAt && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={12} />
|
||||
<span>{getRelativeTime(createdAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onLike?.();
|
||||
}}
|
||||
className={clsx(
|
||||
"flex items-center gap-1 hover:text-red-500 transition-colors",
|
||||
isLiked && "text-red-500"
|
||||
{/* Tools (reserve row height even when absent to align cards) */}
|
||||
<div className="flex items-center gap-2 min-h-[20px]">
|
||||
{displayTools.length > 0 && (
|
||||
<>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Tools:
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{displayTools.slice(0, 4).map((tool) => (
|
||||
tool.logo && (
|
||||
<PictureImg
|
||||
key={tool.name}
|
||||
src={tool.logo}
|
||||
alt={`${tool.name} logo`}
|
||||
className="w-4 h-4 rounded-sm object-cover flex-shrink-0"
|
||||
title={tool.name}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{displayTools.length > 4 && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
+{displayTools.length - 4}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<Heart size={14} className={isLiked ? "fill-current" : ""} />
|
||||
<span>{likeCount}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onShare?.();
|
||||
}}
|
||||
className="flex items-center gap-1 hover:text-blue-500 transition-colors"
|
||||
>
|
||||
<Share2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
{displayTools.length > 0 && (
|
||||
{/* Author and interaction info */}
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Tools:
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{displayTools.slice(0, 4).map((tool) => (
|
||||
tool.logo && (
|
||||
<PictureImg
|
||||
key={tool.name}
|
||||
src={tool.logo}
|
||||
alt={`${tool.name} logo`}
|
||||
className="w-4 h-4 rounded-sm object-cover flex-shrink-0"
|
||||
title={tool.name}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{displayTools.length > 4 && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
+{displayTools.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Complexity for pre-built templates */}
|
||||
{estimatedComplexity && (
|
||||
<div className="mt-2">
|
||||
<span className={clsx(
|
||||
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
|
||||
estimatedComplexity === 'beginner' && "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400",
|
||||
estimatedComplexity === 'intermediate' && "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400",
|
||||
estimatedComplexity === 'advanced' && "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400"
|
||||
)}>
|
||||
{estimatedComplexity}
|
||||
<span>
|
||||
{authorName ? (isAnonymous ? 'Anonymous' : authorName) : 'Rowboat'}
|
||||
</span>
|
||||
{createdAt && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={12} />
|
||||
<span>{getRelativeTime(createdAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onLike?.();
|
||||
}}
|
||||
className={clsx(
|
||||
"flex items-center gap-1 hover:text-red-500 transition-colors",
|
||||
isLiked && "text-red-500"
|
||||
)}
|
||||
>
|
||||
<Heart size={14} className={isLiked ? "fill-current" : ""} />
|
||||
<span>{likeCount || 0}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCopied(true);
|
||||
onShare?.();
|
||||
}}
|
||||
className="flex items-center gap-1 hover:text-blue-500 transition-colors"
|
||||
aria-label="Copy share URL"
|
||||
>
|
||||
<Share2 size={14} className={copied ? "text-blue-600" : undefined} />
|
||||
{copied && <span className="text-[10px] text-blue-600">Copied</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ interface AssistantItem {
|
|||
likeCount?: number;
|
||||
createdAt?: string;
|
||||
isLiked?: boolean;
|
||||
// Pre-built specific
|
||||
estimatedComplexity?: string;
|
||||
}
|
||||
|
||||
interface AssistantSectionProps {
|
||||
|
|
@ -225,7 +223,6 @@ export function AssistantSection({
|
|||
isAnonymous={item.isAnonymous}
|
||||
likeCount={item.likeCount}
|
||||
createdAt={item.createdAt}
|
||||
estimatedComplexity={item.estimatedComplexity}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
loading={loadingItemId === item.id}
|
||||
getUniqueTools={getUniqueTools}
|
||||
|
|
|
|||
357
apps/rowboat/components/common/UnifiedTemplatesSection.tsx
Normal file
357
apps/rowboat/components/common/UnifiedTemplatesSection.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Input } from "@heroui/react";
|
||||
import { Search, Filter } from 'lucide-react';
|
||||
import { AssistantCard } from './AssistantCard';
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface TemplateItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tools?: Array<{
|
||||
name: string;
|
||||
logo?: string;
|
||||
}>;
|
||||
// Community-specific
|
||||
authorName?: string;
|
||||
isAnonymous?: boolean;
|
||||
likeCount?: number;
|
||||
createdAt?: string;
|
||||
isLiked?: boolean;
|
||||
// Template type indicator
|
||||
type: 'prebuilt' | 'community';
|
||||
}
|
||||
|
||||
interface UnifiedTemplatesSectionProps {
|
||||
prebuiltTemplates: TemplateItem[];
|
||||
communityTemplates: TemplateItem[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onTemplateClick?: (item: TemplateItem) => void;
|
||||
onRetry?: () => void;
|
||||
loadingItemId?: string | null;
|
||||
onLike?: (item: TemplateItem) => void;
|
||||
onShare?: (item: TemplateItem) => void;
|
||||
getUniqueTools?: (item: TemplateItem) => Array<{ name: string; logo?: string }>;
|
||||
}
|
||||
|
||||
export function UnifiedTemplatesSection({
|
||||
prebuiltTemplates,
|
||||
communityTemplates,
|
||||
loading = false,
|
||||
error = null,
|
||||
onTemplateClick,
|
||||
onRetry,
|
||||
loadingItemId = null,
|
||||
onLike,
|
||||
onShare,
|
||||
getUniqueTools
|
||||
}: UnifiedTemplatesSectionProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedType, setSelectedType] = useState<'all' | 'prebuilt' | 'community'>('all');
|
||||
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(new Set());
|
||||
const [sortBy, setSortBy] = useState<'popular' | 'newest' | 'alphabetical'>('popular');
|
||||
|
||||
// Combine all templates
|
||||
const allTemplates = useMemo(() => {
|
||||
const combined = [
|
||||
...prebuiltTemplates.map(t => ({ ...t, type: 'prebuilt' as const })),
|
||||
...communityTemplates.map(t => ({ ...t, type: 'community' as const }))
|
||||
];
|
||||
return combined;
|
||||
}, [prebuiltTemplates, communityTemplates]);
|
||||
|
||||
// Get available categories
|
||||
const availableCategories = useMemo(() => {
|
||||
const categories = new Set(allTemplates.map(item => item.category));
|
||||
return Array.from(categories).sort();
|
||||
}, [allTemplates]);
|
||||
|
||||
// Filter and sort templates
|
||||
const filteredTemplates = useMemo(() => {
|
||||
let filtered = [...allTemplates];
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(item =>
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query) ||
|
||||
item.category.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply type filter
|
||||
if (selectedType !== 'all') {
|
||||
filtered = filtered.filter(item => item.type === selectedType);
|
||||
}
|
||||
|
||||
// Apply category filter
|
||||
if (selectedCategories.size > 0) {
|
||||
filtered = filtered.filter(item => selectedCategories.has(item.category));
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
filtered.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'newest':
|
||||
if (a.createdAt && b.createdAt) {
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
}
|
||||
return 0;
|
||||
case 'alphabetical':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'popular':
|
||||
default:
|
||||
// For prebuilt templates, use a default order
|
||||
// For community templates, use like count
|
||||
if (a.type === 'community' && b.type === 'community') {
|
||||
return (b.likeCount || 0) - (a.likeCount || 0);
|
||||
}
|
||||
if (a.type === 'prebuilt' && b.type === 'prebuilt') {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
// Prebuilt templates first, then community
|
||||
if (a.type === 'prebuilt' && b.type === 'community') return -1;
|
||||
if (a.type === 'community' && b.type === 'prebuilt') return 1;
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [allTemplates, searchQuery, selectedType, selectedCategories, sortBy]);
|
||||
|
||||
// Handle category toggle
|
||||
const toggleCategory = (category: string) => {
|
||||
setSelectedCategories(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(category)) {
|
||||
newSet.delete(category);
|
||||
} else {
|
||||
newSet.add(category);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedType('all');
|
||||
setSelectedCategories(new Set());
|
||||
setSortBy('popular');
|
||||
};
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return searchQuery || selectedType !== 'all' || selectedCategories.size > 0;
|
||||
}, [searchQuery, selectedType, selectedCategories]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-left mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Templates
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Discover and use pre-built and community templates.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-2">Loading templates...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-left mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Templates
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Discover and use pre-built and community templates.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500 dark:text-red-400">{error}</p>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="mt-4 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-left mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Templates
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Discover and use pre-built and community templates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Search and Type Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Search templates..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
startContent={<Search size={16} className="text-gray-400" />}
|
||||
className="max-w-md"
|
||||
classNames={{
|
||||
input: "focus:outline-none focus:ring-0 focus:border-gray-300 dark:focus:border-gray-600",
|
||||
inputWrapper: "focus-within:ring-0 focus-within:ring-offset-0 focus-within:border-gray-300 dark:focus-within:border-gray-600"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* Type Filter Pills */}
|
||||
<div className="flex gap-1">
|
||||
{[
|
||||
{ key: 'all', label: 'All', count: allTemplates.length },
|
||||
{ key: 'prebuilt', label: 'Library', count: prebuiltTemplates.length },
|
||||
{ key: 'community', label: 'Community', count: communityTemplates.length }
|
||||
].map(({ key, label, count }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSelectedType(key as any)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedType === key
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{label} ({count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="w-32 px-3 py-1.5 pr-8 border border-gray-200 dark:border-gray-700 rounded-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 appearance-none text-sm"
|
||||
>
|
||||
<option value="popular">Most Popular</option>
|
||||
<option value="newest">Newest First</option>
|
||||
<option value="alphabetical">A-Z</option>
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Filter size={14} />
|
||||
<span>Categories:</span>
|
||||
</div>
|
||||
{availableCategories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => toggleCategory(category)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedCategories.has(category)
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Clear Filters Button */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="space-y-4">
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{searchQuery || selectedType !== 'all' || selectedCategories.size > 0
|
||||
? 'No templates found matching your filters'
|
||||
: 'No templates available'
|
||||
}
|
||||
</p>
|
||||
{(searchQuery || selectedType !== 'all' || selectedCategories.size > 0) && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="mt-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {filteredTemplates.length} template{filteredTemplates.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredTemplates.map((item) => (
|
||||
<AssistantCard
|
||||
key={`${item.type}-${item.id}`}
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
description={item.description}
|
||||
category={item.category}
|
||||
tools={item.tools}
|
||||
authorName={item.authorName}
|
||||
isAnonymous={item.isAnonymous}
|
||||
likeCount={item.likeCount}
|
||||
createdAt={item.createdAt}
|
||||
onClick={() => onTemplateClick?.(item)}
|
||||
loading={loadingItemId === item.id}
|
||||
getUniqueTools={getUniqueTools}
|
||||
onLike={() => onLike?.(item)}
|
||||
onShare={() => onShare?.(item)}
|
||||
isLiked={item.isLiked}
|
||||
templateType={item.type}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,7 +25,6 @@ interface CommunityAssistant {
|
|||
likes: string[];
|
||||
copilotPrompt?: string;
|
||||
thumbnailUrl?: string | null;
|
||||
estimatedComplexity: 'beginner' | 'intermediate' | 'advanced';
|
||||
}
|
||||
|
||||
interface CommunitySectionProps {
|
||||
|
|
@ -48,8 +47,8 @@ export function CommunitySection({ onImport }: CommunitySectionProps) {
|
|||
const params = new URLSearchParams();
|
||||
if (filters?.searchQuery) params.append('search', filters.searchQuery);
|
||||
if (filters?.selectedCategory) params.append('category', filters.selectedCategory);
|
||||
|
||||
const url = `/api/community-assistants?${params}`;
|
||||
params.append('source', 'community');
|
||||
const url = `/api/assistant-templates?${params}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to fetch assistants');
|
||||
|
||||
|
|
@ -107,7 +106,7 @@ export function CommunitySection({ onImport }: CommunitySectionProps) {
|
|||
|
||||
try {
|
||||
const guestId = getGuestId();
|
||||
const response = await fetch(`/api/community-assistants/${assistant.id}/like`, {
|
||||
const response = await fetch(`/api/assistant-templates/${assistant.id}/like`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-guest-id': guestId,
|
||||
|
|
@ -144,7 +143,7 @@ export function CommunitySection({ onImport }: CommunitySectionProps) {
|
|||
const assistant = assistants.find(a => a.id === item.id);
|
||||
if (!assistant) return;
|
||||
|
||||
const url = `${window.location.origin}/community-assistants/${assistant.id}`;
|
||||
const url = `${window.location.origin}/assistant-templates/${assistant.id}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
// You could add a toast notification here
|
||||
console.log('URL copied to clipboard');
|
||||
|
|
|
|||
|
|
@ -95,6 +95,29 @@ export class CreateProjectUseCase implements ICreateProjectUseCase {
|
|||
}
|
||||
}
|
||||
|
||||
// Ensure the Gemini Image tool is always available by default
|
||||
const hasImageTool = (workflow.tools || []).some(t => t.isGeminiImage || t.name === 'Generate Image');
|
||||
if (!hasImageTool) {
|
||||
const imageTool = {
|
||||
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' as const,
|
||||
properties: {
|
||||
prompt: { type: 'string', description: 'Text prompt describing the image to generate' },
|
||||
modelName: { type: 'string', description: 'Optional Gemini model override' },
|
||||
},
|
||||
required: ['prompt'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
};
|
||||
workflow = {
|
||||
...workflow,
|
||||
tools: [...(workflow.tools || []), imageTool] as any,
|
||||
};
|
||||
}
|
||||
|
||||
// create project secret
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { z } from "zod";
|
||||
import { Workflow } from "../../../app/lib/types/workflow_types";
|
||||
|
||||
export const CommunityAssistant = z.object({
|
||||
export const AssistantTemplate = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
|
|
@ -19,21 +19,24 @@ export const CommunityAssistant = z.object({
|
|||
featured: z.boolean().default(false),
|
||||
isPublic: z.boolean().default(true),
|
||||
// Social features
|
||||
likes: z.array(z.string()).default([]), // Array of user IDs who liked it
|
||||
likes: z.array(z.string()).default([]),
|
||||
// Template-like metadata
|
||||
copilotPrompt: z.string().optional(),
|
||||
thumbnailUrl: z.string().optional(),
|
||||
estimatedComplexity: z.enum(['beginner', 'intermediate', 'advanced']).default('beginner'),
|
||||
// New field to indicate source of template
|
||||
source: z.enum(["library", "community"]),
|
||||
});
|
||||
|
||||
export type CommunityAssistant = z.infer<typeof CommunityAssistant>;
|
||||
export type AssistantTemplate = z.infer<typeof AssistantTemplate>;
|
||||
|
||||
export const CommunityAssistantLike = z.object({
|
||||
export const AssistantTemplateLike = z.object({
|
||||
id: z.string(),
|
||||
assistantId: z.string(),
|
||||
userId: z.string(), // Can be guest ID for anonymous users
|
||||
userEmail: z.string().optional(), // For logged-in users
|
||||
userId: z.string(),
|
||||
userEmail: z.string().optional(),
|
||||
createdAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type CommunityAssistantLike = z.infer<typeof CommunityAssistantLike>;
|
||||
export type AssistantTemplateLike = z.infer<typeof AssistantTemplateLike>;
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { z } from "zod";
|
||||
import { Filter, ObjectId } from "mongodb";
|
||||
import { db } from "@/app/lib/mongodb";
|
||||
import { AssistantTemplate, AssistantTemplateLike } from "@/src/entities/models/assistant-template";
|
||||
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||
|
||||
const DocSchema = AssistantTemplate.omit({ id: true });
|
||||
const LikeDocSchema = AssistantTemplateLike.omit({ id: true });
|
||||
|
||||
export class MongoDBAssistantTemplatesRepository {
|
||||
private readonly collection = db.collection<z.infer<typeof DocSchema>>("assistant_templates");
|
||||
private readonly likesCollection = db.collection<z.infer<typeof LikeDocSchema>>("assistant_template_likes");
|
||||
|
||||
async create(data: Omit<z.infer<typeof AssistantTemplate>, 'id' | 'publishedAt' | 'lastUpdatedAt'>): Promise<z.infer<typeof AssistantTemplate>> {
|
||||
const now = new Date().toISOString();
|
||||
const _id = new ObjectId();
|
||||
const doc: z.infer<typeof DocSchema> = { ...data, publishedAt: now, lastUpdatedAt: now } as any;
|
||||
await this.collection.insertOne({ ...doc, _id });
|
||||
return { ...doc, id: _id.toString() } as any;
|
||||
}
|
||||
|
||||
async fetch(id: string): Promise<z.infer<typeof AssistantTemplate> | null> {
|
||||
const result = await this.collection.findOne({ _id: new ObjectId(id) });
|
||||
if (!result) return null;
|
||||
return { ...result, id: result._id.toString() } as any;
|
||||
}
|
||||
|
||||
async list(filters: {
|
||||
category?: string;
|
||||
search?: string;
|
||||
featured?: boolean;
|
||||
isPublic?: boolean;
|
||||
authorId?: string;
|
||||
source?: 'library' | 'community';
|
||||
} = {}, cursor?: string, limit: number = 20): Promise<z.infer<ReturnType<typeof PaginatedList<typeof AssistantTemplate>>>> {
|
||||
const query: Filter<z.infer<typeof DocSchema>> = {};
|
||||
if (filters.category) query.category = filters.category;
|
||||
if (filters.featured !== undefined) query.featured = filters.featured;
|
||||
if (filters.isPublic !== undefined) query.isPublic = filters.isPublic;
|
||||
if (filters.authorId) query.authorId = filters.authorId;
|
||||
if (filters.source) query.source = filters.source;
|
||||
if (filters.search) {
|
||||
query.$or = [
|
||||
{ name: { $regex: filters.search, $options: 'i' } },
|
||||
{ description: { $regex: filters.search, $options: 'i' } },
|
||||
{ tags: { $in: [new RegExp(filters.search, 'i')] } },
|
||||
];
|
||||
}
|
||||
|
||||
const skip = cursor ? parseInt(cursor) : 0;
|
||||
const results = await this.collection.find(query).sort({ publishedAt: -1 }).skip(skip).limit(limit).toArray();
|
||||
const items = results.map(r => ({ ...r, id: r._id.toString() }));
|
||||
const nextCursor = results.length === limit ? (skip + limit).toString() : null;
|
||||
return { items, nextCursor } as any;
|
||||
}
|
||||
|
||||
async toggleLike(assistantId: string, userId: string, userEmail?: string): Promise<{ liked: boolean; likeCount: number }> {
|
||||
const existingLike = await this.likesCollection.findOne({ assistantId, userId });
|
||||
if (existingLike) {
|
||||
await this.likesCollection.deleteOne({ _id: existingLike._id });
|
||||
await this.collection.updateOne({ _id: new ObjectId(assistantId) }, { $inc: { likeCount: -1 }, $pull: { likes: userId } });
|
||||
return { liked: false, likeCount: await this.getLikeCount(assistantId) };
|
||||
} else {
|
||||
const now = new Date().toISOString();
|
||||
await this.likesCollection.insertOne({ assistantId, userId, userEmail, createdAt: now } as any);
|
||||
await this.collection.updateOne({ _id: new ObjectId(assistantId) }, { $inc: { likeCount: 1 }, $addToSet: { likes: userId } });
|
||||
return { liked: true, likeCount: await this.getLikeCount(assistantId) };
|
||||
}
|
||||
}
|
||||
|
||||
async getLikeCount(assistantId: string): Promise<number> {
|
||||
const result = await this.collection.findOne({ _id: new ObjectId(assistantId) }, { projection: { likeCount: 1 } });
|
||||
return result?.likeCount || 0;
|
||||
}
|
||||
|
||||
async getCategories(): Promise<string[]> {
|
||||
const categories = await this.collection.distinct('category', { isPublic: true });
|
||||
return categories.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
import { z } from "zod";
|
||||
import { Filter, ObjectId } from "mongodb";
|
||||
import { db } from "@/app/lib/mongodb";
|
||||
import { CommunityAssistant, CommunityAssistantLike } from "@/src/entities/models/community-assistant";
|
||||
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||
import { NotFoundError } from "@/src/entities/errors/common";
|
||||
|
||||
/**
|
||||
* MongoDB document schema for CommunityAssistant.
|
||||
* Excludes the 'id' field as it's represented by MongoDB's '_id'.
|
||||
*/
|
||||
const DocSchema = CommunityAssistant.omit({ id: true });
|
||||
|
||||
/**
|
||||
* MongoDB document schema for CommunityAssistantLike.
|
||||
*/
|
||||
const LikeDocSchema = CommunityAssistantLike.omit({ id: true });
|
||||
|
||||
/**
|
||||
* MongoDB implementation of the CommunityAssistants repository.
|
||||
*/
|
||||
export class MongoDBCommunityAssistantsRepository {
|
||||
private readonly collection = db.collection<z.infer<typeof DocSchema>>("community_assistants");
|
||||
private readonly likesCollection = db.collection<z.infer<typeof LikeDocSchema>>("community_assistant_likes");
|
||||
|
||||
async create(data: Omit<z.infer<typeof CommunityAssistant>, 'id'>): Promise<z.infer<typeof CommunityAssistant>> {
|
||||
const now = new Date().toISOString();
|
||||
const _id = new ObjectId();
|
||||
|
||||
const doc: z.infer<typeof DocSchema> = {
|
||||
...data,
|
||||
publishedAt: now,
|
||||
lastUpdatedAt: now,
|
||||
};
|
||||
|
||||
await this.collection.insertOne({
|
||||
...doc,
|
||||
_id,
|
||||
});
|
||||
|
||||
return {
|
||||
...doc,
|
||||
id: _id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async fetch(id: string): Promise<z.infer<typeof CommunityAssistant> | null> {
|
||||
const result = await this.collection.findOne({ _id: new ObjectId(id) });
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
...result,
|
||||
id: result._id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async list(filters: {
|
||||
category?: string;
|
||||
search?: string;
|
||||
featured?: boolean;
|
||||
isPublic?: boolean;
|
||||
authorId?: string;
|
||||
} = {}, cursor?: string, limit: number = 20): Promise<z.infer<ReturnType<typeof PaginatedList<typeof CommunityAssistant>>>> {
|
||||
const query: Filter<z.infer<typeof DocSchema>> = {};
|
||||
|
||||
if (filters.category) {
|
||||
query.category = filters.category;
|
||||
}
|
||||
|
||||
if (filters.featured !== undefined) {
|
||||
query.featured = filters.featured;
|
||||
}
|
||||
|
||||
if (filters.isPublic !== undefined) {
|
||||
query.isPublic = filters.isPublic;
|
||||
}
|
||||
|
||||
if (filters.authorId) {
|
||||
query.authorId = filters.authorId;
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
query.$or = [
|
||||
{ name: { $regex: filters.search, $options: 'i' } },
|
||||
{ description: { $regex: filters.search, $options: 'i' } },
|
||||
{ tags: { $in: [new RegExp(filters.search, 'i')] } },
|
||||
];
|
||||
}
|
||||
|
||||
const skip = cursor ? parseInt(cursor) : 0;
|
||||
const results = await this.collection
|
||||
.find(query)
|
||||
.sort({ publishedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
|
||||
const items = results.map(result => ({
|
||||
...result,
|
||||
id: result._id.toString(),
|
||||
}));
|
||||
|
||||
const nextCursor = results.length === limit ? (skip + limit).toString() : null;
|
||||
|
||||
return {
|
||||
items,
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<Omit<z.infer<typeof CommunityAssistant>, 'id' | 'publishedAt'>>): Promise<z.infer<typeof CommunityAssistant> | null> {
|
||||
const now = new Date().toISOString();
|
||||
const updateData = {
|
||||
...data,
|
||||
lastUpdatedAt: now,
|
||||
};
|
||||
|
||||
const result = await this.collection.findOneAndUpdate(
|
||||
{ _id: new ObjectId(id) },
|
||||
{ $set: updateData },
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
...result,
|
||||
id: result._id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.collection.deleteOne({ _id: new ObjectId(id) });
|
||||
return result.deletedCount > 0;
|
||||
}
|
||||
|
||||
async incrementDownloadCount(id: string): Promise<void> {
|
||||
await this.collection.updateOne(
|
||||
{ _id: new ObjectId(id) },
|
||||
{ $inc: { downloadCount: 1 } }
|
||||
);
|
||||
}
|
||||
|
||||
async toggleLike(assistantId: string, userId: string, userEmail?: string): Promise<{ liked: boolean; likeCount: number }> {
|
||||
const likeId = new ObjectId();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Check if user already liked this assistant
|
||||
const existingLike = await this.likesCollection.findOne({
|
||||
assistantId: assistantId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (existingLike) {
|
||||
// Unlike: remove the like
|
||||
await this.likesCollection.deleteOne({ _id: existingLike._id });
|
||||
await this.collection.updateOne(
|
||||
{ _id: new ObjectId(assistantId) },
|
||||
{
|
||||
$inc: { likeCount: -1 },
|
||||
$pull: { likes: userId }
|
||||
}
|
||||
);
|
||||
return { liked: false, likeCount: await this.getLikeCount(assistantId) };
|
||||
} else {
|
||||
// Like: add the like
|
||||
await this.likesCollection.insertOne({
|
||||
_id: likeId,
|
||||
assistantId: assistantId,
|
||||
userId,
|
||||
userEmail,
|
||||
createdAt: now,
|
||||
});
|
||||
await this.collection.updateOne(
|
||||
{ _id: new ObjectId(assistantId) },
|
||||
{
|
||||
$inc: { likeCount: 1 },
|
||||
$addToSet: { likes: userId }
|
||||
}
|
||||
);
|
||||
return { liked: true, likeCount: await this.getLikeCount(assistantId) };
|
||||
}
|
||||
}
|
||||
|
||||
async getLikeCount(assistantId: string): Promise<number> {
|
||||
const result = await this.collection.findOne(
|
||||
{ _id: new ObjectId(assistantId) },
|
||||
{ projection: { likeCount: 1 } }
|
||||
);
|
||||
return result?.likeCount || 0;
|
||||
}
|
||||
|
||||
async getUserLikes(assistantId: string, userId: string): Promise<boolean> {
|
||||
const like = await this.likesCollection.findOne({
|
||||
assistantId: assistantId,
|
||||
userId,
|
||||
});
|
||||
return !!like;
|
||||
}
|
||||
|
||||
async getCategories(): Promise<string[]> {
|
||||
const categories = await this.collection.distinct('category', { isPublic: true });
|
||||
return categories.filter(Boolean);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue