mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
Project modals (#190)
* added the project create and select modals * Added logo and link to home page * minor changes to side bar * added import json and blank template options * added website like home page * change homepage to be more like website * fixed landing page text box * minor size changes to the home page * added prebuilt agents section * Minor changes to the prebuilt agent card * removed the project selection and new project options from side bar * fixed prebuilt agents * fixed import json * my assistants has pagination * add dark mode support to home page * addressed review comments * increase build agents textbox lines * fixed PR comments * move prebuilt assistant under build view * minor changes to home page * minor changes to home page * removed my assistants from side bar * removed sidebar items * fixed review comments * fixed review comments * Add "use client" directive to project-creation-utils.ts This file uses localStorage and calls server actions directly, requiring client-side execution. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix import of pipeline agents --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
10045c742d
commit
9a980f2f9f
8 changed files with 1092 additions and 164 deletions
|
|
@ -20,6 +20,17 @@ const KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || '';
|
|||
|
||||
const projectActionAuthorizationPolicy = container.resolve<IProjectActionAuthorizationPolicy>('projectActionAuthorizationPolicy');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function projectAuthCheck(projectId: string) {
|
||||
if (!USE_AUTH) {
|
||||
return;
|
||||
|
|
@ -115,11 +126,12 @@ export async function createProjectFromWorkflowJson(formData: FormData): Promise
|
|||
const name = formData.get('name') as string | null;
|
||||
|
||||
const workflowJson = formData.get('workflowJson') as string;
|
||||
const { agents, prompts, tools, startAgent } = Workflow.parse(workflowJson);
|
||||
const { agents, prompts, tools, pipelines, startAgent } = Workflow.parse(JSON.parse(workflowJson));
|
||||
const response = await createBaseProject(name || 'Imported project', user, {
|
||||
agents,
|
||||
prompts,
|
||||
tools,
|
||||
pipelines,
|
||||
startAgent,
|
||||
lastUpdatedAt: (new Date()).toISOString(),
|
||||
});
|
||||
|
|
|
|||
78
apps/rowboat/app/components/ui/textarea-with-send.tsx
Normal file
78
apps/rowboat/app/components/ui/textarea-with-send.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
'use client';
|
||||
|
||||
import { forwardRef, TextareaHTMLAttributes } from 'react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Send } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface TextareaWithSendProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'> {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
isSubmitting?: boolean;
|
||||
submitDisabled?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
rows?: number;
|
||||
autoFocus?: boolean;
|
||||
autoResize?: boolean;
|
||||
}
|
||||
|
||||
export const TextareaWithSend = forwardRef<HTMLTextAreaElement, TextareaWithSendProps>(
|
||||
({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
submitDisabled = false,
|
||||
placeholder,
|
||||
className,
|
||||
rows = 3,
|
||||
autoFocus = false,
|
||||
autoResize = false,
|
||||
...props
|
||||
}, ref) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={clsx("pr-14", className)}
|
||||
rows={rows}
|
||||
autoFocus={autoFocus}
|
||||
autoResize={autoResize}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
<div className="absolute right-3 bottom-3">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || submitDisabled || !value.trim()}
|
||||
className={clsx(
|
||||
"rounded-full p-2 transition-all duration-200",
|
||||
value.trim()
|
||||
? "bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:hover:bg-indigo-800/60 dark:text-indigo-300"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500",
|
||||
isSubmitting ? "opacity-50" : "hover:scale-105 active:scale-95"
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
|
||||
) : (
|
||||
<Send size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TextareaWithSend.displayName = 'TextareaWithSend';
|
||||
|
|
@ -4,17 +4,14 @@ import { Project } from "../lib/types/project_types";
|
|||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { listProjects } from "../actions/project_actions";
|
||||
import { USE_MULTIPLE_PROJECTS } from "@/app/lib/feature_flags";
|
||||
import { SearchProjects } from "./components/search-projects";
|
||||
import { CreateProject } from "./components/create-project";
|
||||
import clsx from 'clsx';
|
||||
import { BuildAssistantSection } from "./components/build-assistant-section";
|
||||
|
||||
|
||||
export default function App() {
|
||||
const [projects, setProjects] = useState<z.infer<typeof Project>[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isProjectPaneOpen, setIsProjectPaneOpen] = useState(false);
|
||||
const [defaultName, setDefaultName] = useState('Assistant 1');
|
||||
|
||||
|
||||
const getNextAssistantNumber = (projects: z.infer<typeof Project>[]) => {
|
||||
const untitledProjects = projects
|
||||
.map(p => p.name)
|
||||
|
|
@ -32,22 +29,16 @@ export default function App() {
|
|||
let ignore = false;
|
||||
|
||||
async function fetchProjects() {
|
||||
setIsLoading(true);
|
||||
const projects = await listProjects();
|
||||
if (!ignore) {
|
||||
const sortedProjects = [...projects].sort((a, b) =>
|
||||
const sortedProjects = [...projects].sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
|
||||
|
||||
setProjects(sortedProjects);
|
||||
setIsLoading(false);
|
||||
const nextNumber = getNextAssistantNumber(sortedProjects);
|
||||
const newDefaultName = `Assistant ${nextNumber}`;
|
||||
setDefaultName(newDefaultName);
|
||||
// Default open project pane if there is at least one project
|
||||
if (sortedProjects.length > 0) {
|
||||
setIsProjectPaneOpen(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,29 +50,8 @@ export default function App() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex gap-8 px-16 pt-8">
|
||||
{USE_MULTIPLE_PROJECTS && isProjectPaneOpen && (
|
||||
<div className="w-1/3 min-w-[300px] max-w-[400px]">
|
||||
<SearchProjects
|
||||
projects={projects}
|
||||
isLoading={isLoading}
|
||||
heading="Select existing assistant"
|
||||
className="h-full"
|
||||
onClose={() => setIsProjectPaneOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={clsx(
|
||||
"flex-1",
|
||||
!isProjectPaneOpen && "w-full",
|
||||
)}>
|
||||
<CreateProject
|
||||
defaultName={defaultName}
|
||||
onOpenProjectPane={() => setIsProjectPaneOpen(true)}
|
||||
isProjectPaneOpen={isProjectPaneOpen}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<BuildAssistantSection defaultName={defaultName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
467
apps/rowboat/app/projects/components/build-assistant-section.tsx
Normal file
467
apps/rowboat/app/projects/components/build-assistant-section.tsx
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { listTemplates, listProjects } from "@/app/actions/project_actions";
|
||||
import { createProjectWithOptions, createProjectFromJsonWithOptions, createProjectFromTemplate } from "../lib/project-creation-utils";
|
||||
import { useRouter } from 'next/navigation';
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
import mascotImage from '/public/mascot.png';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Upload, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { TextareaWithSend } from "@/app/components/ui/textarea-with-send";
|
||||
import { Workflow } from '../../lib/types/workflow_types';
|
||||
import { PictureImg } from '@/components/ui/picture-img';
|
||||
import { Tabs, Tab } from "@/components/ui/tabs";
|
||||
import { Project } from "@/app/lib/types/project_types";
|
||||
import { z } from "zod";
|
||||
import Link from 'next/link';
|
||||
|
||||
|
||||
|
||||
interface BuildAssistantSectionProps {
|
||||
defaultName: string;
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 6;
|
||||
|
||||
export function BuildAssistantSection({ defaultName }: BuildAssistantSectionProps) {
|
||||
const [userPrompt, setUserPrompt] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [promptError, setPromptError] = useState<string | null>(null);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [templates, setTemplates] = useState<any[]>([]);
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false);
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null);
|
||||
const [projects, setProjects] = useState<z.infer<typeof Project>[]>([]);
|
||||
const [projectsLoading, setProjectsLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedTab, setSelectedTab] = useState('new');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const totalPages = Math.ceil(projects.length / ITEMS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
const currentProjects = projects.slice(startIndex, endIndex);
|
||||
|
||||
// Extract unique tools from template - using same approach as ToolkitCard
|
||||
const getUniqueTools = (template: any) => {
|
||||
if (!template.tools) return [];
|
||||
|
||||
const uniqueToolsMap = new Map();
|
||||
template.tools.forEach((tool: any) => {
|
||||
if (!uniqueToolsMap.has(tool.name)) {
|
||||
// Include all tools, following the same pattern as Composio toolkit cards
|
||||
const toolData = {
|
||||
name: tool.name,
|
||||
isComposio: tool.isComposio,
|
||||
isLibrary: tool.isLibrary,
|
||||
logo: tool.isComposio && tool.composioData?.logo ? tool.composioData.logo : null,
|
||||
};
|
||||
|
||||
uniqueToolsMap.set(tool.name, toolData);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle template selection
|
||||
const handleTemplateSelect = async (templateId: string, templateName: string) => {
|
||||
await createProjectFromTemplate(templateId, templateName, router);
|
||||
};
|
||||
|
||||
const fetchProjects = async () => {
|
||||
setProjectsLoading(true);
|
||||
try {
|
||||
const projectsList = await listProjects();
|
||||
const sortedProjects = [...projectsList].sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
setProjects(sortedProjects);
|
||||
} catch (error) {
|
||||
console.error('Error fetching projects:', error);
|
||||
} finally {
|
||||
setProjectsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const handleCreateAssistant = async () => {
|
||||
if (!userPrompt.trim()) {
|
||||
setPromptError("Prompt cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await createProjectWithOptions({
|
||||
name: defaultName,
|
||||
prompt: userPrompt,
|
||||
router,
|
||||
onError: (error) => {
|
||||
console.error('Error creating project:', error);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Import JSON functionality
|
||||
const handleImportJsonClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
setTimeout(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
setImportLoading(true);
|
||||
setImportError(null);
|
||||
try {
|
||||
const text = await file.text();
|
||||
let parsed = Workflow.safeParse(JSON.parse(text));
|
||||
if (!parsed.success) {
|
||||
setImportError('Invalid workflow JSON: ' + JSON.stringify(parsed.error.issues));
|
||||
setImportLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create project from imported JSON
|
||||
await createProjectFromJsonWithOptions({
|
||||
name: defaultName,
|
||||
workflowJson: text,
|
||||
router,
|
||||
onError: (error) => {
|
||||
setImportError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
setImportError('Invalid JSON: ' + (err instanceof Error ? err.message : String(err)));
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle "I'll build it myself" button
|
||||
const handleBuildItMyself = async () => {
|
||||
await createProjectWithOptions({
|
||||
name: defaultName,
|
||||
template: 'default',
|
||||
router
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<div className="px-8 py-16">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Main Headline */}
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-gray-100 mb-6 leading-tight">
|
||||
Build <span className="bg-gradient-to-r from-blue-500 to-purple-500 bg-clip-text text-transparent">Rowboats</span> that Work for You
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<Tab key="new" title="New Assistant">
|
||||
<div className="pt-4">
|
||||
<div className="flex items-center gap-12">
|
||||
{/* Mascot */}
|
||||
<div className="flex-shrink-0">
|
||||
<Image
|
||||
src={mascotImage}
|
||||
alt="Rowboat Mascot"
|
||||
width={200}
|
||||
height={200}
|
||||
className="w-[200px] h-[200px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="flex-1">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Hey! What agents can I build for you?
|
||||
</h2>
|
||||
<div className="relative group flex flex-col">
|
||||
<TextareaWithSend
|
||||
value={userPrompt}
|
||||
onChange={(value) => {
|
||||
setUserPrompt(value);
|
||||
setPromptError(null);
|
||||
}}
|
||||
onSubmit={handleCreateAssistant}
|
||||
isSubmitting={isCreating}
|
||||
placeholder="Example: build me an AI SDR agent..."
|
||||
className={clsx(
|
||||
"w-full rounded-lg p-3 border border-gray-200 dark:border-gray-700",
|
||||
"bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750",
|
||||
"focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20",
|
||||
"placeholder:text-gray-400 dark:placeholder:text-gray-500 transition-all duration-200",
|
||||
"text-base text-gray-900 dark:text-gray-100 min-h-32",
|
||||
promptError && "border-red-500 focus:ring-red-500/20",
|
||||
!userPrompt && "animate-pulse border-2 border-indigo-500/40 dark:border-indigo-400/40 shadow-lg shadow-indigo-500/20 dark:shadow-indigo-400/20"
|
||||
)}
|
||||
rows={3}
|
||||
autoFocus
|
||||
autoResize
|
||||
/>
|
||||
{promptError && (
|
||||
<p className="text-sm text-red-500 m-0 mt-2">
|
||||
{promptError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Separation line with OR */}
|
||||
<div className="relative my-3">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300 dark:border-gray-600"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white dark:bg-gray-800 px-3 text-gray-500 dark:text-gray-400">OR</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-3 justify-start">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleImportJsonClick}
|
||||
type="button"
|
||||
startContent={<Upload size={14} />}
|
||||
className="bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600"
|
||||
disabled={importLoading}
|
||||
>
|
||||
{importLoading ? 'Importing...' : 'Import JSON'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleBuildItMyself}
|
||||
type="button"
|
||||
className="bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600"
|
||||
>
|
||||
Go to Builder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{importError && (
|
||||
<p className="text-sm text-red-500 mt-2">
|
||||
{importError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab key="existing" title="My Assistants">
|
||||
<div className="pt-4">
|
||||
<div className="h-96 flex flex-col bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
{projectsLoading ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading assistants...
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-500 dark:text-gray-400">
|
||||
No assistants found. Create your first assistant to get started!
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{currentProjects.map((project) => (
|
||||
<Link
|
||||
key={project._id}
|
||||
href={`/projects/${project._id}/workflow`}
|
||||
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-all group hover:shadow-sm"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 opacity-75 flex-shrink-0"></div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate">
|
||||
{project.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Created {new Date(project.createdAt).toLocaleDateString()} • Last updated {new Date(project.lastUpdatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
→
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={clsx(
|
||||
"p-2 rounded-md transition-colors",
|
||||
"text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
)}
|
||||
>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Page {currentPage} of {totalPages} ({projects.length} assistants)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={clsx(
|
||||
"p-2 rounded-md transition-colors",
|
||||
"text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
)}
|
||||
>
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pre-built Assistants Section - Only show for New Assistant tab */}
|
||||
{selectedTab === 'new' && (
|
||||
<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">
|
||||
Pre-built Assistants
|
||||
</h2>
|
||||
</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>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => handleTemplateSelect(template.id, template.name)}
|
||||
className="block p-4 border border-gray-200 dark:border-gray-700 rounded-xl hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-all group hover:shadow-md text-left"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Tool logos */}
|
||||
{(() => {
|
||||
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>
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 opacity-75"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { createProject, createProjectFromWorkflowJson } from "@/app/actions/project_actions";
|
||||
import { createProjectWithOptions, createProjectFromJsonWithOptions } from "../lib/project-creation-utils";
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import clsx from 'clsx';
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -128,9 +128,10 @@ interface CreateProjectProps {
|
|||
defaultName: string;
|
||||
onOpenProjectPane: () => void;
|
||||
isProjectPaneOpen: boolean;
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpen }: CreateProjectProps) {
|
||||
export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpen, hideHeader = false }: CreateProjectProps) {
|
||||
const [selectedTab, setSelectedTab] = useState<TabState>(TabType.Describe);
|
||||
const [customPrompt, setCustomPrompt] = useState("");
|
||||
const [name, setName] = useState(defaultName);
|
||||
|
|
@ -168,26 +169,17 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
|
|||
if ((urlPrompt || urlTemplate) && !importLoading && !autoCreateLoading) {
|
||||
setAutoCreateLoading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
// If template is provided, use it
|
||||
if (urlTemplate) {
|
||||
formData.append('template', urlTemplate);
|
||||
}
|
||||
|
||||
const response = await createProject(formData);
|
||||
|
||||
if ('id' in response) {
|
||||
// Store prompt in localStorage if provided
|
||||
if (urlPrompt) {
|
||||
localStorage.setItem(`project_prompt_${response.id}`, urlPrompt);
|
||||
await createProjectWithOptions({
|
||||
name: 'New Assistant', // Default name for auto-creation
|
||||
template: urlTemplate || undefined,
|
||||
prompt: urlPrompt || undefined,
|
||||
router,
|
||||
onError: (error) => {
|
||||
// Auto-creation failed, show the form instead
|
||||
setBillingError(error instanceof Error ? error.message : String(error));
|
||||
setAutoCreateLoading(false);
|
||||
}
|
||||
router.push(`/projects/${response.id}/workflow`);
|
||||
} else {
|
||||
// Auto-creation failed, show the form instead
|
||||
setBillingError(response.billingError);
|
||||
setAutoCreateLoading(false);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error auto-creating project:', error);
|
||||
setAutoCreateLoading(false);
|
||||
|
|
@ -288,43 +280,47 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
|
|||
try {
|
||||
if (importedJson) {
|
||||
// Use imported JSON
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
formData.append('workflowJson', importedJson);
|
||||
const response = await createProjectFromWorkflowJson(formData);
|
||||
if ('id' in response) {
|
||||
router.push(`/projects/${response.id}/workflow`);
|
||||
} else {
|
||||
setBillingError(response.billingError);
|
||||
}
|
||||
await createProjectFromJsonWithOptions({
|
||||
name,
|
||||
workflowJson: importedJson,
|
||||
router,
|
||||
onError: (error) => {
|
||||
setBillingError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!customPrompt.trim()) {
|
||||
setPromptError("Prompt cannot be empty");
|
||||
return;
|
||||
}
|
||||
const newFormData = new FormData();
|
||||
newFormData.append('name', name);
|
||||
|
||||
// If template is provided via URL, use it
|
||||
if (urlTemplate) {
|
||||
newFormData.append('template', urlTemplate);
|
||||
}
|
||||
|
||||
const response = await createProject(newFormData);
|
||||
if ('id' in response) {
|
||||
if (customPrompt) {
|
||||
localStorage.setItem(`project_prompt_${response.id}`, customPrompt);
|
||||
await createProjectWithOptions({
|
||||
name,
|
||||
template: urlTemplate || undefined,
|
||||
prompt: customPrompt,
|
||||
router,
|
||||
onError: (error) => {
|
||||
setBillingError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
router.push(`/projects/${response.id}/workflow`);
|
||||
} else {
|
||||
setBillingError(response.billingError);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitWithTemplate(template: string) {
|
||||
await createProjectWithOptions({
|
||||
name,
|
||||
template,
|
||||
router,
|
||||
onError: (error) => {
|
||||
setBillingError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
|
|
@ -344,7 +340,7 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
|
|||
!USE_MULTIPLE_PROJECTS && "px-24",
|
||||
USE_MULTIPLE_PROJECTS && "px-8"
|
||||
)}>
|
||||
{USE_MULTIPLE_PROJECTS && (
|
||||
{USE_MULTIPLE_PROJECTS && !hideHeader && (
|
||||
<>
|
||||
<div className="px-4 pt-4 pb-6 flex justify-between items-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
|
|
@ -477,17 +473,40 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Import JSON button always below the main input, left-aligned, when no file is selected */}
|
||||
<div className="mt-2">
|
||||
|
||||
{/* Separation line with OR */}
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300 dark:border-gray-600"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white dark:bg-gray-900 px-3 text-gray-500 dark:text-gray-400">OR</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-3 justify-start">
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleImportJsonClick}
|
||||
type="button"
|
||||
startContent={<Upload size={16} />}
|
||||
startContent={<Upload size={14} />}
|
||||
className="bg-white dark:bg-white text-gray-900 hover:bg-gray-50 border border-gray-300 dark:border-gray-300"
|
||||
>
|
||||
Import JSON
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleSubmitWithTemplate('default');
|
||||
}}
|
||||
type="button"
|
||||
className="bg-white dark:bg-white text-gray-900 hover:bg-gray-50 border border-gray-300 dark:border-gray-300"
|
||||
>
|
||||
I'll build it myself
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
145
apps/rowboat/app/projects/components/templates-section.tsx
Normal file
145
apps/rowboat/app/projects/components/templates-section.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { listTemplates } from "@/app/actions/project_actions";
|
||||
import { createProjectFromTemplate } from "../lib/project-creation-utils";
|
||||
import { PictureImg } from '@/components/ui/picture-img';
|
||||
|
||||
interface TemplatesSectionProps {}
|
||||
|
||||
export function TemplatesSection({}: TemplatesSectionProps) {
|
||||
const [templates, setTemplates] = useState<any[]>([]);
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false);
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Extract unique tools from template - using same approach as ToolkitCard
|
||||
const getUniqueTools = (template: any) => {
|
||||
if (!template.tools) return [];
|
||||
|
||||
const uniqueToolsMap = new Map();
|
||||
template.tools.forEach((tool: any) => {
|
||||
if (!uniqueToolsMap.has(tool.name)) {
|
||||
// Include all tools, following the same pattern as Composio toolkit cards
|
||||
const toolData = {
|
||||
name: tool.name,
|
||||
isComposio: tool.isComposio,
|
||||
isLibrary: tool.isLibrary,
|
||||
logo: tool.isComposio && tool.composioData?.logo ? tool.composioData.logo : null,
|
||||
};
|
||||
|
||||
uniqueToolsMap.set(tool.name, toolData);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle template selection
|
||||
const handleTemplateSelect = async (templateId: string, templateName: string) => {
|
||||
await createProjectFromTemplate(templateId, templateName, router);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col px-8 py-8 overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto w-full flex flex-col h-full">
|
||||
<div className="px-6 pb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Pre-built agents
|
||||
</h2>
|
||||
</div>
|
||||
<div className="px-6 flex-1 overflow-hidden">
|
||||
{templatesLoading ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading templates...
|
||||
</div>
|
||||
) : templatesError ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-red-500 dark:text-red-400">
|
||||
Error: {templatesError}
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-500 dark:text-gray-400">
|
||||
No templates available
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4">
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => handleTemplateSelect(template.id, template.name)}
|
||||
className="block p-4 border border-gray-200 dark:border-gray-700 rounded-xl hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-all group hover:shadow-md text-left"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Tool logos */}
|
||||
{(() => {
|
||||
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>
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 opacity-75"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import logoImage from '/public/logo-only.png';
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Tooltip } from "@heroui/react";
|
||||
import { Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react";
|
||||
import { UserButton } from "@/app/lib/components/user_button";
|
||||
import {
|
||||
SettingsIcon,
|
||||
WorkflowIcon,
|
||||
PlayIcon,
|
||||
FolderOpenIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
Moon,
|
||||
|
|
@ -18,9 +19,13 @@ import {
|
|||
LogsIcon
|
||||
} from "lucide-react";
|
||||
import { getProjectConfig } from "@/app/actions/project_actions";
|
||||
import { createProjectWithOptions } from "../../lib/project-creation-utils";
|
||||
import { useTheme } from "@/app/providers/theme-provider";
|
||||
import { USE_PRODUCT_TOUR } from '@/app/lib/feature_flags';
|
||||
import { useHelpModal } from "@/app/providers/help-modal-provider";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TextareaWithSend } from "@/app/components/ui/textarea-with-send";
|
||||
|
||||
interface SidebarProps {
|
||||
projectId?: string;
|
||||
|
|
@ -35,10 +40,15 @@ const COLLAPSED_ICON_SIZE = 20; // DO NOT CHANGE THIS
|
|||
|
||||
export default function Sidebar({ projectId, useAuth, collapsed = false, onToggleCollapse, useBilling }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [projectName, setProjectName] = useState<string>("Select Project");
|
||||
const [assistantName, setAssistantName] = useState("");
|
||||
const [assistantPrompt, setAssistantPrompt] = useState("");
|
||||
const [isCreatingAssistant, setIsCreatingAssistant] = useState(false);
|
||||
const isProjectsRoute = pathname === '/projects';
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { showHelpModal } = useHelpModal();
|
||||
const { isOpen: isCreateModalOpen, onOpen: onCreateModalOpen, onClose: onCreateModalClose } = useDisclosure();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchProjectName() {
|
||||
|
|
@ -55,6 +65,35 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
|
|||
fetchProjectName();
|
||||
}, [projectId, isProjectsRoute]);
|
||||
|
||||
|
||||
|
||||
const handleCreateAssistant = async () => {
|
||||
if (!assistantPrompt.trim()) return;
|
||||
|
||||
setIsCreatingAssistant(true);
|
||||
try {
|
||||
await createProjectWithOptions({
|
||||
name: assistantName || 'New Assistant',
|
||||
prompt: assistantPrompt,
|
||||
router,
|
||||
onSuccess: () => {
|
||||
onCreateModalClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error creating assistant:', error);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
setIsCreatingAssistant(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateModalClose = () => {
|
||||
setAssistantName("");
|
||||
setAssistantPrompt("");
|
||||
onCreateModalClose();
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
href: 'workflow',
|
||||
|
|
@ -82,6 +121,13 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
|
|||
}
|
||||
];
|
||||
|
||||
const projectsNavItems: Array<{
|
||||
href: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
requiresProject: boolean;
|
||||
}> = [];
|
||||
|
||||
const handleStartTour = () => {
|
||||
localStorage.removeItem('user_product_tour_completed');
|
||||
window.location.reload();
|
||||
|
|
@ -91,84 +137,86 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
|
|||
<>
|
||||
<aside className={`${collapsed ? 'w-16' : 'w-60'} bg-transparent flex flex-col h-full transition-all duration-300`}>
|
||||
<div className="flex flex-col grow">
|
||||
{!isProjectsRoute && (
|
||||
<>
|
||||
{/* Project Selector */}
|
||||
<div className="p-3 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<Tooltip content={collapsed ? projectName : "Change project"} showArrow placement="right">
|
||||
<Link
|
||||
href="/projects"
|
||||
className={`
|
||||
flex items-center rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800/50 transition-all
|
||||
${collapsed ? 'justify-center py-4' : 'gap-3 px-4 py-2.5'}
|
||||
`}
|
||||
>
|
||||
<FolderOpenIcon
|
||||
size={collapsed ? COLLAPSED_ICON_SIZE : EXPANDED_ICON_SIZE}
|
||||
className="text-zinc-500 dark:text-zinc-400 transition-all duration-200"
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span className="text-sm font-medium truncate">
|
||||
{projectName}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* Rowboat Logo */}
|
||||
<div className="p-3 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<Tooltip content={collapsed ? "Rowboat" : ""} showArrow placement="right">
|
||||
<Link
|
||||
href="/projects"
|
||||
className={`
|
||||
w-full flex items-center justify-center rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800/50 transition-all
|
||||
${collapsed ? 'py-3' : 'gap-3 px-4 py-2.5 justify-start'}
|
||||
`}
|
||||
>
|
||||
<Image
|
||||
src={logoImage}
|
||||
alt="Rowboat"
|
||||
width={collapsed ? 24 : 24}
|
||||
height={collapsed ? 24 : 24}
|
||||
className="rounded-full transition-all duration-200 flex-shrink-0"
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Rowboat
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Project-specific navigation Items */}
|
||||
{projectId && <nav className="p-3 space-y-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const fullPath = `/projects/${projectId}/${item.href}`;
|
||||
const isActive = pathname.startsWith(fullPath);
|
||||
const isDisabled = isProjectsRoute && item.requiresProject;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={item.href}
|
||||
content={collapsed ? item.label : ""}
|
||||
showArrow
|
||||
placement="right"
|
||||
{/* Navigation Items */}
|
||||
<nav className="p-3 space-y-4">
|
||||
{!isProjectsRoute && projectId && (
|
||||
// Project-specific navigation
|
||||
navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const fullPath = `/projects/${projectId}/${item.href}`;
|
||||
const isActive = pathname.startsWith(fullPath);
|
||||
const isDisabled = isProjectsRoute && item.requiresProject;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={item.href}
|
||||
content={collapsed ? item.label : ""}
|
||||
showArrow
|
||||
placement="right"
|
||||
>
|
||||
<Link
|
||||
href={isDisabled ? '#' : fullPath}
|
||||
className={`
|
||||
relative w-full rounded-md flex items-center
|
||||
text-[15px] font-medium transition-all duration-200
|
||||
${collapsed ? 'justify-center py-4' : 'px-2.5 py-3 gap-2.5'}
|
||||
${isActive
|
||||
? 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 border-l-2 border-indigo-600 dark:border-indigo-400'
|
||||
: isDisabled
|
||||
? 'text-zinc-300 dark:text-zinc-600 cursor-not-allowed'
|
||||
: 'text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800/50 hover:text-zinc-900 dark:hover:text-zinc-300'
|
||||
}
|
||||
${isDisabled ? 'pointer-events-none' : ''}
|
||||
`}
|
||||
data-tour-target={item.href === 'config' ? 'settings' : item.href === 'sources' ? 'entity-data-sources' : undefined}
|
||||
>
|
||||
<Link
|
||||
href={isDisabled ? '#' : fullPath}
|
||||
<Icon
|
||||
size={collapsed ? COLLAPSED_ICON_SIZE : EXPANDED_ICON_SIZE}
|
||||
className={`
|
||||
relative w-full rounded-md flex items-center
|
||||
text-[15px] font-medium transition-all duration-200
|
||||
${collapsed ? 'justify-center py-4' : 'px-2.5 py-3 gap-2.5'}
|
||||
${isActive
|
||||
? 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 border-l-2 border-indigo-600 dark:border-indigo-400'
|
||||
: isDisabled
|
||||
? 'text-zinc-300 dark:text-zinc-600 cursor-not-allowed'
|
||||
: 'text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800/50 hover:text-zinc-900 dark:hover:text-zinc-300'
|
||||
transition-all duration-200
|
||||
${isDisabled
|
||||
? 'text-zinc-300 dark:text-zinc-600'
|
||||
: isActive
|
||||
? 'text-indigo-600 dark:text-indigo-400'
|
||||
: 'text-zinc-500 dark:text-zinc-400'
|
||||
}
|
||||
${isDisabled ? 'pointer-events-none' : ''}
|
||||
`}
|
||||
data-tour-target={item.href === 'config' ? 'settings' : item.href === 'sources' ? 'entity-data-sources' : undefined}
|
||||
>
|
||||
<Icon
|
||||
size={collapsed ? COLLAPSED_ICON_SIZE : EXPANDED_ICON_SIZE}
|
||||
className={`
|
||||
transition-all duration-200
|
||||
${isDisabled
|
||||
? 'text-zinc-300 dark:text-zinc-600'
|
||||
: isActive
|
||||
? 'text-indigo-600 dark:text-indigo-400'
|
||||
: 'text-zinc-500 dark:text-zinc-400'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span>{item.label}</span>
|
||||
)}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</nav>}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span>{item.label}</span>
|
||||
)}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Bottom section */}
|
||||
|
|
@ -242,6 +290,74 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
|
|||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
{/* Create Assistant Modal */}
|
||||
<Modal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={handleCreateModalClose}
|
||||
size="2xl"
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
Create New Assistant
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-4">
|
||||
{/* Assistant Name Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Assistant Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={assistantName}
|
||||
onChange={(e) => setAssistantName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Assistant 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Assistant Description/Prompt */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
What do you want to build?
|
||||
</label>
|
||||
<TextareaWithSend
|
||||
value={assistantPrompt}
|
||||
onChange={setAssistantPrompt}
|
||||
onSubmit={handleCreateAssistant}
|
||||
isSubmitting={isCreatingAssistant}
|
||||
placeholder="Example: Create a customer support assistant that can handle product inquiries and returns"
|
||||
className="w-full min-h-[120px] border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
In the next step, our AI copilot will create agents for you, complete with mock-tools.
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCreateModalClose}
|
||||
disabled={isCreatingAssistant}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreateAssistant}
|
||||
disabled={isCreatingAssistant || !assistantPrompt.trim()}
|
||||
>
|
||||
{isCreatingAssistant ? "Creating..." : "Create Assistant"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
apps/rowboat/app/projects/lib/project-creation-utils.ts
Normal file
121
apps/rowboat/app/projects/lib/project-creation-utils.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import { createProject, createProjectFromWorkflowJson } from "@/app/actions/project_actions";
|
||||
|
||||
export interface CreateProjectOptions {
|
||||
name: string;
|
||||
template?: string;
|
||||
prompt?: string;
|
||||
router: any; // NextJS router instance
|
||||
onSuccess?: (projectId: string) => void;
|
||||
onError?: (error: any) => void;
|
||||
}
|
||||
|
||||
export interface CreateProjectFromJsonOptions {
|
||||
name: string;
|
||||
workflowJson: string;
|
||||
router: any; // NextJS router instance
|
||||
onSuccess?: (projectId: string) => void;
|
||||
onError?: (error: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated function to create a project with consistent error handling and navigation
|
||||
*/
|
||||
export async function createProjectWithOptions(options: CreateProjectOptions): Promise<void> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('name', options.name);
|
||||
|
||||
if (options.template) {
|
||||
formData.append('template', options.template);
|
||||
}
|
||||
|
||||
const response = await createProject(formData);
|
||||
|
||||
if ('id' in response) {
|
||||
// Store prompt in localStorage if provided
|
||||
if (options.prompt?.trim()) {
|
||||
localStorage.setItem(`project_prompt_${response.id}`, options.prompt);
|
||||
}
|
||||
|
||||
// Call success callback if provided
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess(response.id);
|
||||
}
|
||||
|
||||
// Navigate to workflow page
|
||||
options.router.push(`/projects/${response.id}/workflow`);
|
||||
} else {
|
||||
// Handle error response
|
||||
const error = (response as any).billingError || 'Failed to create project';
|
||||
if (options.onError) {
|
||||
options.onError(error);
|
||||
} else {
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
if (options.onError) {
|
||||
options.onError(error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated function to create a project from JSON workflow
|
||||
*/
|
||||
export async function createProjectFromJsonWithOptions(options: CreateProjectFromJsonOptions): Promise<void> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('name', options.name);
|
||||
formData.append('workflowJson', options.workflowJson);
|
||||
|
||||
const response = await createProjectFromWorkflowJson(formData);
|
||||
|
||||
if ('id' in response) {
|
||||
// Call success callback if provided
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess(response.id);
|
||||
}
|
||||
|
||||
// Navigate to workflow page
|
||||
options.router.push(`/projects/${response.id}/workflow`);
|
||||
} else {
|
||||
// Handle error response
|
||||
const error = (response as any).billingError || 'Failed to create project';
|
||||
if (options.onError) {
|
||||
options.onError(error);
|
||||
} else {
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating project from JSON:', error);
|
||||
if (options.onError) {
|
||||
options.onError(error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated function to create a project from template selection
|
||||
*/
|
||||
export async function createProjectFromTemplate(
|
||||
templateId: string,
|
||||
templateName: string,
|
||||
router: any,
|
||||
onError?: (error: any) => void
|
||||
): Promise<void> {
|
||||
return createProjectWithOptions({
|
||||
name: templateName,
|
||||
template: templateId,
|
||||
router,
|
||||
onError
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue