Merge pull request #265 from rowboatlabs/dev

Dev
This commit is contained in:
arkml 2025-09-16 18:18:05 +05:30 committed by GitHub
commit 596050e3e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 2856 additions and 712 deletions

View file

@ -83,7 +83,7 @@ Name of the agent
#### Behaviour
- **Agent Type**: Choose from `conversation`, `internal`, or `pipeline`
- **Model**: Select the LLM model (GPT-4o, GPT-4o-mini, google/gemini-2.5-flash, etc.)
- **Model**: Select the LLM model (GPT-4.1, GPT-4o, google/gemini-2.5-flash, etc.)
#### RAG
- **Add Source**: Connect data sources to enable RAG capabilities for the agent

View file

@ -25,7 +25,7 @@ However, you can also configure custom LLM providers (e.g. LiteLLM, OpenRouter)
```bash
export PROVIDER_DEFAULT_MODEL=claude-3-7-sonnet-latest
export PROVIDER_COPILOT_MODEL=gpt-4o
export PROVIDER_COPILOT_MODEL=gpt-4.1
```
**Notes:**

View file

@ -11,7 +11,7 @@ from db import write_test_result, get_scenario_by_id
from rowboat import Client, StatefulChat
openai_client = OpenAI()
MODEL_NAME = "gpt-4o"
MODEL_NAME = "gpt-4.1"
ROWBOAT_API_HOST = os.environ.get("ROWBOAT_API_HOST", "http://127.0.0.1:3000").strip()
async def simulate_simulation(

View file

@ -0,0 +1,264 @@
"use server";
import { z } from 'zod';
import { authCheck } from "./auth.actions";
import { MongoDBAssistantTemplatesRepository } from '@/src/infrastructure/repositories/mongodb.assistant-templates.repository';
import { prebuiltTemplates } from '@/app/lib/prebuilt-cards';
import { USE_AUTH } from '@/app/lib/feature_flags';
// import { ensureLibraryTemplatesSeeded } from '@/app/lib/assistant_templates_seed';
const repo = new MongoDBAssistantTemplatesRepository();
// Helper function to serialize MongoDB objects for client components
function serializeTemplate(template: any) {
return JSON.parse(JSON.stringify(template));
}
function serializeTemplates(templates: any[]) {
return templates.map(serializeTemplate);
}
const ListTemplatesSchema = 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 CreateTemplateSchema = 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(),
});
type ListResponse = { items: any[]; nextCursor: string | null };
function buildPrebuiltList(params: z.infer<typeof ListTemplatesSchema>): ListResponse {
const allPrebuilt = Object.entries(prebuiltTemplates).map(([key, tpl]) => ({
id: `prebuilt:${key}`,
name: (tpl as any).name || key,
description: (tpl as any).description || '',
category: (tpl as any).category || 'Other',
tools: (tpl as any).tools || [],
createdAt: (tpl as any).lastUpdatedAt || undefined,
source: 'library' as const,
}));
let filtered = allPrebuilt;
if (params.category) {
filtered = filtered.filter(t => t.category === params.category);
}
if (params.search) {
const q = params.search.toLowerCase();
filtered = filtered.filter(t =>
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.category.toLowerCase().includes(q)
);
}
const startIndex = params.cursor ? parseInt(params.cursor, 10) || 0 : 0;
const endIndex = Math.min(startIndex + params.limit, filtered.length);
const pageItems = filtered.slice(startIndex, endIndex);
const nextCursor = endIndex < filtered.length ? String(endIndex) : null;
return { items: pageItems, nextCursor };
}
export async function listAssistantTemplates(request: z.infer<typeof ListTemplatesSchema>): Promise<ListResponse> {
const user = await authCheck();
// Prebuilt templates should never be seeded to DB
const params = ListTemplatesSchema.parse(request);
// If source specified, return that subset; for 'library' use in-memory prebuilt from code
if (params.source === 'library') {
const { items, nextCursor } = buildPrebuiltList(params);
return { items: serializeTemplates(items), nextCursor };
}
if (params.source === 'community') {
const result = await repo.list({
category: params.category,
search: params.search,
featured: params.featured,
isPublic: true,
source: 'community',
}, params.cursor, params.limit);
const itemsWithLikeStatus = await addLikeStatusToTemplates(result.items, user.id);
return { ...result, items: serializeTemplates(itemsWithLikeStatus) };
}
// No source: return prebuilt from code + first page of community from DB
const prebuilt = buildPrebuiltList({ ...params, source: 'library' } as any).items;
const communityPage = await repo.list({
category: params.category,
search: params.search,
featured: params.featured,
isPublic: true,
source: 'community',
}, undefined, params.limit);
const items = [...prebuilt, ...communityPage.items];
return { items: serializeTemplates(items), nextCursor: null };
}
// Get a specific template by ID with model transformation
export async function getAssistantTemplate(templateId: string) {
const user = await authCheck();
// Prebuilt: load directly from code
if (templateId.startsWith('prebuilt:')) {
const key = templateId.replace('prebuilt:', '');
const originalTemplate = prebuiltTemplates[key as keyof typeof prebuiltTemplates];
if (!originalTemplate) throw new Error('Template not found');
const defaultModel = process.env.PROVIDER_DEFAULT_MODEL || 'gpt-4.1';
const transformedWorkflow = JSON.parse(JSON.stringify(originalTemplate));
if (transformedWorkflow.agents && Array.isArray(transformedWorkflow.agents)) {
transformedWorkflow.agents.forEach((agent: any) => {
if (agent.model === '') {
agent.model = defaultModel;
}
});
}
// Return minimal shape expected by callers
const result = {
id: templateId,
name: (originalTemplate as any).name || key,
description: (originalTemplate as any).description || '',
category: (originalTemplate as any).category || 'Other',
workflow: transformedWorkflow,
source: 'library' as const,
};
return serializeTemplate(result);
}
// Community template from DB
const template = await repo.fetch(templateId);
if (!template) throw new Error('Template not found');
return serializeTemplate(template);
}
export async function getAssistantTemplateCategories() {
const user = await authCheck();
const categories = await repo.getCategories();
return { items: categories };
}
export async function createAssistantTemplate(data: z.infer<typeof CreateTemplateSchema>) {
const user = await authCheck();
const validatedData = CreateTemplateSchema.parse(data);
let authorName = 'Anonymous';
let authorEmail: string | undefined;
if (USE_AUTH) {
try {
const { auth0 } = await import('@/app/lib/auth0');
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 (validatedData.isAnonymous) {
authorName = 'Anonymous';
authorEmail = undefined;
}
const created = await repo.create({
name: validatedData.name,
description: validatedData.description,
category: validatedData.category,
authorId: user.id,
authorName,
authorEmail,
isAnonymous: validatedData.isAnonymous,
workflow: validatedData.workflow,
tags: validatedData.tags,
copilotPrompt: validatedData.copilotPrompt,
thumbnailUrl: validatedData.thumbnailUrl,
downloadCount: 0,
likeCount: 0,
featured: false,
isPublic: true,
likes: [],
source: 'community',
});
return serializeTemplate(created);
}
export async function deleteAssistantTemplate(id: string) {
const user = await authCheck();
const item = await repo.fetch(id);
if (!item) {
throw new Error('Template not found');
}
// Disallow deleting library/prebuilt items
if ((item as any).source === 'library' || item.authorId === 'rowboat-system') {
throw new Error('Not allowed to delete this template');
}
if (item.authorId !== user.id) {
// Do not reveal existence
throw new Error('Template not found');
}
const ok = await repo.deleteByIdAndAuthor(id, user.id);
if (!ok) {
throw new Error('Template not found');
}
return { success: true };
}
export async function toggleTemplateLike(id: string) {
const user = await authCheck();
// Use authenticated user ID instead of guest ID
const result = await repo.toggleLike(id, user.id);
return serializeTemplate(result);
}
export async function getCurrentUser() {
const user = await authCheck();
return { id: user.id };
}
// Helper function to add isLiked status to templates
async function addLikeStatusToTemplates(templates: any[], userId: string) {
if (templates.length === 0) return templates;
// Get all template IDs
const templateIds = templates.map(t => t.id);
// Check which templates the user has liked
const likedTemplates = await repo.getLikedTemplates(templateIds, userId);
const likedSet = new Set(likedTemplates);
// Add isLiked property to each template
return templates.map(template => ({
...template,
isLiked: likedSet.has(template.id)
}));
}

View file

@ -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) {
@ -93,12 +97,24 @@ export async function createProjectFromWorkflowJson(formData: FormData): Promise
const workflowJson = formData.get('workflowJson') as string;
try {
// Parse workflow and apply default model to blank agent models
const workflow = JSON.parse(workflowJson);
const defaultModel = process.env.PROVIDER_DEFAULT_MODEL || 'gpt-4o';
if (workflow.agents && Array.isArray(workflow.agents)) {
workflow.agents.forEach((agent: any) => {
if (agent.model === '') {
agent.model = defaultModel;
}
});
}
const project = await createProjectController.execute({
userId: user.id,
data: {
name: name || '',
mode: {
workflowJson,
workflowJson: JSON.stringify(workflow),
},
},
});

View file

@ -0,0 +1,65 @@
"use server";
import { z } from "zod";
import { nanoid } from "nanoid";
import { Workflow } from "@/app/lib/types/workflow_types";
import { db } from "@/app/lib/mongodb";
import { SHARED_WORKFLOWS_COLLECTION } from "@/src/infrastructure/repositories/mongodb.shared-workflows.indexes";
import { requireAuth } from "@/app/lib/auth";
const DEFAULT_TTL_SECONDS = 60 * 60 * 24; // 24 hours
interface SharedWorkflowDoc {
_id: string;
workflow: unknown;
createdAt: Date;
expiresAt: Date;
}
function validateWorkflowJson(obj: unknown) {
const parsed = Workflow.safeParse(obj);
if (!parsed.success) {
const message = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
throw new Error(`Invalid workflow JSON: ${message}`);
}
return parsed.data;
}
export async function createSharedWorkflowFromJson(json: string): Promise<{ id: string; ttlSeconds: number; }>
{
// Require an authenticated user (respects guest mode when auth is disabled)
await requireAuth();
const obj = JSON.parse(json);
const workflow = validateWorkflowJson(obj);
const coll = db.collection<SharedWorkflowDoc>(SHARED_WORKFLOWS_COLLECTION);
const id = nanoid();
const now = new Date();
const expiresAt = new Date(now.getTime() + DEFAULT_TTL_SECONDS * 1000);
await coll.insertOne({ _id: id, workflow, createdAt: now, expiresAt });
return { id, ttlSeconds: DEFAULT_TTL_SECONDS };
}
/**
* Load a shared workflow by ephemeral share id stored in MongoDB.
* Expected when the query param `shared` is present in the UI.
*/
export async function loadSharedWorkflow(id: string): Promise<z.infer<typeof Workflow>> {
// Ensure caller is authenticated (guest allowed when auth disabled)
await requireAuth();
// Look up by shared id in MongoDB
const coll = db.collection<SharedWorkflowDoc>(SHARED_WORKFLOWS_COLLECTION);
const doc = await coll.findOne(
{ _id: id },
{ projection: { workflow: 1, expiresAt: 1 } }
);
if (!doc) {
throw new Error('Not found or expired');
}
if (doc.expiresAt && doc.expiresAt.getTime() <= Date.now()) {
throw new Error('Not found or expired');
}
return validateWorkflowJson(doc.workflow);
}

View file

@ -1,12 +1,14 @@
import { NextRequest, NextResponse } from 'next/server';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { S3Client, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { Readable } from 'stream';
export async function GET(request: NextRequest, props: { params: Promise<{ path: string[] }> }) {
// Serves generated images from S3 by UUID-only path: /api/generated-images/{id}
// Reconstructs the S3 key using the same sharding logic as image creation.
export async function GET(request: NextRequest, props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const path = params.path || [];
if (path.length < 3) {
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
const id = params.id;
if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
const bucket = process.env.RAG_UPLOADS_S3_BUCKET || '';
@ -23,8 +25,31 @@ export async function GET(request: NextRequest, props: { params: Promise<{ path:
} as any : undefined,
});
const filename = path[path.length - 1];
const key = `generated_images/${path.join('/')}`;
// Reconstruct directory sharding from last two characters of UUID
const last2 = id.slice(-2).padStart(2, '0');
const dirA = last2.charAt(0);
const dirB = last2.charAt(1);
const baseKey = `generated_images/${dirA}/${dirB}/${id}`;
// Try known extensions in order used by generator
const exts = ['.png', '.jpg', '.webp'];
let foundExt: string | null = null;
for (const ext of exts) {
try {
await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: `${baseKey}${ext}` }));
foundExt = ext;
break;
} catch {
// continue trying next extension
}
}
if (!foundExt) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const key = `${baseKey}${foundExt}`;
const filename = `${id}${foundExt}`;
try {
const resp = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
const contentType = resp.ContentType || 'application/octet-stream';

View file

@ -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 });
}
}

View file

@ -1,101 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { Workflow } from '@/app/lib/types/workflow_types';
import { nanoid } from 'nanoid';
import { db } from '@/app/lib/mongodb';
import { SHARED_WORKFLOWS_COLLECTION } from '@/src/infrastructure/repositories/mongodb.shared-workflows.indexes';
const DEFAULT_TTL_SECONDS = 60 * 60 * 24; // 24 hours
interface SharedWorkflowDoc {
_id: string;
workflow: unknown;
createdAt: Date;
expiresAt: Date;
}
function validateWorkflowJson(obj: unknown) {
const parsed = Workflow.safeParse(obj);
if (!parsed.success) {
const message = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
throw new Error(`Invalid workflow JSON: ${message}`);
}
return parsed.data;
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get('id');
const url = searchParams.get('url');
if (id) {
const coll = db.collection<SharedWorkflowDoc>(SHARED_WORKFLOWS_COLLECTION);
const doc = await coll.findOne(
{ _id: id },
{ projection: { workflow: 1, expiresAt: 1 } }
);
if (!doc) {
return NextResponse.json({ error: 'Not found or expired' }, { status: 404 });
}
// Optional safeguard if TTL not yet cleaned up
if (doc.expiresAt && doc.expiresAt.getTime() <= Date.now()) {
return NextResponse.json({ error: 'Not found or expired' }, { status: 404 });
}
return NextResponse.json(doc.workflow);
}
if (!url) {
return NextResponse.json({ error: 'Missing "id" or "url" query param' }, { status: 400 });
}
if (url.startsWith('blob:')) {
return NextResponse.json({ error: 'Blob URLs are not accessible from the server. Use POST /api/shared-workflow to upload the workflow and share its id.' }, { status: 400 });
}
const isHttp = url.startsWith('http://') || url.startsWith('https://');
if (!isHttp) {
return NextResponse.json({ error: 'Only http(s) URLs are supported in the "url" param' }, { status: 400 });
}
const resp = await fetch(url, { cache: 'no-store' });
if (!resp.ok) {
return NextResponse.json({ error: `Failed to fetch URL: ${resp.status} ${resp.statusText}` }, { status: 400 });
}
const text = await resp.text();
const obj = JSON.parse(text);
const workflow = validateWorkflowJson(obj);
return NextResponse.json(workflow);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: message }, { status: 400 });
}
}
export async function POST(req: NextRequest) {
try {
const contentType = req.headers.get('content-type') || '';
let body: any;
if (contentType.includes('application/json')) {
body = await req.json();
} else {
const text = await req.text();
body = JSON.parse(text);
}
const workflowCandidate = typeof body?.workflow === 'object' ? body.workflow : body;
const workflow = validateWorkflowJson(workflowCandidate);
const id = nanoid();
const coll = db.collection<SharedWorkflowDoc>(SHARED_WORKFLOWS_COLLECTION);
const now = new Date();
const expiresAt = new Date(now.getTime() + DEFAULT_TTL_SECONDS * 1000);
await coll.insertOne({ _id: id, workflow, createdAt: now, expiresAt });
const origin = new URL(req.url).origin;
const href = `${origin}/api/shared-workflow?id=${id}`;
return NextResponse.json({ id, href, ttlSeconds: DEFAULT_TTL_SECONDS });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: message }, { status: 400 });
}
}

View file

@ -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);
}

View file

@ -0,0 +1,210 @@
import { db } from "@/app/lib/mongodb";
import { prebuiltTemplates } from "@/app/lib/prebuilt-cards";
// Cache to track which templates have been seeded
const seededTemplates = new Set<string>();
// 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();
console.log('[PrebuiltTemplates] Starting template seeding...');
const entries = Object.entries(prebuiltTemplates);
const currentPrebuiltKeys = new Set<string>(entries.map(([key]) => key));
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;
// Upsert to avoid race-condition duplicates
const filter = {
authorName: "Rowboat",
source: 'library',
tags: { $all: ["__library__", `prebuilt:${prebuiltKey}`] },
} as const;
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.updateOne(
filter as any,
{ $setOnInsert: doc } as any,
{ upsert: true } as any
);
}
// Strong reconcile: ensure DB exactly matches code exports
try {
const libCursor = collection.find({
source: 'library',
authorName: 'Rowboat',
tags: { $in: ["__library__"] },
}, { projection: { _id: 1, tags: 1, name: 1, publishedAt: 1 } });
type DocLite = { _id: any; tags?: string[]; name?: string; publishedAt?: string };
const keyToDocs = new Map<string, DocLite[]>();
const orphans: any[] = [];
const orphanNames: string[] = [];
for await (const doc of libCursor as any as AsyncIterable<DocLite>) {
const prebuiltTag = (doc.tags || []).find(t => typeof t === 'string' && t.startsWith('prebuilt:'));
if (!prebuiltTag) {
orphans.push(doc._id);
if (doc.name) orphanNames.push(doc.name);
continue;
}
const key = prebuiltTag.replace('prebuilt:', '');
if (!currentPrebuiltKeys.has(key)) {
orphans.push(doc._id);
if (doc.name) orphanNames.push(doc.name);
continue;
}
const arr = keyToDocs.get(key) || [];
arr.push(doc);
keyToDocs.set(key, arr);
}
// Delete orphans (no key or key not in code)
if (orphans.length > 0) {
await collection.deleteMany({ _id: { $in: orphans } } as any);
console.log(`[PrebuiltTemplates] Reconciled by deleting ${orphans.length} orphans/removed templates:`, orphanNames);
}
// For each key, keep newest by publishedAt; delete others
const dupRemovals: any[] = [];
for (const [key, docs] of keyToDocs.entries()) {
if (docs.length <= 1) continue;
const sorted = [...docs].sort((a, b) => String(b.publishedAt || '').localeCompare(String(a.publishedAt || '')));
const extras = sorted.slice(1).map(d => d._id);
dupRemovals.push(...extras);
}
if (dupRemovals.length > 0) {
await collection.deleteMany({ _id: { $in: dupRemovals } } as any);
console.log(`[PrebuiltTemplates] De-duplicated ${dupRemovals.length} duplicate templates`);
}
} catch (reconcileErr) {
console.error('[PrebuiltTemplates] Reconcile (strict sync) failed:', reconcileErr);
}
} catch (err) {
// best-effort seed; do not throw to avoid breaking requests
console.error("ensureLibraryTemplatesSeeded error:", err);
}
}
// Lazy seed: only seed a specific template when it's requested
export async function ensureTemplateSeeded(prebuiltKey: string): Promise<void> {
if (seededTemplates.has(prebuiltKey)) {
return; // Already seeded
}
const tpl = prebuiltTemplates[prebuiltKey as keyof typeof prebuiltTemplates];
if (!tpl) {
console.warn(`[PrebuiltTemplates] Template not found: ${prebuiltKey}`);
return;
}
try {
const collection = db.collection("assistant_templates");
const now = new Date().toISOString();
const name = (tpl as any).name || prebuiltKey;
// Check if already exists
const existing = await collection.findOne({
name,
authorName: "Rowboat",
tags: { $in: [ `prebuilt:${prebuiltKey}`, "__library__" ] }
});
if (existing) {
// Update existing template with current model configuration
const defaultModel = process.env.PROVIDER_DEFAULT_MODEL || 'gpt-4.1';
const updatedWorkflow = JSON.parse(JSON.stringify(tpl));
// Apply model transformation
if (updatedWorkflow.agents && Array.isArray(updatedWorkflow.agents)) {
updatedWorkflow.agents.forEach((agent: any) => {
if (agent.model === '') {
agent.model = defaultModel;
}
});
}
await collection.updateOne(
{ _id: existing._id },
{
$set: {
workflow: updatedWorkflow,
lastUpdatedAt: now,
}
}
);
console.log(`[PrebuiltTemplates] Updated template: ${name}`);
} else {
// Create new template with model transformation
const defaultModel = process.env.PROVIDER_DEFAULT_MODEL || 'gpt-4.1';
const transformedWorkflow = JSON.parse(JSON.stringify(tpl));
// Apply model transformation
if (transformedWorkflow.agents && Array.isArray(transformedWorkflow.agents)) {
transformedWorkflow.agents.forEach((agent: any) => {
if (agent.model === '') {
agent.model = defaultModel;
}
});
}
const doc = {
name,
description: (tpl as any).description || "",
category: (tpl as any).category || "Other",
authorId: "rowboat-system",
authorName: "Rowboat",
authorEmail: undefined,
isAnonymous: false,
workflow: transformedWorkflow,
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);
console.log(`[PrebuiltTemplates] Created template: ${name}`);
}
seededTemplates.add(prebuiltKey);
} catch (err) {
console.error(`[PrebuiltTemplates] Error seeding template ${prebuiltKey}:`, err);
}
}

View file

@ -29,7 +29,7 @@ export function validateConfigChanges(configType: string, configChanges: Record<
instructions: 'test',
prompts: [],
tools: [],
model: 'gpt-4o',
model: 'gpt-4.1',
ragReturnType: 'chunks',
ragK: 10,
connectedAgents: [],

View file

@ -0,0 +1,36 @@
import { z } from 'zod';
// Returns the list of built-in tools that should appear by default
// in the workflow editor and be usable at runtime without attaching
// them to the workflow. These are displayed as read-only library tools.
// Note: avoid importing WorkflowTool here to prevent circular deps.
// Return a structurally compatible object instead.
export function getDefaultTools(): Array<any> {
// Show built-in tools only when a public, non-secret flag is set.
// Avoids exposing real secrets in client bundles.
const hasGoogleKeyFlag = (process.env.NEXT_PUBLIC_HAS_GOOGLE_API_KEY || '').toLowerCase() === 'true';
if (!hasGoogleKeyFlag) return [];
return [
{
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,
isLibrary: 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,
},
},
];
}

View file

@ -5,8 +5,7 @@
"type": "conversation",
"description": "Hub agent to answer product information questions (using RAG) and delivery status questions.",
"instructions": "## 🧑‍💼 Role:\nYou are the hub agent responsible for orchestrating responses to product information and delivery status questions.\n\n---\n## ⚙️ Steps to Follow:\n1. Greet the user and ask how you can help. Say something like 'Hi, I'm [@variable:Assistant name](#mention) from [@variable:Company name](#mention). How can I help you today?'\n2. Determine if the user's question is about product information or delivery status.\n3. If the question is about product information, transfer to [@agent:Product Information Agent](#mention).\n4. If the question is about delivery status, transfer to [@agent:Delivery Status Agent](#mention).\n5. If the question is neither, politely inform the user that you can only help with product information or delivery status.\n6. Return the final answer to the user.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Routing product information questions.\n- Routing delivery status questions.\n\n❌ Out of Scope:\n- Directly answering product or delivery questions.\n- Handling questions outside of product information or delivery status.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Clearly identify the type of user query.\n- Route to the correct agent.\n\n🚫 Don'ts:\n- Do not attempt to answer questions directly.\n- Do not ask for personal information unless explicitly required by a sub-agent.\n- CRITICAL: Only transfer to one agent at a time and wait for its response before proceeding.\n\n",
"examples": "- **User** : What are the features of product X?\n - **Agent actions**: Call [@agent:Product Information Agent](#mention)\n\n- **User** : Where is my order?\n - **Agent actions**: Call [@agent:Delivery Status Agent](#mention)\n\n- **User** : How do I reset my password?\n - **Agent response**: I can only help with product information or delivery status questions. How else can I assist you today?",
"model": "gpt-4o",
"model": "",
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
@ -20,7 +19,7 @@
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are an internal agent that answers product information questions using RAG data sources. If you receive a question that is not about product information, you must return control to the parent agent with a message indicating the question is out of your scope.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the product information question from the parent agent.\n2. Determine if the question is about product information.\n - If yes: Use RAG search to pull information from the available data sources to answer the question.\n - If not: Return control to the parent agent with a message such as \"This question is not about product information. Returning to parent agent.\"\n3. Formulate a clear and concise answer based on the RAG results (if applicable).\n4. If question is out of scope call [@agent:Product & Delivery Assistant](#mention) \n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Answering product information questions using RAG.\n- Returning control to parent if the question is out of scope.\n\n❌ Out of Scope:\n- Handling delivery status questions.\n- Interacting directly with the user.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Use RAG search to find relevant information for product questions.\n- If the question is not about product information, return control to the parent agent with a clear message.\n\n🚫 Don'ts:\n- Do not answer questions outside of product information.\n- Do not interact with the user directly.\n- Do not ignore out-of-scope questions; always return to parent.\n",
"examples": "\n",
"model": "gpt-4o",
"model": "",
"locked": false,
"toggleAble": true,
"ragDataSources": [
@ -39,7 +38,7 @@
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are an internal agent that answers delivery status questions. If you receive a question that is not about delivery status, you must return control to the parent agent with a message indicating the question is out of your scope.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the delivery status question from the parent agent.\n2. Determine if the question is about delivery status.\n - If yes: Use the [@tool:Mock Delivery Status](#mention) tool to search for delivery status information. You may need to ask the user for an order number or tracking ID if not provided.\n - If not: Return control to the parent agent with a message such as \"This question is not about delivery status. Returning to parent agent.\"\n3. Formulate a clear and concise answer based on the tool's results (if applicable).\n4. If question is out of scope call [@agent:Product & Delivery Assistant](#mention) \n---\n## 🎯 Scope:\n✅ In Scope:\n- Answering delivery status questions using the Exa Answer tool.\n- Returning control to parent if the question is out of scope.\n\n❌ Out of Scope:\n- Handling product information questions.\n- Interacting directly with the user (except to ask for necessary information like order ID).\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Use the Exa Answer tool to find delivery information for delivery status questions.\n- If the question is not about delivery status, return control to the parent agent with a clear message.\n- Ask for order details if needed.\n\n🚫 Don'ts:\n- Do not answer questions outside of delivery status.\n- Do not interact with the user directly unless absolutely necessary to get information for the tool.\n- Do not ignore out-of-scope questions; always return to parent.\n",
"examples": "\n",
"model": "gpt-4o",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -108,6 +107,6 @@
"lastUpdatedAt": "2025-09-11T18:51:15.548Z",
"name": "Customer Support",
"description": "Answers product information (RAG) and delivery status (MCP) questions.",
"category": "Customer Support",
"category": "Support",
"copilotPrompt": "Give me a brief explanation of this assistant."
}

View file

@ -0,0 +1,125 @@
{
"agents": [
{
"name": "Classification Agent",
"type": "pipeline",
"description": "Classifies a single email into one of the four Eisenhower Matrix categories.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nClassify the provided email into one of the four Eisenhower Matrix categories:\n- Urgent + Important: Critical, requires immediate action (e.g., legal, financial, investor, user-blocking).\n- Not Urgent + Important: High-value, strategic, should be scheduled (e.g., partnerships, key coordination).\n- Urgent + Not Important: Time-sensitive but delegable (e.g., routine ops, technical updates).\n- Not Urgent + Not Important: Low-value, noise, spam, promotions (should be labeled as 'Low Priority').\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the email (subject, sender, body, etc.).\n2. Analyze the content and assign the correct category.\n3. Return the category as a string.\n\n---\n## 📋 Guidelines:\n- Use the provided definitions for each category.\n- Be accurate and consistent.\n- Do not perform any actions other than classification.",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"outputVisibility": "internal",
"controlType": "relinquish_to_parent",
"maxCallsPerParentAgent": 3
},
{
"name": "Label Agent",
"type": "pipeline",
"description": "Applies the correct Gmail label to the email based on its Eisenhower Matrix category.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nApply the correct Gmail label to the email based on its Eisenhower Matrix category, using the provided label IDs.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the email's message_id and its assigned category.\n2. Map the category to the correct label ID using these variables:\n - 'Important + Not Urgent': [@variable:Important + Not Urgent Label ID](#mention)\n - 'Important + Urgent': [@variable:Important + Urgent Label ID](#mention)\n - 'Not Important + Urgent': [@variable:Not Important + Urgent Label ID](#mention)\n - 'Not Important + Not Urgent': [@variable:Not Important + Not Urgent Label ID](#mention)\n3. Use [@tool:Modify email labels](#mention) to add the correct label ID to the email (add_label_ids).\n4. Return a status indicating the label was applied.\n\n---\n## 📋 Guidelines:\n- Always use the provided label IDs.\n- Always apply the correct label.\n- Do not archive or delete emails.",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"outputVisibility": "internal",
"controlType": "relinquish_to_parent",
"maxCallsPerParentAgent": 3
}
],
"prompts": [
{
"name": "Important + Not Urgent Label ID",
"type": "base_prompt",
"prompt": "<needs to be added>"
},
{
"name": "Important + Urgent Label ID",
"type": "base_prompt",
"prompt": "<needs to be added>"
},
{
"name": "Not Important + Urgent Label ID",
"type": "base_prompt",
"prompt": "<needs to be added>"
},
{
"name": "Not Important + Not Urgent Label ID",
"type": "base_prompt",
"prompt": "<needs to be added>"
},
{
"name": "user_id",
"type": "base_prompt",
"prompt": "<needs to be added>"
}
],
"tools": [
{
"name": "Modify email labels",
"description": "Adds and/or removes specified gmail labels for a message; ensure `message id` and all `label ids` are valid.",
"mockTool": false,
"parameters": {
"type": "object",
"properties": {
"add_label_ids": {
"type": "array",
"description": "Label IDs to add.",
"items": {
"type": "string"
},
"default": []
},
"message_id": {
"type": "string",
"description": "Immutable ID of the message to modify."
},
"remove_label_ids": {
"type": "array",
"description": "Label IDs to remove.",
"items": {
"type": "string"
},
"default": []
},
"user_id": {
"type": "string",
"description": "User's email address or 'me' for the authenticated user.",
"default": "me"
}
},
"required": [
"message_id"
]
},
"isComposio": true,
"composioData": {
"slug": "GMAIL_ADD_LABEL_TO_EMAIL",
"noAuth": false,
"toolkitName": "gmail",
"toolkitSlug": "gmail",
"logo": "https://cdn.jsdelivr.net/gh/ComposioHQ/open-logos@master/gmail.svg"
}
}
],
"pipelines": [
{
"name": "Eisenhower Email Pipeline",
"description": "Pipeline that classifies an email and applies the correct label (including 'Low Priority' for low-value emails).",
"agents": [
"Classification Agent",
"Label Agent"
]
}
],
"startAgent": "Eisenhower Email Pipeline",
"lastUpdatedAt": "2025-09-13T20:34:42.747Z",
"name": "Eisenhower Email Organizer",
"description": "Organizes emails into the four Eisenhower Matrix categories.",
"category": "Work Productivity",
"copilotPrompt": "Give me a brief explanation of this assistant. Also briefly tell me about how to setup a trigger for this assistant."
}

View file

@ -5,7 +5,7 @@
"type": "conversation",
"description": "Hub agent that orchestrates fetching GitHub stats for rowboatlabs/rowboat and logging them to a Google Sheet.",
"instructions": "## 🧑‍💼 Role:\nYou are the hub agent responsible for orchestrating the process of fetching GitHub repository stats for 'rowboatlabs/rowboat' and logging them to a Google Sheet.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive a user request to log GitHub stats.\n2. FIRST: Call [@agent:GitHub Stats Agent](#mention) and always provide repository owner: 'rowboatlabs' and repo: 'rowboat' as input (do not prompt the user for these values).\n3. Wait for the stats to be returned.\n4. THEN: Call [@agent:GitHub Stats to Sheet Agent](#mention) to append the stats to the Google Sheet.\n5. Wait for confirmation from the Sheets agent.\n6. Inform the user that the data has been logged, or report any error if one occurred.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Orchestrating the sequential workflow: fetch stats for rowboatlabs/rowboat, then log to sheet, then inform the user.\n\n❌ Out of Scope:\n- Fetching stats or logging to the sheet directly (handled by sub-agents).\n- Handling requests unrelated to GitHub stats logging.\n- Accepting or prompting for other repositories.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Always use 'rowboatlabs' as owner and 'rowboat' as repo when calling the GitHub Stats Agent.\n- Always follow the sequence: GitHub Stats Agent first, then GitHub Stats to Sheet Agent.\n- Wait for each agent's complete response before proceeding.\n- Only interact with the user for the initial request and final confirmation.\n\n🚫 Don'ts:\n- Do not perform stats fetching or sheet logging yourself.\n- Do not try to call both agents at once.\n- Do not reference internal agent names to the user.\n- Do not prompt the user for a repository or accept any other repository.\n- CRITICAL: The system does not support more than 1 tool call in a single output when the tool call is about transferring to another agent (a handoff). You must only put out 1 transfer related tool call in one output.\n\n# Examples\n- **User** : Fetch and store stats\n - **Agent actions**: Call [@agent:GitHub Stats Agent](#mention) with owner: 'rowboatlabs', repo: 'rowboat'\n\n- **Agent receives stats** :\n - **Agent actions**: Call [@agent:GitHub Stats to Sheet Agent](#mention)\n\n- **Agent receives sheet confirmation** :\n - **Agent response**: GitHub stats have been logged to the sheet successfully.\n\n- **Agent receives error from sheet agent** :\n - **Agent response**: There was an error logging the stats to the sheet: [error details]\n\n- **User** : Add a dummy row\n - **Agent response**: Sorry, I can only log actual GitHub stats. Please use the workflow to log real data.",
"model": "google/gemini-2.5-flash",
"model": "",
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
@ -18,7 +18,7 @@
"description": "Fetches GitHub page view and clone statistics for rowboatlabs/rowboat for the previous day.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are an internal agent that fetches GitHub page view and clone statistics for the repository for the previous day.\n\n---\n## ⚙️ Steps to Follow:\n1. Always use owner: <take from context> and repo: <take from context> (do not expect or prompt for these values from the parent agent).\n2. Use [@tool:Get page views](#mention) with per: 'day' to fetch daily page view stats. You must actually call this tool.\n3. Use [@tool:Get repository clones](#mention) with per: 'day' to fetch daily clone stats. You must actually call this tool.\n4. Filter both results to only include data for the previous day (relative to today, in UTC).\n5. Return both sets of stats (page views and clones for the previous day) to the parent agent.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Fetching and returning GitHub page view and clone stats for 'rowboatlabs/rowboat' for the previous day.\n\n❌ Out of Scope:\n- Answering user questions directly.\n- Modifying repository data.\n- Accepting or prompting for any other repository.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Return only the stats for the previous day.\n- Return both page views and clone stats in a clear, structured format.\n- **Do not simulate or describe tool calls—always actually call the tools.**\n\n🚫 Don'ts:\n- Do not interact with the user directly.\n- Do not perform any actions other than fetching and returning stats.\n- Do not prompt for or accept any repository input.\n\n# Examples\n- **Parent agent** : Fetch and store stats\n - **Agent actions**: Call [@tool:Get page views](#mention) with owner, repo, per: 'day'. Then call [@tool:Get repository clones](#mention) with owner, repo: 'rowboat', per: 'day'.\n - **Agent response**: [Page views and clone stats for owner/repo for the previous day]\n\n\n\n",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -33,7 +33,7 @@
"description": "Appends the latest GitHub clone and view stats as a new row to a specified Google Sheet.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are an internal agent that receives GitHub stats (clones and views), extracts the most recent date for each, and appends a row to a Google Sheet with the specified columns.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive GitHub stats data (including arrays of daily clone and view stats, each with date, count, and uniques).\n2. Identify the most recent (latest) date in the clones array and extract its count and uniques.\n3. Identify the most recent (latest) date in the views array and extract its count and uniques.\n4. Use the current UTC date (YYYY-MM-DD) as the run date.\n5. Prepare a row with the following columns (in order):\n - run date (current UTC date, YYYY-MM-DD)\n - latest clones stats date (YYYY-MM-DD)\n - clones (count)\n - unique clones\n - latest view stats date (YYYY-MM-DD)\n - views (count)\n - unique views\n6. Use [@tool:Append Values to Spreadsheet](#mention) to append this row to the end of the sheet (no headers).\n - spreadsheetId: <take from context>\n - range: <take from context> (or the correct sheet name if specified)\n - valueInputOption: USER_ENTERED\n - values: [[run date, latest clones stats date, clones, unique clones, latest view stats date, views, unique views]]\n7. Return a confirmation or error message to the parent agent.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Appending a single row of stats to the specified Google Sheet.\n\n❌ Out of Scope:\n- Adding headers or modifying existing data.\n- Interacting with the user directly.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Only use the most recent date for each stat type.\n- Ensure the row is appended at the end (no headers).\n- Use the correct spreadsheetId and valueInputOption.\n\n🚫 Don'ts:\n- Do not add column headers.\n- Do not overwrite existing data.\n- Do not interact with the user directly.\n\n# Examples\n- **Parent agent** : Insert latest GitHub stats into sheet\n - **Agent actions**: Call [@tool:Append Values to Spreadsheet](#mention) with the latest stats and current date\n - **Agent response**: Row appended confirmation or error message\n\n- **Parent agent** : Insert stats with missing data\n - **Agent actions**: If either clones or views data is missing, append available data and leave missing fields blank\n - **Agent response**: Row appended confirmation or error message\n\n- **Parent agent** : Insert stats for a different repo\n - **Agent actions**: Same as above, using provided stats\n - **Agent response**: Row appended confirmation or error message\n\n- **Parent agent** : Insert stats with only views data\n - **Agent actions**: Append row with views data, leave clone fields blank\n - **Agent response**: Row appended confirmation or error message\n\n- **Parent agent** : Insert stats with only clones data\n - **Agent actions**: Append row with clone data, leave view fields blank\n - **Agent response**: Row appended confirmation or error message\n",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -48,7 +48,7 @@
"description": "Fetches daily page view stats for rowboatlabs/rowboat using the Get page views tool.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nFetch daily page view stats for the repository 'rowboatlabs/rowboat'.\n\n---\n## ⚙️ Steps to Follow:\n1. Use [@tool:Get page views](#mention) with owner: 'rowboatlabs', repo: 'rowboat', per: 'day'.\n2. Return the full result to the next pipeline step.\n\n---\n## 📋 Guidelines:\n- Do not prompt for repository details; always use the specified owner and repo.\n- Do not interact with the user or other agents.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -63,7 +63,7 @@
"description": "Fetches daily clone stats for rowboatlabs/rowboat using the Get repository clones tool.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nFetch daily clone stats for the repository 'rowboatlabs/rowboat'.\n\n---\n## ⚙️ Steps to Follow:\n1. Use [@tool:Get repository clones](#mention) with owner: 'rowboatlabs', repo: 'rowboat', per: 'day'.\n2. Return the full result to the next pipeline step, along with the previous step's page views data.\n\n---\n## 📋 Guidelines:\n- Do not prompt for repository details; always use the specified owner and repo.\n- Do not interact with the user or other agents.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -78,7 +78,7 @@
"description": "Appends the latest GitHub clone and view stats as a new row to the specified Google Sheet.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nAppend the latest GitHub stats (clones and views) as a new row to the Google Sheet.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive both page views and clone stats (arrays of daily stats, each with date, count, uniques).\n2. Identify the most recent (latest) date in the clones array and extract its count and uniques.\n3. Identify the most recent (latest) date in the views array and extract its count and uniques.\n4. Use the current UTC date (YYYY-MM-DD) as the run date.\n5. Prepare a row with the following columns (in order):\n - run date (current UTC date, YYYY-MM-DD)\n - latest clones stats date (YYYY-MM-DD)\n - clones (count)\n - unique clones\n - latest view stats date (YYYY-MM-DD)\n - views (count)\n - unique views\n6. Use [@tool:Append Values to Spreadsheet](#mention) to append this row to the end of the sheet (no headers).\n - spreadsheetId: <take from context>\n - range: <take from context>\n - valueInputOption: USER_ENTERED\n - values: [[run date, latest clones stats date, clones, unique clones, latest view stats date, views, unique views]]\n7. Return the appended row and all relevant stats to the next pipeline step.\n\n---\n## 📋 Guidelines:\n- Only use the most recent date for each stat type.\n- Ensure the row is appended at the end (no headers).\n- Use the correct spreadsheetId and valueInputOption.\n- Do not interact with the user or other agents.\n\n",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -93,7 +93,7 @@
"description": "Sends a summary message to the #stats Slack channel, including a link to the updated sheet.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nSend a summary message to the #stats Slack channel after stats are logged to the sheet.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the appended row and all relevant stats from the previous step.\n2. Compose a message summarizing the latest GitHub stats update, including:\n - The run date\n - The latest clones and views stats (date, count, uniques)\n - A statement that the data has been updated in the sheet\n - A link to the sheet: <take from context>\n3. Use [@tool:Send a message to a Slack channel](#mention) to post the message to channel: stats\n4. Return a confirmation or error message.\n\n---\n## 📋 Guidelines:\n- The message should be clear, concise, and include the sheet link.\n- Do not interact with the user or other agents.\n",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -108,7 +108,7 @@
"description": "",
"disabled": false,
"instructions": "",
"model": "gpt-4o",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -123,7 +123,7 @@
"description": "User-facing hub that triggers the GitHub Stats Logging Pipeline and reports when complete.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are the hub agent responsible for triggering the GitHub Stats Logging Pipeline.\n\n---\n## ⚙️ Steps to Follow:\n1. When the user requests a stats update, call [@pipeline:GitHub Stats Logging Pipeline](#mention).\n2. Wait for the pipeline to complete.\n3. Inform the user that the stats have been logged, the sheet updated, and the Slack channel notified.\n\n---\n## 📋 Guidelines:\n- Do not perform any stats fetching, sheet logging, or Slack messaging yourself.\n- Do not reference internal agent or pipeline names to the user.\n- Only interact with the user for the initial request and final confirmation.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",

View file

@ -6,7 +6,7 @@
"description": "Receives new GitHub issue details and sends a formatted message to Slack.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are the assistant responsible for sending new GitHub issue details to Slack.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive a new GitHub issue payload (via trigger).\n2. Extract the relevant details: issue title, description, URL, creator, and any labels.\n3. Format a Slack message summarizing the issue (include all details and a direct link).\n4. Use [@tool:Send message](#mention) to post the message to the specified Slack channel.\n5. Respond with 'done!' to indicate completion.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Formatting and sending Slack messages for new GitHub issues.\n\n❌ Out of Scope:\n- Handling other GitHub events.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Ensure the message is clear and includes all relevant details.\n- Use markdown formatting for readability.\n\n🚫 Don'ts:\n- Do not process non-issue events.\n- CRITICAL: Only call the Slack tool once per issue event.\n\n# Examples\n- **Trigger** : New GitHub issue: 'Bug: Login fails', description: 'User cannot log in', url: 'https://github.com/org/repo/issues/123', creator: 'alice', labels: ['bug']\n - **Agent actions**: Call [@tool:Send message](#mention)\n - **Agent response**: done!",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",

View file

@ -5,7 +5,7 @@
"type": "conversation",
"description": "Receives PR event details and sends a formatted Slack message to a specified channel.",
"instructions": "## 🧑‍💼 Role:\nYou are an internal agent that receives pull request (PR) event details and sends a Slack message with the PR information.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive PR event details (title, author, URL, description, etc.) and the Slack channel name.\n2. Format a clear, concise Slack message summarizing the PR (e.g., title, author, link, and description).\n3. Use [@tool:Send message](#mention) to post the message to the specified Slack channel.\n4. Return confirmation of message sent.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Formatting PR details for Slack.\n- Sending messages to Slack channels.\n\n❌ Out of Scope:\n- Handling PR events directly (trigger is external).\n- User interaction or responding to user queries.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Ensure the Slack message is clear and includes a link to the PR.\n- Use markdown formatting for readability.\n\n🚫 Don'ts:\n- Do not interact with users.\n- Do not process events other than PRs.\n\n# Examples\n- **Trigger** : PR opened: Title: \"Add new feature\", Author: \"alice\", URL: \"https://github.com/org/repo/pull/123\", Description: \"Implements feature X.\"\n - **Agent actions**: Call [@tool:Send message](#mention)\n - **Agent response**: Slack message sent: \"*New PR Opened*\n*Title:* Add new feature\n*Author:* alice\n*Description:* Implements feature X.\n<https://github.com/org/repo/pull/123>\"\n\n- **Trigger** : PR merged: Title: \"Fix bug\", Author: \"bob\", URL: \"https://github.com/org/repo/pull/456\", Description: \"Fixes Y bug.\"\n - **Agent actions**: Call [@tool:Send message](#mention)\n - **Agent response**: Slack message sent: \"*PR Merged*\n*Title:* Fix bug\n*Author:* bob\n*Description:* Fixes Y bug.\n<https://github.com/org/repo/pull/456>\"",
"model": "google/gemini-2.5-flash",
"model": "",
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,

View file

@ -6,10 +6,11 @@ import interviewScheduler from './interview-scheduler.json';
import meetingPrepAssistant from './meeting-prep-assistant.json';
import redditOnSlack from './reddit-on-slack.json';
import twitterSentiment from './twitter-sentiment.json';
import tweetWithGeneratedImage from './tweet-with-generated-image.json';
import tweetAssistant from './tweet-assistant.json';
import customerSupport from './customer-support.json';
import githubIssueToSlack from './github-issue-to-slack.json';
import githubPrToSlack from './github-pr-to-slack.json';
import eisenhowerEmailOrganizer from './eisenhower-email-organizer.json';
// Keep keys consistent with prior file basenames to avoid breaking links.
export const prebuiltTemplates = {
@ -18,9 +19,10 @@ export const prebuiltTemplates = {
'Meeting Prep Assistant': meetingPrepAssistant,
'Reddit on Slack': redditOnSlack,
'Twitter Sentiment': twitterSentiment,
'Tweet with generated image': tweetWithGeneratedImage,
'Tweet Assistant': tweetAssistant,
'Customer Support': customerSupport,
'GitHub Issue to Slack': githubIssueToSlack,
'GitHub PR to Slack': githubPrToSlack,
'Eisenhower Email Organizer': eisenhowerEmailOrganizer,
};

View file

@ -6,8 +6,7 @@
"type": "conversation",
"description": "Hub agent to orchestrate interview scheduling with candidates from a Google Sheet.",
"instructions": "## 🧑‍💼 Role:\nYou are the Recruitment HR Bot, responsible for orchestrating the process of scheduling interviews with candidates from a Google Sheet and updating their status, or handling calendar event RSVPs.\n\n---\n## ⚙️ Steps to Follow:\n1. Greet the user.\n2. **IF** the input is a calendar event RSVP (e.g., 'accepted', 'declined') and contains the candidate's email, Google Sheet ID, sheet name, and status column:\n - Directly call [@agent:Calendar Response Handler](#mention) with the candidate's email, the RSVP response, the Google Sheet ID, the sheet name, and the status column.\n - Inform the user that the calendar response has been processed.\n3. **ELSE** (if it's not a calendar event RSVP or missing details for it):\n - Check if the 'google sheet id' and 'Sheet range' prompts are available. If so, use their values. Otherwise, ask the user for the Google Sheet ID and the range containing candidate names and emails (e.g., 'Sheet1!A2:B').\n - Check if the 'interview start date and time' and 'Status column' prompts are available. If so, use their values. Otherwise, ask for the desired start date and time for interviews (e.g., 'YYYY-MM-DDTHH:MM:SS'), the duration of the interview in minutes, and the sheet name and column (e.g., 'Sheet1!C') where the interview status should be updated.\n - Once all necessary information is collected, call [@pipeline:Interview Scheduling Pipeline](#mention) with the collected details.\n - Inform the user when the interview scheduling process is complete.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Orchestrating the workflow for fetching candidates, scheduling interviews, and updating sheet status.\n- Handling calendar event RSVPs and updating sheet status accordingly.\n\n❌ Out of Scope:\n- Directly fetching candidate data, scheduling interviews, or updating sheet status (handled by pipeline agents).\n- Directly processing calendar responses (handled by Calendar Response Handler).\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Prioritize handling calendar event RSVPs if the necessary information is present.\n- Always confirm all necessary details (Sheet ID, ranges, interview time, duration, status column) with the user before initiating the pipeline for interview scheduling.\n- Ensure all steps are completed in sequence.\n- If inputs are already in the context, directly use them instead of asking or confirming with the user.\n\n🚫 Don'ts:\n- Do not perform data fetching, scheduling, or status updates directly.\n- Do not skip any step in the workflow.\n- Do not mention internal agent names to the user.\n- Do not say 'connecting you to another agent'.\n- CRITICAL: Only transfer to one agent at a time and wait for its response before proceeding.\n\n---\n## 📥 Inputs:\n- **Google Sheet ID**: The unique identifier of the Google Spreadsheet containing candidate data. (e.g., '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms')\n- **Sheet Range**: The range in A1 notation (e.g., 'Sheet1!A2:B') containing candidate names and emails.\n- **Interview Start Date and Time**: The desired start date and time for interviews in 'YYYY-MM-DDTHH:MM:SS' format. Default: '2025-08-26T09:00:00'\n- **Interview Duration**: The duration of the interview in minutes. Default: 30\n- **Status Column**: The sheet name and column (e.g., 'Sheet1!C') where the interview status should be updated.",
"examples": "- **User** : I want to schedule interviews for candidates from a Google Sheet.\n - **Agent response**: Sure! Please provide the desired start date and time for interviews (e.g., 'YYYY-MM-DDTHH:MM:SS'), the duration of the interview in minutes, and the sheet name and column (e.g., 'Sheet1!C') where the interview status should be updated.\n\n- **User** : Start Time: 2024-09-01T10:00:00, Duration: 30, Status Column: Sheet1!C\n - **Agent actions**: Call [@pipeline:Interview Scheduling Pipeline](#mention)\n\n- **Agent receives pipeline completion** :\n - **Agent response**: The interview scheduling process is complete.\n\n- **User** : Candidate [candidate_email] has accepted the interview. Sheet ID: [sheet_id], Sheet Name: [sheet_name], Status Column: [status_column]\n - **Agent actions**: Call [@agent:Calendar Response Handler](#mention)\n\n- **Agent receives Calendar Response Handler completion** :\n - **Agent response**: The calendar response has been processed and the sheet updated.",
"model": "google/gemini-2.5-flash",
"model": "",
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
@ -20,7 +19,7 @@
"description": "Reads candidate names and emails from a specified Google Sheet range.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nFetch candidate names and emails from the provided Google Sheet and ranges.\n\n---\n## ⚙️ Steps to Follow:\n1. Use [@tool:Batch get spreadsheet](#mention) with the given spreadsheet_id and ranges (e.g., 'Sheet1!A2:B').\n2. Return a normalized array of { name, email } objects.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Fetching rows from Google Sheets and returning structured data.\n\n❌ Out of Scope:\n- Scheduling interviews or updating sheet status.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Validate rows and skip empties.\n🚫 Don'ts:\n- Do not schedule interviews or update sheet status.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -35,7 +34,7 @@
"description": "Schedules an interview for each candidate using Google Calendar.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nSchedule an interview for each candidate.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive a list of { name, email } objects from the previous step.\n2. For each candidate, use [@tool:Create Event](#mention) to schedule an interview. The event summary should be 'Interview with [Candidate Name]', and the attendee should be the candidate's email. You will need to ask the user for the start_datetime and duration of the interview.\n3. Return a list of { candidate_email, status: 'scheduled' } for each successfully scheduled interview.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Scheduling interviews on Google Calendar.\n\n❌ Out of Scope:\n- Fetching candidate data or updating sheet status.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Ensure all required fields for event creation are provided.\n🚫 Don'ts:\n- Do not fetch candidate data or update sheet status.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -50,7 +49,7 @@
"description": "Updates the status column in the Google Sheet to 'interview scheduled' for each candidate.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nUpdate the status column in the Google Sheet for scheduled interviews.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive a list of { candidate_email, status: 'scheduled' } objects from the previous step.\n2. For each candidate, use [@tool:Batch update spreadsheet](#mention) to update the corresponding row in the Google Sheet. You will need to ask the user for the spreadsheet_id, sheet_name, and the column where the status needs to be updated.\n3. The value to be updated should be 'invite sent'.\n4. Return a confirmation of the updates.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Updating the status column in the Google Sheet.\n\n❌ Out of Scope:\n- Fetching candidate data or scheduling interviews.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Ensure the correct row and column are updated.\n🚫 Don'ts:\n- Do not fetch candidate data or schedule interviews.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -65,7 +64,7 @@
"description": "",
"disabled": false,
"instructions": "",
"model": "gpt-4o",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -80,7 +79,7 @@
"description": "Handles calendar accept/reject responses and updates the Google Sheet status accordingly.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nProcess calendar responses (accept/reject) and update the Google Sheet with the appropriate interview status.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the candidate's email, the calendar response (e.g., 'accepted', 'declined'), the Google Sheet ID, the sheet name, and the column where the status needs to be updated.\n2. If the response is 'accepted', set the status to 'interview scheduled'.\n3. If the response is 'declined', set the status to 'declined'.\n4. Use [@tool:Batch update spreadsheet](#mention) to update the corresponding row in the Google Sheet with the determined status.\n5. Return a confirmation of the update.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Interpreting calendar responses and updating the Google Sheet status.\n\n❌ Out of Scope:\n- Scheduling interviews or fetching candidate data.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Accurately map calendar responses to interview statuses.\n- Ensure the correct row and column are updated in the Google Sheet.\n🚫 Don'ts:\n- Do not interact with the user directly.\n- Do not schedule interviews.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",

View file

@ -6,7 +6,7 @@
"description": "Researches each guest in the calendar invite using Exa Answer and compiles a summary.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are a pipeline agent that researches each guest in a Google Calendar invite.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive a list of guest names and emails from the calendar invite.\n2. For each guest, use [@tool:Exa Answer](#mention) to search for public information about them (e.g., 'Who is [Name] [Email]?').\n3. Summarize the findings for each guest in 2-3 sentences.\n4. Return a list of guest research summaries for the next step.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Be concise and factual.\n- If no information is found, state 'No public information found.'\n🚫 Don'ts:\n- Do not fabricate information.\n- Do not send emails or interact with the user.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -21,7 +21,7 @@
"description": "Formats the guest research summaries into a clear email body.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are a pipeline agent that formats guest research into an email body.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the list of guest research summaries.\n2. Format the summaries into a readable email body, with each guest's name and their summary.\n3. Add a subject line: 'Meeting Guest Research Summary'.\n4. Return the subject and body for the next step.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Use clear formatting (e.g., bullet points or sections per guest).\n🚫 Don'ts:\n- Do not send emails or interact with the user.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -36,7 +36,7 @@
"description": "Sends the compiled guest research summary to the user's email using Gmail.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are a pipeline agent that sends the guest research summary email.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the subject, body, and recipient email address.\n2. Use [@tool:Send Email](#mention) to send the email.\n3. Return confirmation of sending.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Ensure the email is sent to the correct address.\n🚫 Don'ts:\n- Do not perform research or format the email body.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",

View file

@ -6,7 +6,7 @@
"description": "Searches Reddit for posts based on a given topic and subreddits.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are a pipeline agent responsible for searching Reddit for the latest posts based on given subreddits and a lookback period.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the `Subreddits` and `LookbackInHours` variables from the parent agent.\n2. Calculate the `time_filter` parameter for the `Search across subreddits` tool based on `LookbackInHours`. For example, if `LookbackInHours` is 24, `time_filter` should be 'day'. If `LookbackInHours` is 1, `time_filter` should be 'hour'. If `LookbackInHours` is 7*24, `time_filter` should be 'week'.\n3. Use the [@tool:Search across subreddits](#mention) tool with the `Subreddits` as `search_query` and `sort` set to 'new', and the calculated `time_filter`.\n4. Return the raw search results to the parent agent.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Searching Reddit for posts within a specified time frame.\n\n❌ Out of Scope:\n- Filtering posts by topic.\n- Sending posts to Slack.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Ensure the search query includes the subreddits.\n- Accurately calculate and apply the `time_filter`.\n- Return all relevant search results.\n\n🚫 Don'ts:\n- Do not filter posts by topic.\n- Do not send messages to Slack.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -21,7 +21,7 @@
"description": "Filters Reddit posts based on the Topics",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are a pipeline agent responsible for filtering Reddit posts based on a specified topics.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the raw Reddit posts and the `Topic` variable from the parent agent.\n2. Filter the posts to include only those that are on the specified Topics.\n3. Return the filtered posts to the parent agent.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Filtering Reddit posts by topic.\n\n❌ Out of Scope:\n- Searching Reddit.\n- Filtering posts by time.\n- Sending posts to Slack.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Accurately filter posts based on the provided topic.\n- Return only the posts that meet the topic criteria.\n\n🚫 Don'ts:\n- Do not perform Reddit searches or time-based filtering.\n- Do not send messages to Slack.\n",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -36,7 +36,7 @@
"description": "Formats and sends filtered Reddit posts to a specified Slack channel.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are a pipeline agent responsible for formatting and sending filtered Reddit posts to a specified Slack channel.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the filtered Reddit posts and the `SlackChannel` variable from the parent agent.\n2. Format the posts into a readable message for Slack, including the post title, URL, and a brief summary.\n3. Use the [@tool:Send message](#mention) tool to send the formatted message to the `SlackChannel`.\n4. Return a confirmation message to the parent agent.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Formatting Reddit posts for Slack.\n- Sending messages to Slack.\n\n❌ Out of Scope:\n- Searching Reddit.\n- Filtering posts by time.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Ensure the Slack message is well-formatted and easy to read.\n- Include all relevant information for each post.\n\n🚫 Don'ts:\n- Do not perform Reddit searches or filtering.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",

View file

@ -3,44 +3,23 @@
{
"name": "Tweet Assistant",
"type": "conversation",
"description": "Assists users in creating and posting tweets with images, including crafting tweet text, generating images, and posting to Twitter.",
"instructions": "## 🧑‍💼 Role:\nYou are a helpful assistant that helps users create and post tweets with images. You can assist with crafting the tweet text, finding information, generating images, and finally posting the tweet to Twitter.\n\n---\n## ⚙️ Operating Procedure:\n1. Greet the user and ask for the text they want to include in the tweet. Offer to help them craft it or find information about a topic.\n2. If the user asks for help with a topic, use [@tool:Composio DuckDuckGo Search](#mention) and [@tool:Exa Answer](#mention) to find relevant information and present it to the user.\n3. Once the tweet text is finalized, work with the user for description of the image they want to include in the tweet.\n4. Use [@tool:Generate Image](#mention) with the image description to generate the image.\n5. Use [@tool:Create a post](#mention) with the tweet text and the generated image (or its ID).\n6. Inform the user when the tweet has been successfully posted.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Interacting with the user to get tweet text and image description.\n- Offering assistance in crafting tweets and finding information.\n- Generating images from text descriptions.\n- Posting tweets with images to Twitter.\n\n❌ Out of Scope:\n- None, this agent handles the entire workflow.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Always confirm the tweet text and image description with the user.\n- Be proactive in offering help and suggestions for tweet content.\n- Ensure the generated image matches the description.\n- Ensure the tweet text and image are correctly posted.\n\n🚫 Don'ts:\n- Do not fabricate information.\n\n",
"model": "google/gemini-2.5-flash",
"description": "Assists users in creating and posting tweets, including crafting tweet text, finding information, and posting to Twitter.",
"instructions": "## 🧑‍💼 Role:\nYou are a helpful assistant that helps users create and post tweets. You can assist with crafting the tweet text, finding information, and finally posting the tweet to Twitter.\n\n---\n## ⚙️ Operating Procedure:\n1. Greet the user and ask for the text they want to include in the tweet. Offer to help them craft it or find information about a topic.\n2. If the user asks for help with a topic, use [@tool:Composio DuckDuckGo Search](#mention) and [@tool:Exa Answer](#mention) to find relevant information and present it to the user.\n3. Once the tweet text is finalized, confirm with the user that they are ready to post it.\n4. Use [@tool:Create a post](#mention) with the tweet text to post the tweet to Twitter.\n5. Inform the user when the tweet has been successfully posted.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Interacting with the user to get tweet text.\n- Offering assistance in crafting tweets and finding information.\n- Posting tweets to Twitter.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Always confirm the tweet text with the user before posting.\n- Be proactive in offering help and suggestions for tweet content.\n- Ensure the tweet text is correctly posted.\n\n🚫 Don'ts:\n- Do not fabricate information.\n",
"examples": "\n",
"model": "",
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"controlType": "retain",
"outputVisibility": "user_facing",
"examples": "\n"
"controlType": "retain"
}
],
"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
}
},
{
"name": "Create a post",
"description": "Creates a tweet on twitter; `text` is required unless `card uri`, `media media ids`, `poll options`, or `quote tweet id` is provided.",
"mockTool": false,
"parameters": {
"type": "object",
"properties": {
@ -221,7 +200,6 @@
},
"required": []
},
"mockTool": false,
"isComposio": true,
"composioData": {
"slug": "TWITTER_CREATION_OF_A_POST",
@ -234,6 +212,7 @@
{
"name": "Exa Answer",
"description": "Get answers with citations using the exa api.",
"mockTool": false,
"parameters": {
"type": "object",
"properties": {
@ -250,7 +229,6 @@
"content"
]
},
"mockTool": false,
"isComposio": true,
"composioData": {
"slug": "COMPOSIO_SEARCH_EXA_ANSWER",
@ -263,6 +241,7 @@
{
"name": "Composio DuckDuckGo Search",
"description": "The duckduckgosearch class utilizes the composio duckduckgo search api to perform searches, focusing on web information and details. it leverages the duckduckgo search engine via the composio duckduckgo search api to retrieve relevant web data based on the provided query.",
"mockTool": false,
"parameters": {
"type": "object",
"properties": {
@ -279,7 +258,6 @@
"query"
]
},
"mockTool": false,
"isComposio": true,
"composioData": {
"slug": "COMPOSIO_SEARCH_DUCK_DUCK_GO_SEARCH",
@ -292,9 +270,9 @@
],
"pipelines": [],
"startAgent": "Tweet Assistant",
"lastUpdatedAt": "2025-09-11T18:02:35.880Z",
"name": "Viral Tweet Assistant",
"description": "Research topics and create a tweet including generated images.",
"lastUpdatedAt": "2025-09-16T09:18:26.925Z",
"name": "Tweet Assistant",
"description": "Research topics and creates a tweet.",
"category": "News & Social",
"copilotPrompt": "Give me a brief explanation of this assistant."
}
}

View file

@ -6,7 +6,7 @@
"description": "Searches Twitter for tweets about a specified keywords.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nSearch Twitter for tweets about a given keyword within a specified time window.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the keywords. Use [@variable:ResultCount](#mention) for the Twitter search and [@variable:LookbackInHours](#mention) to search Twitter.\n2. Use the [@tool:Search full archive of tweets](#mention) tool with each keyword as the query, and the provided `start_time` and `end_time`.\n3. Return the text of the tweets to the next agent in the pipeline.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Searching Twitter for tweets within a given time period.\n\n❌ Out of Scope:\n- Analyzing sentiment.\n- Interacting with the user directly.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Return only the tweet text.\n- Ensure `start_time` and `end_time` are correctly passed to the tool.\n\n🚫 Don'ts:\n- Do not perform sentiment analysis.\n- Do not interact with the user directly.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -21,7 +21,7 @@
"description": "Analyzes the sentiment of tweets and provides a positive sentiment score for each.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nAnalyze the sentiment of tweets and provide a positive sentiment score for each.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive a list of tweets from the previous agent in the pipeline.\n2. For each tweet, classify its sentiment into positive, negative, or neutral.\n3. Return a list of tweets with their corresponding positive sentiment score.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Analyzing tweet sentiment.\n- Providing a positive sentiment score.\n\n❌ Out of Scope:\n- Searching Twitter.\n- Interacting with the user directly.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Provide a clear positive sentiment score for each tweet.\n\n🚫 Don'ts:\n- Do not search Twitter.\n- Do not interact with the user directly.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
@ -36,13 +36,13 @@
"description": "Summarizes the sentiment of tweets in three sentences.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nSummarize the sentiment of tweets.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive a list of tweets with their positive sentiment scores from the previous agent.\n2. Calculate the percentage of positive tweets.\n3. Summarize the findings in three sentences, including:\n - The percentage of positive tweets.\n - General themes of positive comments.\n - General themes of negative comments.\n4. Return the summary to the parent agent.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Summarizing tweet sentiments.\n\n❌ Out of Scope:\n- Searching Twitter.\n- Analyzing sentiment.\n- Interacting with the user directly.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Provide a concise summary as requested.\n\n🚫 Don'ts:\n- Do not perform other tasks.\n- Do not interact with the user directly.",
"model": "google/gemini-2.5-flash",
"model": "",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"controlType": "relinquish_to_parent",
"outputVisibility": "internal",
"controlType": "relinquish_to_parent",
"maxCallsPerParentAgent": 3
}
],
@ -56,6 +56,11 @@
"name": "LookbackInHours",
"type": "base_prompt",
"prompt": "<needs to be added>"
},
{
"name": "ResultCount",
"type": "base_prompt",
"prompt": "<needs to be added>"
}
],
"tools": [
@ -372,9 +377,9 @@
}
],
"startAgent": "Twitter Sentiment Pipeline",
"lastUpdatedAt": "2025-09-11T17:26:48.865Z",
"lastUpdatedAt": "2025-09-16T09:24:45.848Z",
"name": "Twitter Sentiment",
"description": "Searches Twitter for tweets about a company and analyzes their sentiment.",
"category": "News & Social",
"copilotPrompt": "Give me a brief explanation of this assistant. Also briefly tell me about how to setup a scheduled trigger for this assistant."
}
}

View file

@ -1,50 +1,19 @@
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";
// Provide a minimal default template to satisfy legacy code paths that
// still reference `templates.default`. Real templates are DB-backed.
// 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> } = {};
const defaultTemplate: z.infer<typeof WorkflowTemplate> = {
name: 'Blank Template',
description: 'A blank canvas to build your assistant.',
startAgent: "",
agents: [],
prompts: [],
tools: [],
pipelines: [],
};
// 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,
},
},
],
};
// 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,
};

View file

@ -1,4 +1,5 @@
import { z } from "zod";
import { getDefaultTools } from "@/app/lib/default_tools";
export const WorkflowAgent = z.object({
name: z.string(),
order: z.number().int().optional(),
@ -165,7 +166,10 @@ export function sanitizeTextWithMentions(
const agent = workflow.agents.find(a => a.name === entity.name);
return agent && agent.type !== 'pipeline';
} else if (entity.type === 'tool') {
return workflow.tools.some(t => t.name === entity.name);
// Allow referencing workflow tools or default library tools
const inWorkflow = workflow.tools.some(t => t.name === entity.name);
const inDefaults = getDefaultTools().some(t => t.name === entity.name);
return inWorkflow || inDefaults;
} else if (entity.type === 'prompt') {
return workflow.prompts.some(p => p.name === entity.name);
} else if (entity.type === 'pipeline') {

View file

@ -16,6 +16,7 @@ import { Panel } from "@/components/common/panel-common";
import { Button as CustomButton } from "@/components/ui/button";
import clsx from "clsx";
import { InputField } from "@/app/lib/components/input-field";
import { getDefaultTools } from "@/app/lib/default_tools";
import { USE_TRANSFER_CONTROL_OPTIONS } from "@/app/lib/feature_flags";
import { Info as InfoIcon } from "lucide-react";
import { useCopilot } from "../copilot/use-copilot";
@ -68,7 +69,6 @@ export function AgentConfig({
const [isAdvancedConfigOpen, setIsAdvancedConfigOpen] = useState(false);
const [showGenerateModal, setShowGenerateModal] = useState(false);
const [isInstructionsMaximized, setIsInstructionsMaximized] = useState(false);
const [isExamplesMaximized, setIsExamplesMaximized] = useState(false);
const { showPreview } = usePreviewModal();
const [localName, setLocalName] = useState(agent.name);
const [nameError, setNameError] = useState<string | null>(null);
@ -172,15 +172,12 @@ export function AgentConfig({
if (isInstructionsMaximized) {
setIsInstructionsMaximized(false);
}
if (isExamplesMaximized) {
setIsExamplesMaximized(false);
}
}
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [isInstructionsMaximized, isExamplesMaximized]);
}, [isInstructionsMaximized]);
const validateName = (value: string) => {
if (value.length === 0) {
@ -240,7 +237,13 @@ export function AgentConfig({
const atMentions = createAtMentions({
agents: agents,
prompts,
tools,
tools: (() => {
const defaults = getDefaultTools();
const map = new Map<string, z.infer<typeof WorkflowTool>>();
for (const t of tools) map.set(t.name, t);
for (const t of defaults) if (!map.has(t.name)) map.set(t.name, t as any);
return Array.from(map.values());
})(),
pipelines: agent.type === "pipeline" ? [] : (workflow.pipelines || []), // Pipeline agents can't reference pipelines
currentAgentName: agent.name,
currentAgent: agent
@ -438,98 +441,7 @@ export function AgentConfig({
className="h-full min-h-0 overflow-auto !mb-0 !mt-0 min-h-[300px]"
/>
</div>
{/* Examples Section */}
<div className="space-y-2 mb-6">
<div className="flex items-center gap-2">
<label className="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Examples</label>
<button
type="button"
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
style={{ lineHeight: 0 }}
onClick={() => setIsExamplesMaximized(!isExamplesMaximized)}
>
{isExamplesMaximized ? (
<Minimize2 className="w-4 h-4" style={{ width: 16, height: 16 }} />
) : (
<Maximize2 className="w-4 h-4" style={{ width: 16, height: 16 }} />
)}
</button>
</div>
{!isExamplesMaximized && (
<div className="text-xs text-gray-500 dark:text-gray-400">
💡 Tip: Use the maximized view for a better editing experience
</div>
)}
{isExamplesMaximized ? (
<div className="fixed inset-0 z-50 bg-white dark:bg-gray-900">
<div className="h-full flex flex-col">
{/* Saved Banner for maximized examples */}
{showSavedBanner && (
<div className="absolute top-4 left-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm font-medium">Changes saved</span>
</div>
)}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{agent.name}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">/</span>
<span className="text-sm text-gray-500 dark:text-gray-400">Examples</span>
</div>
<button
type="button"
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
style={{ lineHeight: 0 }}
onClick={() => setIsExamplesMaximized(false)}
>
<Minimize2 className="w-4 h-4" style={{ width: 16, height: 16 }} />
</button>
</div>
<div className="flex-1 overflow-hidden p-4">
<InputField
type="text"
key="examples-maximized"
value={agent.examples || ""}
onChange={(value) => {
handleUpdate({
...agent,
examples: value
});
showSavedMessage();
}}
placeholder="Enter examples for this agent"
markdown
multiline
mentions
mentionsAtValues={atMentions}
className="h-full min-h-0 overflow-auto !mb-0 !mt-0"
/>
</div>
</div>
</div>
) : (
<InputField
type="text"
key="examples"
value={agent.examples || ""}
onChange={(value) => {
handleUpdate({
...agent,
examples: value
});
showSavedMessage();
}}
placeholder="Enter examples for this agent"
markdown
multiline
mentions
mentionsAtValues={atMentions}
className="h-full min-h-0 overflow-auto !mb-0 !mt-0"
/>
)}
</div>
{/* Examples Section removed */}
</div>
)}
</>
@ -989,4 +901,4 @@ function validateAgentName(value: string, currentName?: string, usedNames?: Set<
return "Name must contain only letters, numbers, underscores, hyphens, and spaces";
}
return null;
}
}

View file

@ -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({
</div>
{/* Share Modal */}
<Modal isOpen={isShareModalOpen} onClose={onShareModalClose} size="lg">
<Modal
isOpen={isShareModalOpen}
onClose={onShareModalClose}
size="2xl"
scrollBehavior="inside"
classNames={{
base: "bg-white dark:bg-gray-900 max-h-[90vh]",
header: "border-b border-gray-200 dark:border-gray-700 pb-4 flex-shrink-0",
body: "py-6 overflow-y-auto flex-1",
footer: "border-t border-gray-200 dark:border-gray-700 pt-4 flex-shrink-0"
}}
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
Share Assistant
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Share Assistant</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 font-normal">Choose how you&apos;d like to share your assistant</p>
</ModalHeader>
<ModalBody>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Share this assistant with others using the URL below:
</p>
{shareUrl ? (
<div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<input
type="text"
value={shareUrl || ''}
readOnly
className="flex-1 bg-transparent text-sm text-gray-700 dark:text-gray-300 outline-none"
/>
<Button
size="sm"
variant="solid"
onPress={onCopyShareUrl}
className="bg-indigo-100 hover:bg-indigo-200 text-indigo-800"
>
Copy
</Button>
<div className="space-y-8">
{/* Quick Share Section */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<ShareIcon size={16} className="text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100">Quick Share</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Share with a direct link</p>
</div>
</div>
) : (
<div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<Spinner size="sm" />
<span className="text-sm text-gray-600 dark:text-gray-400">
Generating share URL...
</span>
{shareUrl ? (
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700">
<div className="flex-1 min-w-0">
<input
type="text"
value={shareUrl || ''}
readOnly
className="w-full bg-transparent text-sm text-gray-700 dark:text-gray-300 outline-none font-mono focus:outline-none !focus:ring-0 !focus:ring-offset-0 !ring-0 !ring-offset-0"
/>
</div>
<Button
size="sm"
variant="solid"
onPress={handleCopyUrl}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium"
>
{copyButtonText}
</Button>
</div>
) : (
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700">
<Spinner size="sm" />
<span className="text-sm text-gray-500 dark:text-gray-400">
Generating share URL...
</span>
</div>
)}
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200 dark:border-gray-700"></div>
</div>
)}
<div className="relative flex justify-center">
<span className="px-4 bg-white dark:bg-gray-900 text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider">or</span>
</div>
</div>
{/* Community Publishing Section */}
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<MessageCircleIcon size={16} className="text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100">Publish to Community</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Make it discoverable by others</p>
</div>
</div>
<div className="space-y-5">
{/* Assistant Name */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Assistant Name <span className="text-red-500">*</span>
</label>
<Input
placeholder="Enter assistant name"
value={communityData.name}
onChange={(e) => 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"
}}
/>
</div>
{/* Description */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Description <span className="text-red-500">*</span>
</label>
<Textarea
placeholder="Describe what this assistant does..."
value={communityData.description}
onChange={(e) => setCommunityData({ ...communityData, description: e.target.value })}
minRows={3}
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"
}}
/>
</div>
{/* Category */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Category <span className="text-red-500">*</span>
</label>
<Select
placeholder="Select a category"
selectedKeys={communityData.category ? [communityData.category] : []}
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as string;
setCommunityData({ ...communityData, category: selected });
}}
classNames={{
trigger: "border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 focus:outline-none !focus:ring-0 !focus:ring-offset-0 !ring-0 !ring-offset-0 focus-within:border-gray-300 dark:focus-within:border-gray-500 !focus-within:ring-0 !focus-within:ring-offset-0",
value: "text-sm"
}}
>
<SelectItem key="Work Productivity">Work Productivity</SelectItem>
<SelectItem key="Developer Productivity">Developer Productivity</SelectItem>
<SelectItem key="News & Social">News & Social</SelectItem>
<SelectItem key="Customer Support">Customer Support</SelectItem>
<SelectItem key="Education">Education</SelectItem>
<SelectItem key="Entertainment">Entertainment</SelectItem>
<SelectItem key="Other">Other</SelectItem>
</Select>
</div>
{/* Privacy Toggle */}
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800/30 rounded-xl border border-gray-200 dark:border-gray-700">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
{communityData.isAnonymous ? 'Publish anonymously' : `Publish as ${getUserDisplayName()}`}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{communityData.isAnonymous ? 'Your name will be hidden from the community' : 'Your name will be visible to the community'}
</div>
</div>
<button
type="button"
onClick={() => setCommunityData({ ...communityData, isAnonymous: !communityData.isAnonymous })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
communityData.isAnonymous ? 'bg-gray-300 dark:bg-gray-600' : 'bg-blue-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
communityData.isAnonymous ? 'translate-x-1' : 'translate-x-6'
}`}
/>
</button>
</div>
{/* Success Message */}
{communityPublishSuccess && (
<div className="flex items-center gap-3 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl">
<div className="w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/40 flex items-center justify-center">
<span className="text-green-600 dark:text-green-400 text-xs"></span>
</div>
<p className="text-green-700 dark:text-green-300 text-sm font-medium">
Successfully published to community!
</p>
</div>
)}
</div>
</div>
</div>
</ModalBody>
<ModalFooter className="gap-3">
<Button
variant="light"
onPress={onShareModalClose}
className="px-6 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
Cancel
</Button>
<Button
color={communityPublishSuccess ? "success" : "primary"}
onPress={() => {
// Open confirmation first
onConfirmOpen();
}}
isLoading={communityPublishing}
isDisabled={communityPublishSuccess || !communityData.name.trim() || !communityData.description.trim() || !communityData.category}
className={`${communityPublishSuccess ? 'bg-green-600 hover:bg-green-700' : 'bg-blue-600 hover:bg-blue-700'} px-6 py-2 text-white font-medium`}
>
{communityPublishSuccess ? 'Published' : (communityPublishing ? 'Publishing...' : 'Publish to Community')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Confirmation Modal for Community Publish */}
<Modal
isOpen={isConfirmOpen}
onClose={() => { setAcknowledged(false); onConfirmClose(); }}
size="md"
classNames={{
base: "bg-white dark:bg-gray-900",
header: "border-b border-gray-200 dark:border-gray-700 pb-3",
body: "py-5",
footer: "border-t border-gray-200 dark:border-gray-700 pt-3"
}}
>
<ModalContent>
<ModalHeader>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Confirm publish to community</h3>
</ModalHeader>
<ModalBody>
<div className="space-y-3 text-sm text-gray-700 dark:text-gray-300">
<p>Publishing to community will make this assistant and its description publicly visible to other users.</p>
<ul className="list-disc pl-5 space-y-1">
<li>Your assistant may appear in the community templates library.</li>
<li>Others can import and use this assistant in their own projects.</li>
<li>Do not include secrets or private data in the description or workflow.</li>
</ul>
<div className="mt-3 flex items-start gap-2">
<input
id="ack-publish"
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
className="mt-1 h-4 w-4"
/>
<label htmlFor="ack-publish" className="text-sm">I understand this will be publicly available.</label>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onShareModalClose}>
Close
<Button variant="light" onPress={() => { setAcknowledged(false); onConfirmClose(); }}>Cancel</Button>
<Button
color="primary"
isDisabled={!acknowledged}
onPress={() => {
onConfirmClose();
setAcknowledged(false);
onCommunityPublish();
}}
>
Confirm & Publish
</Button>
</ModalFooter>
</ModalContent>

View file

@ -6,7 +6,7 @@ import { DataSource } from "@/src/entities/models/data-source";
import { WithStringId } from "../../../lib/types/types";
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@heroui/react";
import { useRef, useEffect, useState } from "react";
import { EllipsisVerticalIcon, ImportIcon, PlusIcon, Brain, Boxes, Wrench, PenLine, Library, ChevronDown, ChevronRight, ServerIcon, Component, ScrollText, GripVertical, Users, Cog, CheckCircle2, LinkIcon, UnlinkIcon, MoreVertical, Eye, Trash2, AlertTriangle, Circle, Database } from "lucide-react";
import { EllipsisVerticalIcon, ImportIcon, PlusIcon, Brain, Boxes, Wrench, PenLine, Library, ChevronDown, ChevronRight, ServerIcon, Component, ScrollText, GripVertical, Users, Cog, CheckCircle2, LinkIcon, UnlinkIcon, MoreVertical, Eye, Trash2, AlertTriangle, Circle, Database, Image as ImageIcon } from "lucide-react";
import { Tooltip } from "@heroui/react";
import { DndContext, DragEndEvent, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
@ -20,6 +20,7 @@ import { ServerLogo } from '../tools/components/MCPServersCommon';
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { ToolsModal } from './components/ToolsModal';
import { DataSourcesModal } from './components/DataSourcesModal';
import { getDefaultTools } from "@/app/lib/default_tools";
import { DataSourceIcon } from '../../../lib/components/datasource-icon';
import { deleteDataSource } from '../../../actions/data-source.actions';
import { ToolkitAuthModal } from '../tools/components/ToolkitAuthModal';
@ -939,97 +940,121 @@ export const EntityList = forwardRef<
{expandedPanels.tools && (
<div className="h-full overflow-y-auto">
<div className="p-2">
{tools.length > 0 ? (
<div className="space-y-1">
{/* Group tools by server */}
{(() => {
// Get custom tools (non-MCP tools)
const customTools = tools.filter(tool => !tool.isMcp && !tool.isComposio);
// Group MCP tools by server
const serverTools = tools.reduce((acc, tool) => {
if (tool.isMcp && tool.mcpServerName) {
if (!acc[tool.mcpServerName]) {
acc[tool.mcpServerName] = [];
}
acc[tool.mcpServerName].push(tool);
}
return acc;
}, {} as Record<string, typeof tools>);
{(() => {
// Merge workflow tools with default library tools (unique by name)
const defaults = getDefaultTools();
const toolMap = new Map<string, z.infer<typeof WorkflowTool>>();
for (const t of tools) toolMap.set(t.name, t);
for (const t of defaults) if (!toolMap.has(t.name)) toolMap.set(t.name, t as any);
const toolsForDisplay = Array.from(toolMap.values());
return (
<>
{/* Show composio cards - ordered by status */}
{Object.values(composioTools)
.map((card) => (
<ComposioCard
key={card.slug}
card={card}
selectedEntity={selectedEntity}
onSelectTool={handleToolSelection}
onDeleteTool={onDeleteTool}
selectedRef={selectedRef}
projectConfig={projectConfig}
projectId={projectId}
workflow={workflow}
onProjectToolsUpdated={onProjectToolsUpdated}
setSelectedToolkitSlug={setSelectedToolkitSlug}
setShowToolsModal={setShowToolsModal}
/>
))}
if (toolsForDisplay.length > 0) {
return (
<div className="space-y-1">
{/* Group tools by server */}
{(() => {
// Get custom tools (non-MCP, non-Composio)
const customTools = toolsForDisplay.filter(tool => !tool.isMcp && !tool.isComposio);
{/* Show MCP server cards */}
{Object.entries(serverTools).map(([serverName, tools]) => (
<ServerCard
key={serverName}
serverName={serverName}
tools={tools}
selectedEntity={selectedEntity}
onSelectTool={handleToolSelection}
onDeleteTool={onDeleteTool}
selectedRef={selectedRef}
/>
))}
// Group MCP tools by server
const serverTools = toolsForDisplay.reduce((acc, tool) => {
if (tool.isMcp && tool.mcpServerName) {
if (!acc[tool.mcpServerName]) {
acc[tool.mcpServerName] = [];
}
acc[tool.mcpServerName].push(tool);
}
return acc;
}, {} as Record<string, typeof toolsForDisplay>);
{/* Show custom tools */}
{customTools.length > 0 && (
<div className="mt-2">
{customTools.map((tool, index) => (
<div
key={`custom-tool-${index}`}
className={clsx(
"flex items-center gap-2 px-3 py-2 rounded cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-800",
selectedEntity?.type === "tool" && selectedEntity.name === tool.name && "bg-indigo-50 dark:bg-indigo-950/30"
return (
<>
{/* Show composio cards - ordered by status */}
{Object.values(composioTools)
.map((card) => (
<ComposioCard
key={card.slug}
card={card}
selectedEntity={selectedEntity}
onSelectTool={handleToolSelection}
onDeleteTool={onDeleteTool}
selectedRef={selectedRef}
projectConfig={projectConfig}
projectId={projectId}
workflow={workflow}
onProjectToolsUpdated={onProjectToolsUpdated}
setSelectedToolkitSlug={setSelectedToolkitSlug}
setShowToolsModal={setShowToolsModal}
/>
))}
{/* Show MCP server cards */}
{Object.entries(serverTools).map(([serverName, tools]) => (
<ServerCard
key={serverName}
serverName={serverName}
tools={tools}
selectedEntity={selectedEntity}
onSelectTool={handleToolSelection}
onDeleteTool={onDeleteTool}
selectedRef={selectedRef}
/>
))}
{/* Show custom tools, including default library tools (read-only) */}
{customTools.length > 0 && (
<div className="mt-2">
{customTools.map((tool, index) => (
<div
key={`custom-tool-${index}`}
className={clsx(
"flex items-center gap-2 px-3 py-2 rounded cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-800",
selectedEntity?.type === "tool" && selectedEntity.name === tool.name && "bg-indigo-50 dark:bg-indigo-950/30",
tool.isLibrary ? "cursor-default" : ""
)}
onClick={() => { if (!tool.isLibrary) handleToolSelection(tool.name); }}
>
{tool.isGeminiImage ? (
<ImageIcon className="w-4 h-4 text-blue-600/70 dark:text-blue-500/70" />
) : (
<Boxes className="w-4 h-4 text-blue-600/70 dark:text-blue-500/70" />
)}
<span className={clsx(
"flex-1 text-xs whitespace-normal break-words",
// Match font styling to other tools even if read-only
"text-zinc-900 dark:text-zinc-100"
)}>{tool.name}</span>
{tool.mockTool && (
<span className="ml-2 px-1 py-0 rounded bg-purple-50 text-purple-400 dark:bg-purple-900/40 dark:text-purple-200 text-[11px] font-normal align-middle">Mocked</span>
)}
{!tool.isLibrary && (
<Tooltip content="Remove tool" size="sm" delay={500}>
<button
className="ml-1 p-1 pr-2 rounded hover:bg-red-100 dark:hover:bg-red-900 flex items-center"
onClick={e => { e.stopPropagation(); onDeleteTool(tool.name); }}
>
<Trash2 className="w-3 h-3 text-red-500" />
</button>
</Tooltip>
)}
</div>
))}
</div>
)}
onClick={() => handleToolSelection(tool.name)}
>
<Boxes className="w-4 h-4 text-blue-600/70 dark:text-blue-500/70" />
<span className="flex-1 text-xs text-zinc-900 dark:text-zinc-100 whitespace-normal break-words">{tool.name}</span>
{tool.mockTool && (
<span className="ml-2 px-1 py-0 rounded bg-purple-50 text-purple-400 dark:bg-purple-900/40 dark:text-purple-200 text-[11px] font-normal align-middle">Mocked</span>
)}
<Tooltip content="Remove tool" size="sm" delay={500}>
<button
className="ml-1 p-1 pr-2 rounded hover:bg-red-100 dark:hover:bg-red-900 flex items-center"
onClick={e => { e.stopPropagation(); onDeleteTool(tool.name); }}
>
<Trash2 className="w-3 h-3 text-red-500" />
</button>
</Tooltip>
</div>
))}
</div>
)}
</>
);
})()}
</div>
) : (
<EmptyState
entity="tools"
hasFilteredItems={false}
/>
)}
</>
);
})()}
</div>
);
}
return (
<EmptyState
entity="tools"
hasFilteredItems={false}
/>
);
})()}
</div>
</div>
)}
@ -1532,10 +1557,6 @@ const ComposioCard = ({
case 'remove-toolkit':
setShowRemoveToolkitModal(true);
break;
case 'more-tools':
setSelectedToolkitSlug(card.slug);
setShowToolsModal(true);
break;
}
}}
disabledKeys={[
@ -1543,12 +1564,6 @@ const ComposioCard = ({
...(isProcessingRemove ? ['remove-toolkit'] : []),
]}
>
<DropdownItem
key="more-tools"
startContent={<PlusIcon className="h-3 w-3" />}
>
More tools
</DropdownItem>
{hasToolkitWithAuth && isToolkitConnected ? (
<DropdownItem
key="disconnect"
@ -1637,7 +1652,8 @@ const ComposioCard = ({
<button
className={clsx(
"flex-1 flex items-center gap-2 text-sm text-left bg-transparent border-none p-0 m-0",
tool.isLibrary ? "text-zinc-400 dark:text-zinc-600" : "text-zinc-900 dark:text-zinc-100"
// Use same font styling for library tools; keep disabled state only
"text-zinc-900 dark:text-zinc-100"
)}
onClick={() => onSelectTool(tool.name)}
disabled={tool.isLibrary}
@ -2135,4 +2151,4 @@ function AddVariableModal({ isOpen, onClose, onConfirm, initialName, initialValu
</ModalContent>
</Modal>
);
}
}

View file

@ -10,6 +10,8 @@ import { PipelineConfig } from "../entities/pipeline_config";
import { ToolConfig } from "../entities/tool_config";
import { App as ChatApp } from "../playground/app";
import { z } from "zod";
import { createSharedWorkflowFromJson } from '@/app/actions/shared-workflow.actions';
import { createAssistantTemplate } from '@/app/actions/assistant-templates.actions';
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react";
import { PromptConfig } from "../entities/prompt_config";
import { DataSourceConfig } from "../entities/datasource_config";
@ -36,6 +38,7 @@ import { Panel } from "@/components/common/panel-common";
import { Button as CustomButton } from "@/components/ui/button";
import { InputField } from "@/app/lib/components/input-field";
import { getDefaultTools } from "@/app/lib/default_tools";
import { VoiceSection } from "../config/components/voice";
import { TopBar } from "./components/TopBar";
@ -1603,22 +1606,13 @@ export function WorkflowEditor({
document.body.removeChild(a);
}
// Share: upload JSON to server to get a share ID and reveal copy button
// Share: create a shared workflow via server action to get an ID and reveal copy button
const [shareUrl, setShareUrl] = useState<string | null>(null);
async function handleShareWorkflow() {
try {
// POST to server to create a share token
const json = buildWorkflowExportJson();
const resp = await fetch('/api/shared-workflow', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: json,
});
if (!resp.ok) {
console.error('Failed to create share link');
return;
}
const data = await resp.json();
const data = await createSharedWorkflowFromJson(json);
const createUrl = `${window.location.origin}/projects?shared=${encodeURIComponent(data.id)}`;
setShareUrl(createUrl);
} catch (e) {
@ -1633,6 +1627,46 @@ export function WorkflowEditor({
setTimeout(() => setShowCopySuccess(false), 2000);
}
// Community publishing functions
const [shareMode, setShareMode] = useState<'url' | 'community'>('url');
const [communityData, setCommunityData] = useState({
name: '',
description: '',
category: '',
tags: [] as string[],
isAnonymous: false,
copilotPrompt: '',
});
const [communityPublishing, setCommunityPublishing] = useState(false);
const [communityPublishSuccess, setCommunityPublishSuccess] = useState(false);
const handleCommunityPublish = async () => {
if (!communityData.name.trim() || !communityData.description.trim() || !communityData.category) {
return;
}
setCommunityPublishing(true);
try {
// Use the same redaction logic as URL sharing to mask environment variables
const redactedWorkflow = JSON.parse(buildWorkflowExportJson());
await createAssistantTemplate({
...communityData,
workflow: redactedWorkflow, // Use the redacted workflow
});
setCommunityPublishSuccess(true);
setTimeout(() => {
setCommunityPublishSuccess(false);
// Close modal or reset
}, 2000);
} catch (error) {
console.error('Error publishing to community:', error);
} finally {
setCommunityPublishing(false);
}
};
// Cleanup blob URL on unmount
// No-op cleanup; shareUrl is a normal URL now
@ -1949,6 +1983,13 @@ export function WorkflowEditor({
onShareWorkflow={handleShareWorkflow}
shareUrl={shareUrl}
onCopyShareUrl={handleCopyShareUrl}
shareMode={shareMode}
setShareMode={setShareMode}
communityData={communityData}
setCommunityData={setCommunityData}
onCommunityPublish={handleCommunityPublish}
communityPublishing={communityPublishing}
communityPublishSuccess={communityPublishSuccess}
onPublishWorkflow={handlePublishWorkflow}
onChangeMode={onChangeMode}
onRevertToLive={handleRevertToLive}
@ -2167,7 +2208,14 @@ export function WorkflowEditor({
usedAgentNames={new Set(state.present.workflow.agents.filter((agent) => agent.name !== state.present.selection!.name).map((agent) => agent.name))}
usedPipelineNames={new Set((state.present.workflow.pipelines || []).map((pipeline) => pipeline.name))}
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
tools={(() => {
const { tools } = state.present.workflow;
const defaults = getDefaultTools();
const map = new Map<string, any>();
for (const t of tools) map.set(t.name, t);
for (const t of defaults) if (!map.has(t.name)) map.set(t.name, t);
return Array.from(map.values());
})()}
prompts={state.present.workflow.prompts}
dataSources={dataSources}
handleUpdate={(update) => { dispatchGuarded({ type: "update_agent", name: state.present.selection!.name, agent: update }); }}
@ -2195,7 +2243,14 @@ export function WorkflowEditor({
key={`overlay-${state.present.selection.name}-${configKey}`}
prompt={state.present.workflow.prompts.find((prompt) => prompt.name === state.present.selection!.name)!}
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
tools={(() => {
const { tools } = state.present.workflow;
const defaults = getDefaultTools();
const map = new Map<string, any>();
for (const t of tools) map.set(t.name, t);
for (const t of defaults) if (!map.has(t.name)) map.set(t.name, t);
return Array.from(map.values());
})()}
prompts={state.present.workflow.prompts}
usedPromptNames={new Set(state.present.workflow.prompts.filter((prompt) => prompt.name !== state.present.selection!.name).map((prompt) => prompt.name))}
handleUpdate={(update) => { dispatchGuarded({ type: "update_prompt", name: state.present.selection!.name, prompt: update }); }}
@ -2273,7 +2328,14 @@ export function WorkflowEditor({
usedAgentNames={new Set(state.present.workflow.agents.filter((agent) => agent.name !== state.present.selection!.name).map((agent) => agent.name))}
usedPipelineNames={new Set((state.present.workflow.pipelines || []).map((pipeline) => pipeline.name))}
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
tools={(() => {
const { tools } = state.present.workflow;
const defaults = getDefaultTools();
const map = new Map<string, any>();
for (const t of tools) map.set(t.name, t);
for (const t of defaults) if (!map.has(t.name)) map.set(t.name, t);
return Array.from(map.values());
})()}
prompts={state.present.workflow.prompts}
dataSources={dataSources}
handleUpdate={(update) => { dispatchGuarded({ type: "update_agent", name: state.present.selection!.name, agent: update }); }}
@ -2301,7 +2363,14 @@ export function WorkflowEditor({
key={`overlay2-${state.present.selection.name}-${configKey}`}
prompt={state.present.workflow.prompts.find((prompt) => prompt.name === state.present.selection!.name)!}
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
tools={(() => {
const { tools } = state.present.workflow;
const defaults = getDefaultTools();
const map = new Map<string, any>();
for (const t of tools) map.set(t.name, t);
for (const t of defaults) if (!map.has(t.name)) map.set(t.name, t);
return Array.from(map.values());
})()}
prompts={state.present.workflow.prompts}
usedPromptNames={new Set(state.present.workflow.prompts.filter((prompt) => prompt.name !== state.present.selection!.name).map((prompt) => prompt.name))}
handleUpdate={(update) => { dispatchGuarded({ type: "update_prompt", name: state.present.selection!.name, prompt: update }); }}

View file

@ -1,7 +1,7 @@
'use client';
import { useState, useRef, useEffect } from "react";
import { listTemplates, listProjects } from "@/app/actions/project.actions";
import { useState, useRef, useEffect, useCallback } from "react";
import { listProjects } from "@/app/actions/project.actions";
import { createProjectWithOptions, createProjectFromJsonWithOptions, createProjectFromTemplate } from "../lib/project-creation-utils";
import { useRouter, useSearchParams } from 'next/navigation';
import clsx from 'clsx';
@ -11,11 +11,21 @@ import { Button } from "@/components/ui/button";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { TextareaWithSend } from "@/app/components/ui/textarea-with-send";
import { Workflow } from '../../lib/types/workflow_types';
import { loadSharedWorkflow } from '@/app/actions/shared-workflow.actions';
import { PictureImg } from '@/components/ui/picture-img';
import { Tabs, Tab } from "@/components/ui/tabs";
import { Project } from "@/src/entities/models/project";
import { z } from "zod";
import Link from 'next/link';
import { AssistantSection } from '@/components/common/AssistantSection';
import { UnifiedTemplatesSection } from '@/components/common/UnifiedTemplatesSection';
import {
listAssistantTemplates,
getAssistantTemplateCategories,
toggleTemplateLike,
deleteAssistantTemplate,
getAssistantTemplate
} from '@/app/actions/assistant-templates.actions';
const SHOW_PREBUILT_CARDS = process.env.NEXT_PUBLIC_SHOW_PREBUILT_CARDS !== 'false';
@ -48,9 +58,17 @@ export function BuildAssistantSection() {
const [promptError, setPromptError] = useState<string | null>(null);
const [importLoading, setImportLoading] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
// Library templates (paginated)
const [templates, setTemplates] = useState<any[]>([]);
const [templatesLoading, setTemplatesLoading] = useState(false);
const [templatesError, setTemplatesError] = useState<string | null>(null);
const [templatesCursor, setTemplatesCursor] = useState<string | null>(null);
// Community templates (paginated)
const [communityTemplates, setCommunityTemplates] = useState<any[]>([]);
const [communityTemplatesLoading, setCommunityTemplatesLoading] = useState(false);
const [communityTemplatesError, setCommunityTemplatesError] = useState<string | null>(null);
const [communityCursor, setCommunityCursor] = useState<string | null>(null);
const [projects, setProjects] = useState<z.infer<typeof Project>[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
@ -88,41 +106,138 @@ export function BuildAssistantSection() {
return Array.from(uniqueToolsMap.values()).filter(tool => tool.logo); // Only show tools with logos like ToolkitCard
};
const fetchTemplates = async () => {
setTemplatesLoading(true);
setTemplatesError(null);
try {
const templatesArray = await listTemplates();
setTemplates(templatesArray);
} catch (error) {
console.error('Error fetching templates:', error);
setTemplatesError(error instanceof Error ? error.message : 'Failed to load templates');
} finally {
setTemplatesLoading(false);
// Utility: append unique by id (prevents duplicates when paginating)
const appendUniqueById = useCallback((prev: any[], next: any[]) => {
const seen = new Set(prev.map(i => i.id));
const merged = [...prev];
for (const item of next) {
if (!seen.has(item.id)) {
merged.push(item);
seen.add(item.id);
}
}
};
return merged;
}, []);
// Clean, single loader: load pages for 'library' or 'community' until target count
const loadTemplatesToCount = useCallback(async (source: 'library' | 'community', targetCount: number) => {
const setLoading = source === 'library' ? setTemplatesLoading : setCommunityTemplatesLoading;
const setError = source === 'library' ? setTemplatesError : setCommunityTemplatesError;
const getItems = () => (source === 'library' ? templates : communityTemplates);
const setItems = source === 'library' ? setTemplates : setCommunityTemplates;
const getCursor = () => (source === 'library' ? templatesCursor : communityCursor);
const setCursor = source === 'library' ? setTemplatesCursor : setCommunityCursor;
setLoading(true);
setError(null);
try {
let items = getItems();
let cursor = getCursor();
while (items.length < targetCount && (cursor !== null || items.length === 0)) {
const pageSize = Math.min(Math.max(targetCount - items.length, 12), 30);
const data = await listAssistantTemplates({ source, limit: pageSize, cursor: cursor || undefined });
items = appendUniqueById(items, data.items);
setItems(items);
cursor = data.nextCursor || null;
setCursor(cursor);
if (!cursor) break;
}
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to load templates';
setError(msg);
} finally {
setLoading(false);
}
}, [templates, communityTemplates, templatesCursor, communityCursor, appendUniqueById]);
// Adapter used by UI: map 'prebuilt' to 'library'
const ensureTemplatesLoaded = useCallback(async (type: 'prebuilt' | 'community', targetCount: number) => {
const source = type === 'prebuilt' ? 'library' : 'community';
await loadTemplatesToCount(source, targetCount);
}, [loadTemplatesToCount]);
// 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 server action, then create from JSON
const data = await getAssistantTemplate(template.id);
await createProjectFromJsonWithOptions({
workflowJson: JSON.stringify(data.workflow),
router,
onSuccess: (_projectId) => {},
onError: () => {
setLoadingTemplateId(null);
}
});
} else if (template.type === 'community') {
// Fetch full workflow for community template, then create from JSON
const data = await getAssistantTemplate(template.id);
await createProjectFromJsonWithOptions({
workflowJson: JSON.stringify(data.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);
}
};
// Handle template like (unified for library and community) - now uses proper authentication
const handleTemplateLike = async (template: any) => {
if (template.type === 'prebuilt') return;
try {
const data = await toggleTemplateLike(template.id);
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 data = await getAssistantTemplate(template.id);
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);
@ -145,28 +260,22 @@ export function BuildAssistantSection() {
};
useEffect(() => {
fetchTemplates();
// Load initial library templates to fill 4 rows x up to 3 columns ≈ 12
fetchProjects();
}, []);
ensureTemplatesLoaded('prebuilt', 12);
}, [ensureTemplatesLoaded]);
// Handle URL parameters for auto-creation and direct redirect to build view
useEffect(() => {
const urlPrompt = searchParams.get('prompt');
const urlTemplate = searchParams.get('template');
const sharedId = searchParams.get('shared');
const importUrl = searchParams.get('importUrl');
const run = async () => {
if (sharedId || importUrl) {
if (sharedId) {
try {
setAutoCreateLoading(true);
const qs = sharedId ? `id=${encodeURIComponent(sharedId)}` : `url=${encodeURIComponent(importUrl!)}`;
const resp = await fetch(`/api/shared-workflow?${qs}`, { cache: 'no-store' });
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `Failed to load shared workflow (${resp.status})`);
}
const workflowObj = await resp.json();
const workflowObj = await loadSharedWorkflow(sharedId);
await createProjectFromJsonWithOptions({
workflowJson: JSON.stringify(workflowObj),
router,
@ -184,19 +293,38 @@ 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 data = await getAssistantTemplate(urlTemplate);
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);
}
}
};
@ -291,7 +419,9 @@ export function BuildAssistantSection() {
{/* Tabs Section */}
<div className="max-w-5xl mx-auto">
<div className="p-6 pb-0">
<Tabs defaultSelectedKey="new" selectedKey={selectedTab} onSelectionChange={(key) => setSelectedTab(key as string)} className="w-full">
<Tabs defaultSelectedKey="new" selectedKey={selectedTab} onSelectionChange={(key) => {
setSelectedTab(key as string);
}} className="w-full">
<Tab key="new" title="New Assistant">
<div className="pt-4">
<div className="flex items-center gap-12">
@ -460,151 +590,62 @@ 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">
<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">
Prebuilt Assistants
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Start quickly and let Skipper adapt it to your needs.
</p>
</div>
{templatesLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-500 dark:text-gray-400">
Loading pre-built assistants...
</div>
) : templatesError ? (
<div className="flex items-center justify-center py-12 text-sm text-red-500 dark:text-red-400">
Error: {templatesError}
</div>
) : templates.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-500 dark:text-gray-400">
No pre-built assistants available
</div>
) : (
(() => {
const workTemplates = templates.filter((t) => (t.category || '').toLowerCase() === 'work productivity');
const devTemplates = templates.filter((t) => (t.category || '').toLowerCase() === 'developer productivity');
const newsTemplates = templates.filter((t) => (t.category || '').toLowerCase() === 'news & social');
const customerSupportTemplates = templates.filter((t) => (t.category || '').toLowerCase() === 'customer support');
const renderGrid = (items: any[]) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((template) => (
<button
key={template.id}
onClick={() => handleTemplateSelect(template)}
disabled={loadingTemplateId === template.id}
className={clsx(
"relative block p-4 border border-gray-200 dark:border-gray-700 rounded-xl transition-all group text-left",
"hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-md",
loadingTemplateId === template.id && "opacity-90 cursor-not-allowed"
)}
>
<div className="space-y-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">
{template.name}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{template.description}
</div>
{(() => {
const tools = getUniqueTools(template);
return tools.length > 0 && (
<div className="flex items-center gap-2 mt-2">
<div className="text-xs text-gray-400 dark:text-gray-500">
Tools:
</div>
<div className="flex items-center gap-1">
{tools.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}
/>
)
))}
{tools.length > 4 && (
<span className="text-xs text-gray-400 dark:text-gray-500">
+{tools.length - 4}
</span>
)}
</div>
</div>
);
})()}
<div className="flex items-center justify-between mt-2">
<div className="text-xs text-gray-400 dark:text-gray-500"></div>
{loadingTemplateId === template.id ? (
<div className="text-blue-600 dark:text-blue-400">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
</div>
) : (
<div className="w-2 h-2 rounded-full bg-blue-500 opacity-75"></div>
)}
</div>
</div>
</button>
))}
</div>
);
return (
<div className="space-y-8">
{workTemplates.length > 0 && (
<div>
<div className="mb-3">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-50 text-amber-700 ring-1 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-300 dark:ring-amber-400/30">
Work Productivity
</span>
</div>
{renderGrid(workTemplates)}
</div>
)}
{devTemplates.length > 0 && (
<div>
<div className="mb-3">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-400/10 dark:text-indigo-300 dark:ring-indigo-400/30">
Developer Productivity
</span>
</div>
{renderGrid(devTemplates)}
</div>
)}
{newsTemplates.length > 0 && (
<div>
<div className="mb-3">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-50 text-green-700 ring-1 ring-green-200 dark:bg-green-400/10 dark:text-green-300 dark:ring-green-400/30">
News & Social
</span>
</div>
{renderGrid(newsTemplates)}
</div>
)}
{customerSupportTemplates.length > 0 && (
<div>
<div className="mb-3">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-red-50 text-red-700 ring-1 ring-red-200 dark:bg-red-400/10 dark:text-red-300 dark:ring-red-400/30">
Customer Support
</span>
</div>
{renderGrid(customerSupportTemplates)}
</div>
)}
</div>
);
})()
)}
<UnifiedTemplatesSection
prebuiltTemplates={templates.map(template => ({
id: template.id,
name: template.name,
description: template.description,
category: template.category || 'Other',
tools: template.tools,
type: 'prebuilt' as const,
likeCount: (template as any).likeCount || 0,
isLiked: (template as any).isLiked || false,
}))}
communityTemplates={communityTemplates.map(template => ({
id: template.id,
name: template.name,
description: template.description,
category: template.category,
authorId: template.authorId,
source: template.source,
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={() => {
loadTemplatesToCount('library', 12);
loadTemplatesToCount('community', 12);
}}
loadingItemId={loadingTemplateId}
onLike={handleTemplateLike}
onShare={handleTemplateShare}
onDelete={async (item) => {
try {
await deleteAssistantTemplate(item.id);
setCommunityTemplates(prev => prev.filter(t => t.id !== item.id));
} catch (e) {
console.error(e);
// Optional: surface non-blocking feedback; keeping console error for now
}
}}
getUniqueTools={getUniqueTools}
onLoadMore={async (type, target) => {
await ensureTemplatesLoaded(type, target);
}}
onTypeChange={async (type, target) => {
await ensureTemplatesLoaded(type, target);
}}
/>
</div>
</div>
)}
</div>
</div>

View file

@ -12,6 +12,7 @@ import { HorizontalDivider } from "@/components/ui/horizontal-divider";
import { Tooltip } from "@heroui/react";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
import { Workflow } from '@/app/lib/types/workflow_types';
import { loadSharedWorkflow } from '@/app/actions/shared-workflow.actions';
import { Modal } from '@/components/ui/modal';
import { Upload, Send, X } from "lucide-react";
@ -150,7 +151,6 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
const urlPrompt = searchParams.get('prompt');
const urlTemplate = searchParams.get('template');
const sharedId = searchParams.get('shared');
const importUrl = searchParams.get('importUrl');
// Add this effect to update name when defaultName changes
useEffect(() => {
@ -167,19 +167,13 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
// Add effect to handle URL parameters for auto-creation
useEffect(() => {
const handleAutoCreate = async () => {
// Auto-create from template/prompt, or import from shared/id/url
if ((urlPrompt || urlTemplate || sharedId || importUrl) && !importLoading && !autoCreateLoading) {
// Auto-create from template/prompt, or import from shared id
if ((urlPrompt || urlTemplate || sharedId) && !importLoading && !autoCreateLoading) {
setAutoCreateLoading(true);
try {
if (sharedId || importUrl) {
// Fetch workflow JSON via our API route
const qs = sharedId ? `id=${encodeURIComponent(sharedId)}` : `url=${encodeURIComponent(importUrl!)}`;
const resp = await fetch(`/api/shared-workflow?${qs}`, { cache: 'no-store' });
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `Failed to load shared workflow (${resp.status})`);
}
const workflowObj = await resp.json();
if (sharedId) {
// Load workflow via server action (by id)
const workflowObj = await loadSharedWorkflow(sharedId);
await createProjectFromJsonWithOptions({
workflowJson: JSON.stringify(workflowObj),
router,
@ -208,7 +202,7 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
};
handleAutoCreate();
}, [urlPrompt, urlTemplate, sharedId, importUrl, importLoading, autoCreateLoading, router]);
}, [urlPrompt, urlTemplate, sharedId, importLoading, autoCreateLoading, router]);
// Inject glow animation styles
useEffect(() => {

View file

@ -22,7 +22,7 @@ import { container } from '@/di/container';
const FILE_PARSING_PROVIDER_API_KEY = process.env.FILE_PARSING_PROVIDER_API_KEY || process.env.OPENAI_API_KEY || '';
const FILE_PARSING_PROVIDER_BASE_URL = process.env.FILE_PARSING_PROVIDER_BASE_URL || undefined;
const FILE_PARSING_MODEL = process.env.FILE_PARSING_MODEL || 'gpt-4o';
const FILE_PARSING_MODEL = process.env.FILE_PARSING_MODEL || 'gpt-4.1';
const dataSourcesRepository = container.resolve<IDataSourcesRepository>('dataSourcesRepository');
const dataSourceDocsRepository = container.resolve<IDataSourceDocsRepository>('dataSourceDocsRepository');

View file

@ -0,0 +1,312 @@
'use client';
import React from 'react';
import { clsx } from 'clsx';
import { PictureImg } from '@/components/ui/picture-img';
import { Heart, Share2, Calendar } from 'lucide-react';
// Helper function to get relative time
const getRelativeTime = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) {
return 'just now';
}
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return `${diffInMinutes} minute${diffInMinutes === 1 ? '' : 's'} ago`;
}
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return `${diffInHours} hour${diffInHours === 1 ? '' : 's'} ago`;
}
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) {
return `${diffInDays} day${diffInDays === 1 ? '' : 's'} ago`;
}
const diffInWeeks = Math.floor(diffInDays / 7);
if (diffInWeeks < 4) {
return `${diffInWeeks} week${diffInWeeks === 1 ? '' : 's'} ago`;
}
const diffInMonths = Math.floor(diffInDays / 30);
if (diffInMonths < 12) {
return `${diffInMonths} month${diffInMonths === 1 ? '' : 's'} ago`;
}
const diffInYears = Math.floor(diffInDays / 365);
return `${diffInYears} year${diffInYears === 1 ? '' : 's'} ago`;
};
interface AssistantCardProps {
id: string;
name: string;
description: string;
category: string;
tools?: Array<{
name: string;
logo?: string;
}>;
// Community-specific props
authorName?: string;
isAnonymous?: boolean;
likeCount?: number;
createdAt?: string;
onLike?: () => void;
onShare?: () => void;
onDelete?: () => void;
isLiked?: boolean;
// Template type indicator
templateType?: 'prebuilt' | 'community';
// Common props
onClick?: () => void;
loading?: boolean;
disabled?: boolean;
getUniqueTools?: (item: any) => Array<{ name: string; logo?: string }>;
// UI flags
hideLikes?: boolean;
}
export function AssistantCard({
id,
name,
description,
category,
tools = [],
authorName,
isAnonymous = false,
likeCount = 0,
createdAt,
onLike,
onShare,
isLiked = false,
onDelete,
templateType,
onClick,
loading = false,
disabled = false,
getUniqueTools,
hideLikes = false
}: AssistantCardProps) {
const displayTools = getUniqueTools ? getUniqueTools({ tools }) : tools;
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();
if (lowerCategory.includes('work productivity')) {
return 'bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-300';
} else if (lowerCategory.includes('developer productivity')) {
return 'bg-indigo-50 text-indigo-700 dark:bg-indigo-400/10 dark:text-indigo-300';
} else if (lowerCategory.includes('news') || lowerCategory.includes('social')) {
return 'bg-green-50 text-green-700 dark:bg-green-400/10 dark:text-green-300';
} else if (lowerCategory.includes('customer support')) {
return 'bg-red-50 text-red-700 dark:bg-red-400/10 dark:text-red-300';
} else if (lowerCategory.includes('education')) {
return 'bg-blue-50 text-blue-700 dark:bg-blue-400/10 dark:text-blue-300';
} else if (lowerCategory.includes('entertainment')) {
return 'bg-purple-50 text-purple-700 dark:bg-purple-400/10 dark:text-purple-300';
} else {
return 'bg-gray-50 text-gray-700 dark:bg-gray-400/10 dark:text-gray-300';
}
};
return (
<div
onClick={onClick}
className={clsx(
"relative block p-4 border border-gray-200 dark:border-gray-700 rounded-xl transition-all group text-left cursor-pointer",
"hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-md",
loading && "opacity-90 cursor-not-allowed",
disabled && "opacity-50 cursor-not-allowed"
)}
>
<div className="space-y-3">
{/* Title and Description */}
<div>
<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-rose-50 text-rose-700 dark:bg-rose-400/10 dark:text-rose-300"
)}>
{templateType === 'prebuilt' ? 'Library' : 'Community'}
</span>
)}
</div>
<div className="mt-1 relative">
<div
ref={descriptionRef}
className={clsx(
"text-sm leading-5 text-gray-600 dark:text-gray-400 relative min-h-[2.5rem]",
(!isDescriptionExpanded && showDescriptionToggle) && "pr-20",
!isDescriptionExpanded && "line-clamp-2"
)}
>
{description}
</div>
{showDescriptionToggle && (
!isDescriptionExpanded ? (
<div className="pointer-events-none absolute inset-0">
<div className="absolute bottom-0 right-0 h-5 w-24 pl-2 flex items-center justify-end bg-gradient-to-l from-white dark:from-gray-800/95 to-transparent">
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setIsDescriptionExpanded(true); }}
className="pointer-events-auto text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 px-1"
aria-label="Read more"
>
Read more
</button>
</div>
</div>
) : (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setIsDescriptionExpanded(false); }}
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="Show less"
>
Show less
</button>
)
)}
</div>
</div>
{/* Tools (reserve row height even when absent to align cards) */}
<div className="flex items-center gap-2 min-h-[20px] -mt-1">
{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>
)}
</div>
</>
)}
</div>
{/* Category Badge */}
<div className="flex items-center justify-between">
<span className={clsx(
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
getCategoryColor(category)
)}>
{category}
</span>
{loading && (
<div className="text-blue-600 dark:text-blue-400">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
</div>
)}
</div>
{/* 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">
<span>
{isAnonymous ? 'Anonymous' : (authorName ? (authorName.split(' ')[0] || 'Rowboat') : 'Rowboat')}
</span>
{onDelete && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete();
}}
className="ml-1 inline-flex items-center justify-center text-gray-400 hover:text-red-600 transition-colors"
aria-label="Delete template"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg>
</button>
)}
{createdAt && (
<div className="flex items-center gap-1">
<Calendar size={12} />
<span>{getRelativeTime(createdAt)}</span>
</div>
)}
</div>
<div className="flex items-center gap-3">
{!hideLikes && (
<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>
);
}

View file

@ -0,0 +1,238 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Input } from "@heroui/react";
import { Search } from 'lucide-react';
import { AssistantCard } from './AssistantCard';
interface AssistantItem {
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;
}
interface AssistantSectionProps {
title: string;
description: string;
items: AssistantItem[];
loading?: boolean;
error?: string | null;
onItemClick?: (item: AssistantItem) => void;
onRetry?: () => void;
loadingItemId?: string | null;
emptyMessage?: string;
// Community-specific callbacks
onLike?: (item: AssistantItem) => void;
onShare?: (item: AssistantItem) => void;
// Pre-built specific
getUniqueTools?: (item: AssistantItem) => Array<{ name: string; logo?: string }>;
// Filter state
initialSearchQuery?: string;
initialSelectedCategory?: string;
onFiltersChange?: (filters: {
searchQuery: string;
selectedCategory: string;
}) => void;
}
export function AssistantSection({
title,
description,
items,
loading = false,
error = null,
onItemClick,
onRetry,
loadingItemId = null,
emptyMessage = "No assistants available",
onLike,
onShare,
getUniqueTools,
initialSearchQuery = '',
initialSelectedCategory = '',
onFiltersChange
}: AssistantSectionProps) {
const [searchQuery, setSearchQuery] = useState(initialSearchQuery);
const [selectedCategory, setSelectedCategory] = useState(initialSelectedCategory);
// Notify parent of filter changes if callback provided
useEffect(() => {
if (onFiltersChange) {
onFiltersChange({
searchQuery,
selectedCategory
});
}
}, [searchQuery, selectedCategory, onFiltersChange]);
// Get available categories from items
const availableCategories = React.useMemo(() => {
const categories = new Set(items.map(item => item.category));
return Array.from(categories).sort();
}, [items]);
// Filter items
const filteredItems = React.useMemo(() => {
let filtered = [...items];
// 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 category filter
if (selectedCategory) {
filtered = filtered.filter(item => item.category === selectedCategory);
}
return filtered;
}, [items, searchQuery, selectedCategory]);
const isCommunity = items.length > 0 && items[0].authorName !== undefined;
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">
{title}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{description}
</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 assistants...</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">
{title}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{description}
</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">
{title}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{description}
</p>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1">
<Input
placeholder="Search assistants..."
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">
<div className="relative">
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="w-48 px-3 py-2 pr-10 border border-gray-200 dark:border-gray-700 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 appearance-none text-sm"
>
<option value="">All Categories</option>
{availableCategories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 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>
{/* Grid */}
{filteredItems.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">{emptyMessage}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredItems.map((item) => (
<AssistantCard
key={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={() => onItemClick?.(item)}
loading={loadingItemId === item.id}
getUniqueTools={getUniqueTools}
onLike={onLike ? () => onLike(item) : undefined}
onShare={onShare ? () => onShare(item) : undefined}
isLiked={item.isLiked}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,500 @@
'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";
import { getCurrentUser } from '@/app/actions/assistant-templates.actions';
interface TemplateItem {
id: string;
name: string;
description: string;
category: string;
authorId?: string;
source?: 'library' | 'community';
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;
onDelete?: (item: TemplateItem) => void;
getUniqueTools?: (item: TemplateItem) => Array<{ name: string; logo?: string }>;
onLoadMore?: (type: 'prebuilt' | 'community', targetCount: number) => Promise<void> | void;
onTypeChange?: (type: 'prebuilt' | 'community', targetCount: number) => Promise<void> | void;
}
export function UnifiedTemplatesSection({
prebuiltTemplates,
communityTemplates,
loading = false,
error = null,
onTemplateClick,
onRetry,
loadingItemId = null,
onLike,
onShare,
onDelete,
getUniqueTools,
onLoadMore,
onTypeChange,
}: UnifiedTemplatesSectionProps) {
const [searchQuery, setSearchQuery] = useState('');
const [selectedType, setSelectedType] = useState<'prebuilt' | 'community'>('prebuilt');
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(new Set());
const [sortBy, setSortBy] = useState<'popular' | 'newest' | 'alphabetical'>('alphabetical');
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingDeleteItem, setPendingDeleteItem] = useState<TemplateItem | null>(null);
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false);
// Row-based pagination: paginate in rows of 3 regardless of screen size
const [rowsShown, setRowsShown] = useState<number>(4);
// Track if user has interacted with likes to prevent ALL re-sorting
const [hasUserInteractedWithLikes, setHasUserInteractedWithLikes] = useState(false);
const [originalOrder, setOriginalOrder] = useState<Map<string, number>>(new Map());
// Handle like interaction - capture current order and disable further sorting
const handleLike = (item: TemplateItem) => {
if (!hasUserInteractedWithLikes) {
// Capture the current sorted order when user first interacts with likes
const currentOrder = new Map<string, number>();
filteredTemplates.forEach((template, index) => {
currentOrder.set(template.id, index);
});
setOriginalOrder(currentOrder);
}
setHasUserInteractedWithLikes(true);
onLike?.(item);
};
useEffect(() => {
let isMounted = true;
(async () => {
try {
const data = await getCurrentUser();
if (isMounted) setCurrentUserId(data.id || null);
} catch (_e) {}
})();
return () => { isMounted = false; };
}, []);
// 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 (default to 'prebuilt' / Library)
filtered = filtered.filter(item => item.type === selectedType);
// Apply category filter
if (selectedCategories.size > 0) {
filtered = filtered.filter(item => selectedCategories.has(item.category));
}
// Apply sorting ONLY if user hasn't interacted with likes
if (!hasUserInteractedWithLikes) {
// Normal 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':
// Only meaningful for community templates
if (selectedType === 'community') {
const aLikes = Number(a.likeCount) || 0;
const bLikes = Number(b.likeCount) || 0;
if (bLikes !== aLikes) return bLikes - aLikes;
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
if (bTime !== aTime) return bTime - aTime;
}
return a.name.localeCompare(b.name);
}
});
} else {
// User has interacted - use original order to prevent jumping
filtered.sort((a, b) => {
const aOrder = originalOrder.get(a.id) ?? 0;
const bOrder = originalOrder.get(b.id) ?? 0;
return aOrder - bOrder;
});
}
return filtered;
}, [allTemplates, searchQuery, selectedType, selectedCategories, sortBy, hasUserInteractedWithLikes, originalOrder]);
// No-op: pagination decoupled from display columns
// Reset rowsShown and allow re-sorting when filters/sort change
useEffect(() => {
setRowsShown(4);
// Reset the like interaction flag so sorting can work again
setHasUserInteractedWithLikes(false);
setOriginalOrder(new Map());
}, [searchQuery, selectedType, selectedCategories, sortBy]);
const itemsPerRow = 3; // paginate by 3 items per row irrespective of viewport
const visibleCount = rowsShown * itemsPerRow;
// Show "View more" when there are more items than visible OR
// when we filled the current page and can load more
const hasMore = filteredTemplates.length > visibleCount || (!!onLoadMore && filteredTemplates.length >= visibleCount);
const remainingItems = Math.max(filteredTemplates.length - visibleCount, 0);
const remainingRows = Math.ceil(remainingItems / itemsPerRow);
const visibleTemplates = filteredTemplates.slice(0, visibleCount);
// 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 (default type back to 'prebuilt' / Library)
const clearFilters = () => {
setSearchQuery('');
setSelectedType('prebuilt');
setSelectedCategories(new Set());
setSortBy('alphabetical');
};
// Check if any filters are active
const hasActiveFilters = useMemo(() => {
return !!searchQuery || selectedType !== 'prebuilt' || 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 Segmented Control (Library | Community) */}
<div className="flex gap-0.5 items-center h-8 rounded-full border border-gray-200 dark:border-gray-700 p-0 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
{[
{ key: 'prebuilt', label: 'Library' },
{ key: 'community', label: 'Community' }
].map(({ key, label }) => (
<button
key={key}
onClick={async () => {
const newType = key as 'prebuilt' | 'community';
const target = rowsShown * itemsPerRow;
if (onTypeChange) {
await onTypeChange(newType, target);
}
setSelectedType(newType);
}}
aria-pressed={selectedType === key}
className={`inline-flex items-center h-8 px-2.5 rounded-full text-[13px] font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 ${
selectedType === key
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-transparent text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{label}
</button>
))}
</div>
{/* Sort Dropdown (Popularity only for Community) */}
<div className="relative">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="w-44 h-8 px-4 pr-10 border border-gray-300 dark:border-gray-700 rounded-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 appearance-none text-sm hover:bg-gray-50 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
>
{selectedType === 'community' && (
<option value="popular">Most Popular</option>
)}
<option value="newest">Newest First</option>
<option value="alphabetical">A-Z</option>
</select>
<div className="pointer-events-none absolute inset-y-0 right-3 flex items-center">
<svg className="w-4 h-4 text-gray-400 -translate-y-[2px]" 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 border shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 ${
selectedCategories.has(category)
? 'bg-blue-50 text-blue-700 border-blue-300 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-700'
: 'bg-gray-50 text-gray-700 border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700'
}`}
>
{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 !== 'prebuilt' || selectedCategories.size > 0
? 'No templates found matching your filters'
: 'No templates available'
}
</p>
{(searchQuery || selectedType !== 'prebuilt' || 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 {Math.min(visibleCount, filteredTemplates.length)} of {filteredTemplates.length} template{filteredTemplates.length !== 1 ? 's' : ''} ({rowsShown} row{rowsShown !== 1 ? 's' : ''})
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{visibleTemplates.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={() => handleLike(item)}
onShare={() => onShare?.(item)}
onDelete={onDelete && currentUserId && item.type === 'community' && item.authorId === currentUserId ? () => {
setPendingDeleteItem(item);
setConfirmOpen(true);
} : undefined}
isLiked={item.isLiked}
templateType={item.type}
hideLikes={item.type === 'prebuilt'}
/>
))}
</div>
{hasMore && (
<div className="flex items-center justify-center pt-2">
<button
onClick={async () => {
if (isLoadingMore) return;
setIsLoadingMore(true);
const nextRows = rowsShown + 4; // paginate in 4-row blocks
const target = nextRows * itemsPerRow;
if (onLoadMore) {
try {
await onLoadMore(selectedType, target);
} catch (_e) {
// ignore
}
}
setRowsShown(nextRows);
setIsLoadingMore(false);
}}
disabled={isLoadingMore}
className={`text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium underline-offset-2 hover:underline ${isLoadingMore ? 'opacity-60 cursor-not-allowed' : ''}`}
>
{isLoadingMore ? 'Loading…' : 'View more'}
</button>
</div>
)}
</>
)}
</div>
{/* Delete confirmation modal */}
{confirmOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 max-w-sm w-full p-5">
<div className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Delete template?</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
This will permanently remove &quot;{pendingDeleteItem?.name}&quot; from the community templates. This action cannot be undone.
</div>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={() => { setConfirmOpen(false); setPendingDeleteItem(null); }}
className="px-4 py-2 text-sm rounded-md bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
Cancel
</button>
<button
onClick={async () => {
if (pendingDeleteItem && onDelete) {
await onDelete(pendingDeleteItem);
}
setConfirmOpen(false);
setPendingDeleteItem(null);
}}
className="px-4 py-2 text-sm rounded-md bg-red-600 hover:bg-red-700 text-white"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -27,7 +27,7 @@ import { IProjectsRepository } from "@/src/application/repositories/projects.rep
// Provider configuration
const PROVIDER_API_KEY = process.env.PROVIDER_API_KEY || process.env.OPENAI_API_KEY || '';
const PROVIDER_BASE_URL = process.env.PROVIDER_BASE_URL || undefined;
const MODEL = process.env.PROVIDER_DEFAULT_MODEL || 'gpt-4o';
const MODEL = process.env.PROVIDER_DEFAULT_MODEL || 'gpt-4.1';
const openai = createOpenAI({
apiKey: PROVIDER_API_KEY,
@ -659,11 +659,11 @@ export function createGenerateImageTool(
const images = await Promise.all(result.images.map(async (img) => {
const buf = Buffer.from(img.dataBase64, 'base64');
const ext = img.mimeType === 'image/jpeg' ? '.jpg' : img.mimeType === 'image/webp' ? '.webp' : '.png';
const base = `${projectId}-${Math.floor(Math.random() * 1e12).toString(36)}`;
const last2 = base.slice(-2).padStart(2, '0');
const imageId = crypto.randomUUID();
const last2 = imageId.slice(-2).padStart(2, '0');
const dirA = last2.charAt(0);
const dirB = last2.charAt(1);
const filename = `${base}${ext}`;
const filename = `${imageId}${ext}`;
const key = `generated_images/${dirA}/${dirB}/${filename}`;
await s3.send(new PutObjectCommand({
Bucket: s3Bucket,
@ -671,7 +671,7 @@ export function createGenerateImageTool(
Body: buf,
ContentType: img.mimeType,
}));
const url = `/api/generated-images/${dirA}/${dirB}/${filename}`;
const url = `/api/generated-images/${imageId}`;
return { mimeType: img.mimeType, bytes: buf.length, url };
}));
const payload = {

View file

@ -9,6 +9,7 @@ import crypto from "crypto";
// Internal dependencies
import { createTools, createRagTool } from "./agent-tools";
import { ConnectedEntity, sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPipeline, WorkflowPrompt, WorkflowTool } from "@/app/lib/types/workflow_types";
import { getDefaultTools } from "@/app/lib/default_tools";
import { CHILD_TRANSFER_RELATED_INSTRUCTIONS, CONVERSATION_TYPE_INSTRUCTIONS, PIPELINE_TYPE_INSTRUCTIONS, RAG_INSTRUCTIONS, TASK_TYPE_INSTRUCTIONS, VARIABLES_CONTEXT_INSTRUCTIONS } from "./agent_instructions";
import { PrefixLogger } from "@/app/lib/utils";
import { Message, AssistantMessage, AssistantMessageWithToolCalls, ToolMessage } from "@/app/lib/types/types";
@ -21,7 +22,7 @@ import { PipelineStateManager } from "./pipeline-state-manager";
// Provider configuration
const PROVIDER_API_KEY = process.env.PROVIDER_API_KEY || process.env.OPENAI_API_KEY || '';
const PROVIDER_BASE_URL = process.env.PROVIDER_BASE_URL || undefined;
const MODEL = process.env.PROVIDER_DEFAULT_MODEL || 'gpt-4o';
const MODEL = process.env.PROVIDER_DEFAULT_MODEL || 'gpt-4.1';
// Feature flags
const USE_NATIVE_HANDOFFS = process.env.USE_NATIVE_HANDOFFS === 'true';
@ -361,7 +362,15 @@ function mapConfig(workflow: z.infer<typeof Workflow>): {
...acc,
[agent.name]: agent
}), {});
const toolConfig: Record<string, z.infer<typeof WorkflowTool>> = workflow.tools.reduce((acc, tool) => ({
// Merge workflow tools with default library tools (unique by name)
const mergedTools = (() => {
const defaults = getDefaultTools();
const map = new Map<string, z.infer<typeof WorkflowTool>>();
for (const t of workflow.tools) map.set(t.name, t);
for (const t of defaults) if (!map.has(t.name)) map.set(t.name, t as any);
return Array.from(map.values());
})();
const toolConfig: Record<string, z.infer<typeof WorkflowTool>> = mergedTools.reduce((acc, tool) => ({
...acc,
[tool.name]: tool
}), {});

View file

@ -129,7 +129,6 @@ I'll create the hub agent:
"type": "conversation",
"description": "Hub agent to orchestrate meeting retrieval, participant research, summary generation, and email delivery.",
"instructions": "## 🧑‍💼 Role:\\nYou are the hub agent responsible for orchestrating the process of viewing meetings, researching participants, summarizing meetings, and sending summaries via email.\\n\\n---\\n## ⚙️ Steps to Follow:\\n1. Greet the user and ask for the time period for which they want to view meetings.\\n2. Ask for the user's email address to send the summary.\\n3. Call [@agent:Meeting Fetch Agent](#mention) with the specified time period.\\n4. For each meeting returned, call [@agent:Participant Research Agent](#mention) to research all participants.\\n5. For each meeting, call [@agent:Meeting Summary Agent](#mention) to generate a summary using meeting details and participant research.\\n6. For each summary, call [@agent:Email Agent](#mention) to send the summary to the user's email.\\n7. Inform the user when all summaries have been sent.\\n\\n---\\n## 🎯 Scope:\\n✅ In Scope:\\n- Orchestrating the workflow for meeting retrieval, research, summary, and email delivery.\\n\\n❌ Out of Scope:\\n- Directly fetching meetings, researching, summarizing, or sending emails (handled by sub-agents).\\n\\n---\\n## 📋 Guidelines:\\n✔ Dos:\\n- Always confirm the time period and email address with the user.\\n- Ensure all steps are completed in sequence for each meeting.\\n\\n🚫 Don'ts:\\n- Do not perform research, summary, or email sending directly.\\n- Do not skip any step in the workflow.\\n- Do not mention internal agent names to the user.\\n- Do not say 'connecting you to another agent'.\\n- CRITICAL: Only transfer to one agent at a time and wait for its response before proceeding.",
"examples": "- **User** : I want to see my meetings for next week and get summaries.\\n - **Agent response**: Sure! Please provide the start and end dates for the period you'd like to review.\\n\\n- **User** : From 2024-08-01 to 2024-08-07. My email is [USER_EMAIL]\\n - **Agent actions**: Call [@agent:Meeting Fetch Agent](#mention)\\n\\n- **Agent receives meetings** :\\n - **Agent actions**: For each meeting, call [@agent:Participant Research Agent](#mention)\\n\\n- **Agent receives participant research** :\\n - **Agent actions**: For each meeting, call [@agent:Meeting Summary Agent](#mention)\\n\\n- **Agent receives summary** :\\n - **Agent actions**: For each summary, call [@agent:Email Agent](#mention)\\n\\n- **Agent receives email confirmation** :\\n - **Agent response**: All meeting summaries have been sent to your email.",
"model": "gpt-4.1",
"outputVisibility": "user_facing",
"controlType": "retain"
@ -150,7 +149,6 @@ I'll create the hub agent:
"type": "task",
"description": "Fetches meetings from Google Calendar for a specified time period.",
"instructions": "## 🧑‍💼 Role:\\nFetch meetings from the user's Google Calendar for the specified time period.\\n\\n---\\n## ⚙️ Steps to Follow:\\n1. Receive the time period (start and end date/time) from the parent agent.\\n2. Use [@tool:Find event](#mention) to fetch all meetings in that period.\\n3. Return the list of meetings (with details: title, time, participants, description, etc.) to the parent agent.\\n\\n---\\n## 🎯 Scope:\\n✅ In Scope:\\n- Fetching meetings for a given time period.\\n\\n❌ Out of Scope:\\n- Researching participants.\\n- Summarizing meetings.\\n- Sending emails.\\n\\n---\\n## 📋 Guidelines:\\n✔ Dos:\\n- Return all relevant meeting details.\\n\\n🚫 Don'ts:\\n- Do not perform research or summaries.\\n- Do not interact with the user directly.",
"examples": "- **Parent agent** : Fetch meetings from 2024-08-01 to 2024-08-07.\\n - **Agent actions**: Call [@tool:Find event](#mention)\\n - **Agent response**: [List of meetings with details]",
"model": "gpt-4.1",
"outputVisibility": "internal",
"controlType": "relinquish_to_parent"
@ -171,7 +169,6 @@ I'll create the hub agent:
"type": "task",
"description": "Researches each meeting participant using web search.",
"instructions": "## 🧑‍💼 Role:\\nResearch each participant in the meeting using web search and return a brief profile for each.\\n\\n---\\n## ⚙️ Steps to Follow:\\n1. Receive a list of participant names and emails from the parent agent.\\n2. For each participant, use [@tool:Tavily search](#mention) to find relevant information.\\n3. Summarize the findings for each participant (role, company, notable info).\\n4. Return the research summaries to the parent agent.\\n\\n---\\n## 🎯 Scope:\\n✅ In Scope:\\n- Researching participants using web search.\\n\\n❌ Out of Scope:\\n- Fetching meetings.\\n- Summarizing meetings.\\n- Sending emails.\\n\\n---\\n## 📋 Guidelines:\\n✔ Dos:\\n- Provide concise, relevant participant profiles.\\n\\n🚫 Don'ts:\\n- Do not fabricate information.\\n- Do not interact with the user directly.",
"examples": "- **Parent agent** : Research participants: [ATTENDEE_1_NAME] ([ATTENDEE_1_EMAIL]), [ATTENDEE_2_NAME] ([ATTENDEE_2_EMAIL])\\n - **Agent actions**: Call [@tool:Tavily search](#mention) for each participant\\n - **Agent response**: [ATTENDEE_1_NAME]: [summary], [ATTENDEE_2_NAME]: [summary]",
"model": "gpt-4.1",
"outputVisibility": "internal",
"controlType": "relinquish_to_parent"
@ -192,7 +189,6 @@ I'll create the hub agent:
"type": "task",
"description": "Generates a summary of the meeting using meeting details and participant research.",
"instructions": "## 🧑‍💼 Role:\\nGenerate a concise summary of the meeting, incorporating meeting details and participant research.\\n\\n---\\n## ⚙️ Steps to Follow:\\n1. Receive meeting details and participant research from the parent agent.\\n2. Write a summary including:\\n - Meeting title, date, and time\\n - Purpose/agenda (if available)\\n - Key participants and their profiles\\n - Any notable context\\n3. Return the summary to the parent agent.\\n\\n---\\n## 🎯 Scope:\\n✅ In Scope:\\n- Summarizing meetings using provided details and research.\\n\\n❌ Out of Scope:\\n- Fetching meetings.\\n- Researching participants.\\n- Sending emails.\\n\\n---\\n## 📋 Guidelines:\\n✔ Dos:\\n- Be clear and concise.\\n- Highlight important details.\\n\\n🚫 Don'ts:\\n- Do not add information not provided.\\n- Do not interact with the user directly.",
"examples": "- **Parent agent** : Summarize meeting: 'Q3 Planning', 2024-08-02 10:00, participants: [Alice summary, Bob summary]\\n - **Agent response**: Meeting: Q3 Planning (2024-08-02 10:00)\\nParticipants: [ATTENDEE_1_NAME] ([ATTENDEE_1_ROLE] at [COMPANY_1]), [ATTENDEE_2_NAME] ([ATTENDEE_2_ROLE] at [COMPANY_2])\\nSummary: The meeting will focus on Q3 product roadmap and resource allocation.",
"model": "gpt-4.1",
"outputVisibility": "internal",
"controlType": "relinquish_to_parent"
@ -213,7 +209,6 @@ I'll create the hub agent:
"type": "task",
"description": "Sends the meeting summary to the user's email address.",
"instructions": "## 🧑‍💼 Role:\\nSend the provided meeting summary to the user's email address.\\n\\n---\\n## ⚙️ Steps to Follow:\\n1. Receive the meeting summary and recipient email from the parent agent.\\n2. Use [@tool:Send Email](#mention) to send the summary.\\n3. Confirm delivery to the parent agent.\\n\\n---\\n## 🎯 Scope:\\n✅ In Scope:\\n- Sending meeting summaries via email.\\n\\n❌ Out of Scope:\\n- Fetching meetings.\\n- Researching participants.\\n- Summarizing meetings.\\n\\n---\\n## 📋 Guidelines:\\n✔ Dos:\\n- Ensure the summary is sent to the correct email.\\n\\n🚫 Don'ts:\\n- Do not interact with the user directly.",
"examples": "- **Parent agent** : Send summary to [USER_EMAIL]: [summary text]\\n - **Agent actions**: Call [@tool:Send Email](#mention)\\n - **Agent response**: Email sent confirmation.",
"model": "gpt-4.1",
"outputVisibility": "internal",
"controlType": "relinquish_to_parent"
@ -303,7 +298,7 @@ I'm creating a user-facing agent that fetches a Google Doc by ID and answers que
"name": "Google Doc QnA Assistant",
"type": "conversation",
"description": "Answers user questions based solely on the content of a specified Google Doc.",
"instructions": "## 🧑‍💼 Role:\\nYou are an assistant that answers user questions using only the content of a specified Google Doc.\\n\\n---\\n## ⚙️ Steps to Follow:\\n1. Ask the user for the Google Doc ID and their question.\\n2. Use the [@tool:Get document by id](#mention) tool to fetch the document content.\\n3. Read the content of the document.\\n4. Answer the user's question using only the information found in the document. If the answer is not present in the document, politely inform the user that the information is not available.\\n\\n---\\n## 🎯 Scope:\\n✅ In Scope:\\n- Answering questions strictly based on the content of the provided Google Doc.\\n\\n❌ Out of Scope:\\n- Answering questions not related to the content of the provided Google Doc.\\n- Using external sources or prior knowledge.\\n\\n---\\n## 📋 Guidelines:\\n✔ Dos:\\n- Always fetch the document before answering.\\n- Be concise and accurate.\\n- If the answer is not in the document, say so politely.\\n\\n🚫 Don'ts:\\n- Do not use information outside the document.\\n- Do not attempt to answer unrelated questions.\\n- Do not use RAG or external search.\\n\\n# Examples\\n- **User** : What is the project deadline? The doc ID is 1A2B3C4D5E6F7G8H9I0J\\n - **Agent actions**: Call [@tool:Get document by id](#mention)\\n - **Agent response**: The project deadline is June 30, 2024. (if found in doc)\\n\\n- **User** : Who is the project manager? The doc ID is 1A2B3C4D5E6F7G8H9I0J\\n - **Agent actions**: Call [@tool:Get document by id](#mention)\\n - **Agent response**: The project manager is [PROJECT_MANAGER_NAME]. (if found in doc)\\n\\n- **User** : What is the weather today? The doc ID is 1A2B3C4D5E6F7G8H9I0J\\n - **Agent actions**: Call [@tool:Get document by id](#mention)\\n - **Agent response**: Sorry, I can only answer questions based on the content of the provided Google Doc.\\n\\n- **User** : Tell me about the budget. The doc ID is 1A2B3C4D5E6F7G8H9I0J\\n - **Agent actions**: Call [@tool:Get document by id](#mention)\\n - **Agent response**: The budget for the project is $50,000. (if found in doc)\\n\\n- **User** : Can you summarize the document? The doc ID is 1A2B3C4D5E6F7G8H9I0J\\n - **Agent actions**: Call [@tool:Get document by id](#mention)\\n - **Agent response**: [Provides a brief summary of the document's main points]",
"instructions": "## 🧑‍💼 Role:\\nYou are an assistant that answers user questions using only the content of a specified Google Doc.\\n\\n---\\n## ⚙️ Steps to Follow:\\n1. Ask the user for the Google Doc ID and their question.\\n2. Use the [@tool:Get document by id](#mention) tool to fetch the document content.\\n3. Read the content of the document.\\n4. Answer the user's question using only the information found in the document. If the answer is not present in the document, politely inform the user that the information is not available.\\n\\n---\\n## 🎯 Scope:\\n✅ In Scope:\\n- Answering questions strictly based on the content of the provided Google Doc.\\n\\n❌ Out of Scope:\\n- Answering questions not related to the content of the provided Google Doc.\\n- Using external sources or prior knowledge.\\n\\n---\\n## 📋 Guidelines:\\n✔ Dos:\\n- Always fetch the document before answering.\\n- Be concise and accurate.\\n- If the answer is not in the document, say so politely.\\n\\n🚫 Don'ts:\\n- Do not use information outside the document.\\n- Do not attempt to answer unrelated questions.\\n- Do not use RAG or external search.\\n",
"model": "gpt-4.1",
"outputVisibility": "user_facing",
"controlType": "retain"
@ -1233,7 +1228,6 @@ I'll create an agent to handle product information questions. You can later conn
"description": "Answers product information questions using RAG data sources.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are an internal agent that answers product information questions using RAG data sources. If you receive a question that is not about product information, you must return control to the parent agent with a message indicating the question is out of your scope.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the product information question from the parent agent.\n2. Determine if the question is about product information.\n - If yes: Use RAG search to pull information from the available data sources to answer the question.\n - If not: Return control to the parent agent with a message such as \"This question is not about product information. Returning to parent agent.\"\n3. Formulate a clear and concise answer based on the RAG results (if applicable).\n4. If question is out of scope call [@agent:Product & Delivery Assistant](#mention) \n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Answering product information questions using RAG.\n- Returning control to parent if the question is out of scope.\n\n❌ Out of Scope:\n- Handling delivery status questions.\n- Interacting directly with the user.\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Use RAG search to find relevant information for product questions.\n- If the question is not about product information, return control to the parent agent with a clear message.\n\n🚫 Don'ts:\n- Do not answer questions outside of product information.\n- Do not interact with the user directly.\n- Do not ignore out-of-scope questions; always return to parent.\n",
"examples": "\n",
"model": "google/gemini-2.5-flash",
"locked": false,
"toggleAble": true,
@ -1262,7 +1256,6 @@ I'll create an agent to handle delivery status questions that uses a mocked tool
"description": "Answers delivery status questions using the Exa Answer tool.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are an internal agent that answers delivery status questions. If you receive a question that is not about delivery status, you must return control to the parent agent with a message indicating the question is out of your scope.\n\n---\n## ⚙️ Steps to Follow:\n1. Receive the delivery status question from the parent agent.\n2. Determine if the question is about delivery status.\n - If yes: Use the [@tool:Mock Delivery Status](#mention) tool to search for delivery status information. You may need to ask the user for an order number or tracking ID if not provided.\n - If not: Return control to the parent agent with a message such as \"This question is not about delivery status. Returning to parent agent.\"\n3. Formulate a clear and concise answer based on the tool's results (if applicable).\n4. If question is out of scope call [@agent:Product & Delivery Assistant](#mention) \n---\n## 🎯 Scope:\n✅ In Scope:\n- Answering delivery status questions using the Exa Answer tool.\n- Returning control to parent if the question is out of scope.\n\n❌ Out of Scope:\n- Handling product information questions.\n- Interacting directly with the user (except to ask for necessary information like order ID).\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Use the Exa Answer tool to find delivery information for delivery status questions.\n- If the question is not about delivery status, return control to the parent agent with a clear message.\n- Ask for order details if needed.\n\n🚫 Don'ts:\n- Do not answer questions outside of delivery status.\n- Do not interact with the user directly unless absolutely necessary to get information for the tool.\n- Do not ignore out-of-scope questions; always return to parent.\n",
"examples": "\n",
"model": "gpt-4.1",
"locked": false,
"toggleAble": true,

View file

@ -95,6 +95,8 @@ export class CreateProjectUseCase implements ICreateProjectUseCase {
}
}
// Do not auto-attach image generation tool; it is available as a default library tool in the editor/runtime
// create project secret
const secret = crypto.randomBytes(32).toString('hex');

View file

@ -0,0 +1,42 @@
import { z } from "zod";
import { Workflow } from "../../../app/lib/types/workflow_types";
export const AssistantTemplate = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
category: z.string(),
authorId: z.string(),
authorName: z.string(),
authorEmail: z.string().optional(),
isAnonymous: z.boolean(),
workflow: Workflow,
tags: z.array(z.string()),
publishedAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
downloadCount: z.number().default(0),
likeCount: z.number().default(0),
featured: z.boolean().default(false),
isPublic: z.boolean().default(true),
// Social features
likes: z.array(z.string()).default([]),
// Template-like metadata
copilotPrompt: z.string().optional(),
thumbnailUrl: z.string().optional(),
// New field to indicate source of template
source: z.enum(["library", "community"]),
});
export type AssistantTemplate = z.infer<typeof AssistantTemplate>;
export const AssistantTemplateLike = z.object({
id: z.string(),
assistantId: z.string(),
userId: z.string(),
userEmail: z.string().optional(),
createdAt: z.string().datetime(),
});
export type AssistantTemplateLike = z.infer<typeof AssistantTemplateLike>;

View file

@ -11,6 +11,7 @@ import { SCHEDULED_JOB_RULES_COLLECTION, SCHEDULED_JOB_RULES_INDEXES } from "../
import { COMPOSIO_TRIGGER_DEPLOYMENTS_COLLECTION, COMPOSIO_TRIGGER_DEPLOYMENTS_INDEXES } from "../repositories/mongodb.composio-trigger-deployments.indexes";
import { USERS_COLLECTION, USERS_INDEXES } from "../repositories/mongodb.users.indexes";
import { SHARED_WORKFLOWS_COLLECTION, SHARED_WORKFLOWS_INDEXES } from "../repositories/mongodb.shared-workflows.indexes";
import { COMMUNITY_ASSISTANTS_COLLECTION, COMMUNITY_ASSISTANTS_INDEXES, COMMUNITY_ASSISTANT_LIKES_COLLECTION, COMMUNITY_ASSISTANT_LIKES_INDEXES } from "../repositories/mongodb.community-assistants.indexes";
export async function ensureAllIndexes(database: Db): Promise<void> {
await database.collection(API_KEYS_COLLECTION).createIndexes(API_KEYS_INDEXES);
@ -25,4 +26,6 @@ export async function ensureAllIndexes(database: Db): Promise<void> {
await database.collection(COMPOSIO_TRIGGER_DEPLOYMENTS_COLLECTION).createIndexes(COMPOSIO_TRIGGER_DEPLOYMENTS_INDEXES);
await database.collection(USERS_COLLECTION).createIndexes(USERS_INDEXES);
await database.collection(SHARED_WORKFLOWS_COLLECTION).createIndexes(SHARED_WORKFLOWS_INDEXES);
await database.collection(COMMUNITY_ASSISTANTS_COLLECTION).createIndexes(COMMUNITY_ASSISTANTS_INDEXES);
await database.collection(COMMUNITY_ASSISTANT_LIKES_COLLECTION).createIndexes(COMMUNITY_ASSISTANT_LIKES_INDEXES);
}

View file

@ -0,0 +1,106 @@
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;
// Stable sort: newest first, with _id as tiebreaker to ensure deterministic pages
const results = await this.collection
.find(query)
.sort({ publishedAt: -1, _id: -1 } as any)
.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 getLikedTemplates(templateIds: string[], userId: string): Promise<string[]> {
const likes = await this.likesCollection.find({
assistantId: { $in: templateIds },
userId
}).toArray();
return likes.map(like => like.assistantId);
}
async getCategories(): Promise<string[]> {
const categories = await this.collection.distinct('category', { isPublic: true });
return categories.filter(Boolean);
}
async deleteByIdAndAuthor(id: string, authorId: string): Promise<boolean> {
const result = await this.collection.deleteOne({ _id: new ObjectId(id), authorId } as any);
if (result.deletedCount && result.deletedCount > 0) {
// Clean up likes associated with this assistant template
await this.likesCollection.deleteMany({ assistantId: id } as any);
return true;
}
return false;
}
}

View file

@ -0,0 +1,22 @@
import { IndexDescription } from "mongodb";
export const COMMUNITY_ASSISTANTS_COLLECTION = "community_assistants";
export const COMMUNITY_ASSISTANT_LIKES_COLLECTION = "community_assistant_likes";
export const COMMUNITY_ASSISTANTS_INDEXES: IndexDescription[] = [
{ key: { category: 1, publishedAt: -1 }, name: "category_publishedAt" },
{ key: { tags: 1 }, name: "tags" },
{ key: { authorId: 1 }, name: "authorId" },
{ key: { isPublic: 1, featured: 1, publishedAt: -1 }, name: "isPublic_featured_publishedAt" },
{ key: { name: "text", description: "text", tags: "text" }, name: "text_search" },
{ key: { publishedAt: -1 }, name: "publishedAt_desc" },
{ key: { likeCount: -1 }, name: "likeCount_desc" },
{ key: { downloadCount: -1 }, name: "downloadCount_desc" },
];
export const COMMUNITY_ASSISTANT_LIKES_INDEXES: IndexDescription[] = [
{ key: { assistantId: 1, userId: 1 }, name: "assistantId_userId", unique: true },
{ key: { assistantId: 1 }, name: "assistantId" },
{ key: { userId: 1 }, name: "userId" },
{ key: { createdAt: -1 }, name: "createdAt_desc" },
];

View file

@ -22,6 +22,17 @@ export USE_KLAVIS_TOOLS=true
# export USE_KLAVIS_TOOLS=true
# fi
# default to disabling auth if not explicitly enabled
export USE_AUTH="${USE_AUTH:-false}"
# provide dummy auth0 env vars if missing (to silence build-time warnings)
# Note: app/lib/auth0.ts expects AUTH0_ISSUER_BASE_URL and AUTH0_BASE_URL
export AUTH0_ISSUER_BASE_URL="${AUTH0_ISSUER_BASE_URL:-${AUTH0_DOMAIN:-test}}"
export AUTH0_CLIENT_ID="${AUTH0_CLIENT_ID:-test}"
export AUTH0_BASE_URL="${AUTH0_BASE_URL:-${APP_BASE_URL:-test}}"
export AUTH0_SECRET="${AUTH0_SECRET:-test}"
export AUTH0_CLIENT_SECRET="${AUTH0_CLIENT_SECRET:-test}"
# Start with the base command and profile flags
CMD="docker compose"
CMD="$CMD --profile setup_qdrant"