mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
add composio triggers (#192)
This commit is contained in:
parent
5e706f0684
commit
3552302f4a
72 changed files with 4887 additions and 111 deletions
|
|
@ -0,0 +1,19 @@
|
|||
import { Metadata } from "next";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
import { ConversationView } from "../components/conversation-view";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Conversation",
|
||||
};
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ projectId: string, conversationId: string }>
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
await requireActiveBillingSubscription();
|
||||
return <ConversationView projectId={params.projectId} conversationId={params.conversationId} />;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { fetchConversation } from "@/app/actions/conversation_actions";
|
||||
import { Conversation } from "@/src/entities/models/conversation";
|
||||
import { Turn } from "@/src/entities/models/turn";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
import { MessageDisplay } from "../../../../lib/components/message-display";
|
||||
|
||||
function TurnReason({ reason }: { reason: z.infer<typeof Turn>['reason'] }) {
|
||||
const getReasonDisplay = () => {
|
||||
switch (reason.type) {
|
||||
case 'chat':
|
||||
return { label: 'CHAT', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' };
|
||||
case 'api':
|
||||
return { label: 'API', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' };
|
||||
case 'job':
|
||||
return { label: `JOB: ${reason.jobId}`, color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' };
|
||||
default:
|
||||
return { label: 'UNKNOWN', color: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300' };
|
||||
}
|
||||
};
|
||||
|
||||
const { label, color } = getReasonDisplay();
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-mono font-medium ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnReasonWithLink({ reason, projectId }: { reason: z.infer<typeof Turn>['reason']; projectId: string }) {
|
||||
const getReasonDisplay = () => {
|
||||
switch (reason.type) {
|
||||
case 'chat':
|
||||
return { label: 'CHAT', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' };
|
||||
case 'api':
|
||||
return { label: 'API', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' };
|
||||
case 'job':
|
||||
return {
|
||||
label: `JOB: ${reason.jobId}`,
|
||||
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
isJob: true,
|
||||
jobId: reason.jobId
|
||||
};
|
||||
default:
|
||||
return { label: 'UNKNOWN', color: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300' };
|
||||
}
|
||||
};
|
||||
|
||||
const { label, color, isJob, jobId } = getReasonDisplay();
|
||||
|
||||
if (isJob && jobId) {
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${projectId}/jobs/${jobId}`}
|
||||
className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-mono font-medium ${color} hover:opacity-80 transition-opacity`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-mono font-medium ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnContainer({ turn, index, projectId }: { turn: z.infer<typeof Turn>; index: number; projectId: string }) {
|
||||
return (
|
||||
<div id={`turn-${turn.id}`} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
{/* Turn Header */}
|
||||
<div className="bg-gray-100 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-mono font-semibold text-gray-700 dark:text-gray-300">
|
||||
TURN #{index + 1}
|
||||
</span>
|
||||
<TurnReasonWithLink reason={turn.reason} projectId={projectId} />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500">
|
||||
{new Date(turn.createdAt).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Turn Content */}
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{/* Input Messages */}
|
||||
{turn.input.messages && turn.input.messages.length > 0 && (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-900/50">
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">
|
||||
Input Messages ({turn.input.messages.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{turn.input.messages.map((message, msgIndex) => (
|
||||
<MessageDisplay key={`input-${msgIndex}`} message={message} index={msgIndex} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output Messages */}
|
||||
{turn.output && turn.output.length > 0 && (
|
||||
<div className="p-4">
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">
|
||||
Output Messages ({turn.output.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{turn.output.map((message, msgIndex) => (
|
||||
<MessageDisplay key={`output-${msgIndex}`} message={message} index={msgIndex} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{turn.error && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/10 border-l-4 border-red-500">
|
||||
<div className="text-xs font-semibold text-red-600 dark:text-red-400 mb-1 uppercase tracking-wide">
|
||||
Error
|
||||
</div>
|
||||
<div className="text-sm text-red-700 dark:text-red-300 font-mono">
|
||||
{turn.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationView({ projectId, conversationId }: { projectId: string; conversationId: string; }) {
|
||||
const [conversation, setConversation] = useState<z.infer<typeof Conversation> | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetchConversation({ conversationId });
|
||||
if (ignore) return;
|
||||
setConversation(res);
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => { ignore = true; };
|
||||
}, [conversationId]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!conversation) return 'Conversation';
|
||||
return `Conversation ${conversation.id}`;
|
||||
}, [conversation]);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={<div className="flex items-center gap-3"><div className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</div></div>}
|
||||
rightActions={<div className="flex items-center gap-3"></div>}
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[1024px] mx-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && conversation && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Conversation Metadata */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Conversation ID:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{conversation.id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
|
||||
{new Date(conversation.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{conversation.updatedAt && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Updated:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
|
||||
{new Date(conversation.updatedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Live Workflow:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
|
||||
{conversation.isLiveWorkflow ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Turns */}
|
||||
{conversation.turns && conversation.turns.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||
Turns ({conversation.turns.length})
|
||||
</div>
|
||||
{conversation.turns.map((turn, index) => (
|
||||
<TurnContainer key={turn.id} turn={turn} index={index} projectId={projectId} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<div className="text-sm font-mono">No turns in this conversation.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link, Spinner } from "@heroui/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { listConversations } from "@/app/actions/conversation_actions";
|
||||
import { z } from "zod";
|
||||
import { ListedConversationItem } from "@/src/application/repositories/conversations.repository.interface";
|
||||
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
|
||||
|
||||
type ListedItem = z.infer<typeof ListedConversationItem>;
|
||||
|
||||
export function ConversationsList({ projectId }: { projectId: string }) {
|
||||
const [items, setItems] = useState<ListedItem[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||
|
||||
const fetchPage = useCallback(async (cursorArg?: string | null) => {
|
||||
const res = await listConversations({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
|
||||
return res;
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetchPage(null);
|
||||
if (ignore) return;
|
||||
setItems(res.items);
|
||||
setCursor(res.nextCursor);
|
||||
setHasMore(Boolean(res.nextCursor));
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => { ignore = true; };
|
||||
}, [fetchPage]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!cursor) return;
|
||||
setLoadingMore(true);
|
||||
const res = await fetchPage(cursor);
|
||||
setItems(prev => [...prev, ...res.items]);
|
||||
setCursor(res.nextCursor);
|
||||
setHasMore(Boolean(res.nextCursor));
|
||||
setLoadingMore(false);
|
||||
}, [cursor, fetchPage]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const groups: Record<string, ListedItem[]> = {
|
||||
Today: [],
|
||||
'This week': [],
|
||||
'This month': [],
|
||||
Older: [],
|
||||
};
|
||||
for (const item of items) {
|
||||
const d = new Date(item.createdAt);
|
||||
if (isToday(d)) groups['Today'].push(item);
|
||||
else if (isThisWeek(d)) groups['This week'].push(item);
|
||||
else if (isThisMonth(d)) groups['This month'].push(item);
|
||||
else groups['Older'].push(item);
|
||||
}
|
||||
return groups;
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
CONVERSATIONS
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
rightActions={
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Reserved for future actions */}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[1024px] mx-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="mt-4 text-center">No conversations yet.</p>
|
||||
)}
|
||||
{!loading && items.length > 0 && (
|
||||
<div className="flex flex-col gap-8">
|
||||
{Object.entries(sections).map(([label, group]) => (
|
||||
group.length > 0 ? (
|
||||
<div key={label}>
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3">{label}</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Conversation</th>
|
||||
<th className="w-[30%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{group.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td className="px-6 py-4 text-left">
|
||||
<Link
|
||||
href={`/projects/${projectId}/conversations/${c.id}`}
|
||||
size="lg"
|
||||
isBlock
|
||||
className="text-sm text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
|
||||
>
|
||||
{c.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-left text-sm text-gray-600 dark:text-gray-300">
|
||||
{new Date(c.createdAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? 'Loading...' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
19
apps/rowboat/app/projects/[projectId]/conversations/page.tsx
Normal file
19
apps/rowboat/app/projects/[projectId]/conversations/page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Metadata } from "next";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
import { ConversationsList } from "./components/conversations-list";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Conversations",
|
||||
};
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
await requireActiveBillingSubscription();
|
||||
return <ConversationsList projectId={params.projectId} />;
|
||||
}
|
||||
|
||||
|
||||
17
apps/rowboat/app/projects/[projectId]/jobs/[jobId]/page.tsx
Normal file
17
apps/rowboat/app/projects/[projectId]/jobs/[jobId]/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Metadata } from "next";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
import { JobView } from "../components/job-view";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Job",
|
||||
};
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ projectId: string, jobId: string }>
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
await requireActiveBillingSubscription();
|
||||
return <JobView projectId={params.projectId} jobId={params.jobId} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { fetchJob } from "@/app/actions/job_actions";
|
||||
import { Job } from "@/src/entities/models/job";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
import { MessageDisplay } from "../../../../lib/components/message-display";
|
||||
|
||||
export function JobView({ projectId, jobId }: { projectId: string; jobId: string; }) {
|
||||
const [job, setJob] = useState<z.infer<typeof Job> | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetchJob({ jobId });
|
||||
if (ignore) return;
|
||||
setJob(res);
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => { ignore = true; };
|
||||
}, [jobId]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!job) return 'Job';
|
||||
return `Job ${job.id}`;
|
||||
}, [job]);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
case 'failed':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
case 'running':
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
case 'pending':
|
||||
return 'text-yellow-600 dark:text-yellow-400';
|
||||
default:
|
||||
return 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getReasonDisplay = (reason: any) => {
|
||||
if (reason.type === 'composio_trigger') {
|
||||
return {
|
||||
type: 'Composio Trigger',
|
||||
details: {
|
||||
'Trigger Type': reason.triggerTypeSlug,
|
||||
'Trigger ID': reason.triggerId,
|
||||
'Deployment ID': reason.triggerDeploymentId,
|
||||
},
|
||||
payload: reason.payload
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'Unknown',
|
||||
details: {},
|
||||
payload: null
|
||||
};
|
||||
};
|
||||
|
||||
// Extract conversation and turn IDs from job output
|
||||
const conversationId = job?.output?.conversationId;
|
||||
const turnId = job?.output?.turnId;
|
||||
const reasonInfo = job ? getReasonDisplay(job.reason) : null;
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={<div className="flex items-center gap-3"><div className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</div></div>}
|
||||
rightActions={<div className="flex items-center gap-3"></div>}
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[1024px] mx-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && job && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Job Metadata */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Job ID:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{job.id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Status:</span>
|
||||
<span className={`ml-2 font-mono ${getStatusColor(job.status)}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
|
||||
{new Date(job.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{job.updatedAt && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Updated:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
|
||||
{new Date(job.updatedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{conversationId && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Conversation:</span>
|
||||
<Link
|
||||
href={`/projects/${projectId}/conversations/${conversationId}`}
|
||||
className="ml-2 font-mono text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{conversationId}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{turnId && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Turn:</span>
|
||||
<Link
|
||||
href={`/projects/${projectId}/conversations/${conversationId}#turn-${turnId}`}
|
||||
className="ml-2 font-mono text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{turnId}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{job.output?.error && (
|
||||
<div className="col-span-2">
|
||||
<span className="font-semibold text-red-700 dark:text-red-300">Error:</span>
|
||||
<span className="ml-2 font-mono text-red-600 dark:text-red-400">
|
||||
{job.output.error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job Reason */}
|
||||
{reasonInfo && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">
|
||||
Job Reason
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
|
||||
{reasonInfo.type}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 text-sm">
|
||||
{Object.entries(reasonInfo.details).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">{key}:</span>
|
||||
<span className="font-mono text-gray-600 dark:text-gray-400">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reasonInfo.payload && Object.keys(reasonInfo.payload).length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
|
||||
Trigger Payload
|
||||
</div>
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono max-h-[300px]">
|
||||
{JSON.stringify(reasonInfo.payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job Input */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">
|
||||
Job Input
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* Messages */}
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
|
||||
Messages ({job.input.messages.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{job.input.messages.map((message, msgIndex) => (
|
||||
<MessageDisplay key={`input-${msgIndex}`} message={message} index={msgIndex} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow */}
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
|
||||
Workflow
|
||||
</div>
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono max-h-[400px]">
|
||||
{JSON.stringify(job.input.workflow, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job Output */}
|
||||
{job.output && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">
|
||||
Job Output
|
||||
</div>
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono">
|
||||
{JSON.stringify(job.output, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !job && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<div className="text-sm font-mono">Job not found.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link, Spinner } from "@heroui/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { listJobs } from "@/app/actions/job_actions";
|
||||
import { z } from "zod";
|
||||
import { ListedJobItem } from "@/src/application/repositories/jobs.repository.interface";
|
||||
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
|
||||
|
||||
type ListedItem = z.infer<typeof ListedJobItem>;
|
||||
|
||||
export function JobsList({ projectId }: { projectId: string }) {
|
||||
const [items, setItems] = useState<ListedItem[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||
|
||||
const fetchPage = useCallback(async (cursorArg?: string | null) => {
|
||||
const res = await listJobs({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
|
||||
return res;
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetchPage(null);
|
||||
if (ignore) return;
|
||||
setItems(res.items);
|
||||
setCursor(res.nextCursor);
|
||||
setHasMore(Boolean(res.nextCursor));
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => { ignore = true; };
|
||||
}, [fetchPage]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!cursor) return;
|
||||
setLoadingMore(true);
|
||||
const res = await fetchPage(cursor);
|
||||
setItems(prev => [...prev, ...res.items]);
|
||||
setCursor(res.nextCursor);
|
||||
setHasMore(Boolean(res.nextCursor));
|
||||
setLoadingMore(false);
|
||||
}, [cursor, fetchPage]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const groups: Record<string, ListedItem[]> = {
|
||||
Today: [],
|
||||
'This week': [],
|
||||
'This month': [],
|
||||
Older: [],
|
||||
};
|
||||
for (const item of items) {
|
||||
const d = new Date(item.createdAt);
|
||||
if (isToday(d)) groups['Today'].push(item);
|
||||
else if (isThisWeek(d)) groups['This week'].push(item);
|
||||
else if (isThisMonth(d)) groups['This month'].push(item);
|
||||
else groups['Older'].push(item);
|
||||
}
|
||||
return groups;
|
||||
}, [items]);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
case 'failed':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
case 'running':
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
case 'pending':
|
||||
return 'text-yellow-600 dark:text-yellow-400';
|
||||
default:
|
||||
return 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getReasonDisplay = (reason: any) => {
|
||||
if (reason.type === 'composio_trigger') {
|
||||
return `Composio: ${reason.triggerTypeSlug}`;
|
||||
}
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
JOBS
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
rightActions={
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Reserved for future actions */}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[1024px] mx-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="mt-4 text-center">No jobs yet.</p>
|
||||
)}
|
||||
{!loading && items.length > 0 && (
|
||||
<div className="flex flex-col gap-8">
|
||||
{Object.entries(sections).map(([label, group]) => (
|
||||
group.length > 0 ? (
|
||||
<div key={label}>
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3">{label}</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Job</th>
|
||||
<th className="w-[20%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
|
||||
<th className="w-[25%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Reason</th>
|
||||
<th className="w-[25%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{group.map((job) => (
|
||||
<tr key={job.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td className="px-6 py-4 text-left">
|
||||
<Link
|
||||
href={`/projects/${projectId}/jobs/${job.id}`}
|
||||
size="lg"
|
||||
isBlock
|
||||
className="text-sm text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
|
||||
>
|
||||
{job.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-left">
|
||||
<span className={`text-sm font-medium ${getStatusColor(job.status)}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-left">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300 font-mono">
|
||||
{getReasonDisplay(job.reason)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-left text-sm text-gray-600 dark:text-gray-300">
|
||||
{new Date(job.createdAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? 'Loading...' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
17
apps/rowboat/app/projects/[projectId]/jobs/page.tsx
Normal file
17
apps/rowboat/app/projects/[projectId]/jobs/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Metadata } from "next";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
import { JobsList } from "./components/jobs-list";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Jobs",
|
||||
};
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
await requireActiveBillingSubscription();
|
||||
return <JobsList projectId={params.projectId} />;
|
||||
}
|
||||
|
|
@ -10,34 +10,31 @@ import { getProjectConfig } from '@/app/actions/project_actions';
|
|||
import { z } from 'zod';
|
||||
import { ZToolkit, ZListResponse, ZTool } from '@/app/lib/composio/composio';
|
||||
import { Project } from '@/app/lib/types/project_types';
|
||||
import { ComposioToolsPanel } from './ComposioToolsPanel';
|
||||
import { ToolkitCard } from './ToolkitCard';
|
||||
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
|
||||
import { Workflow } from '@/app/lib/types/workflow_types';
|
||||
|
||||
type ToolkitType = z.infer<typeof ZToolkit>;
|
||||
type ToolkitListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>;
|
||||
type ProjectType = z.infer<typeof Project>;
|
||||
|
||||
interface ComposioProps {
|
||||
interface SelectComposioToolkitProps {
|
||||
projectId: string;
|
||||
tools: z.infer<typeof Workflow.shape.tools>;
|
||||
onAddTool: (tool: z.infer<typeof WorkflowTool>) => void;
|
||||
onSelectToolkit: (toolkit: ToolkitType) => void;
|
||||
initialToolkitSlug?: string | null;
|
||||
}
|
||||
|
||||
export function Composio({
|
||||
export function SelectComposioToolkit({
|
||||
projectId,
|
||||
tools,
|
||||
onAddTool,
|
||||
onSelectToolkit,
|
||||
initialToolkitSlug
|
||||
}: ComposioProps) {
|
||||
}: SelectComposioToolkitProps) {
|
||||
const [toolkits, setToolkits] = useState<ToolkitType[]>([]);
|
||||
const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null);
|
||||
const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false);
|
||||
|
||||
const loadProjectConfig = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -84,14 +81,8 @@ export function Composio({
|
|||
}, [projectId]);
|
||||
|
||||
const handleSelectToolkit = useCallback((toolkit: ToolkitType) => {
|
||||
setSelectedToolkit(toolkit);
|
||||
setIsToolsPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseToolsPanel = useCallback(() => {
|
||||
setSelectedToolkit(null);
|
||||
setIsToolsPanelOpen(false);
|
||||
}, []);
|
||||
onSelectToolkit(toolkit);
|
||||
}, [onSelectToolkit]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProjectConfig();
|
||||
|
|
@ -106,11 +97,10 @@ export function Composio({
|
|||
if (initialToolkitSlug && toolkits.length > 0) {
|
||||
const toolkit = toolkits.find(t => t.slug === initialToolkitSlug);
|
||||
if (toolkit) {
|
||||
setSelectedToolkit(toolkit);
|
||||
setIsToolsPanelOpen(true);
|
||||
onSelectToolkit(toolkit);
|
||||
}
|
||||
}
|
||||
}, [initialToolkitSlug, toolkits]);
|
||||
}, [initialToolkitSlug, toolkits, onSelectToolkit]);
|
||||
|
||||
const filteredToolkits = toolkits.filter(toolkit => {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
|
|
@ -226,15 +216,6 @@ export function Composio({
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools Panel */}
|
||||
{selectedToolkit && <ComposioToolsPanel
|
||||
toolkit={selectedToolkit}
|
||||
isOpen={isToolsPanelOpen}
|
||||
onClose={handleCloseToolsPanel}
|
||||
tools={tools}
|
||||
onAddTool={onAddTool}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,10 +3,12 @@
|
|||
import { useState } from 'react';
|
||||
import { Tabs, Tab } from '@/components/ui/tabs';
|
||||
import { CustomMcpServers } from './CustomMcpServer';
|
||||
import { Composio } from './Composio';
|
||||
import { SelectComposioToolkit } from './SelectComposioToolkit';
|
||||
import { ComposioToolsPanel } from './ComposioToolsPanel';
|
||||
import { AddWebhookTool } from './AddWebhookTool';
|
||||
import type { Key } from 'react';
|
||||
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
|
||||
import { ZToolkit } from '@/app/lib/composio/composio';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface ToolsConfigProps {
|
||||
|
|
@ -17,6 +19,8 @@ interface ToolsConfigProps {
|
|||
initialToolkitSlug?: string | null;
|
||||
}
|
||||
|
||||
type ToolkitType = z.infer<typeof ZToolkit>;
|
||||
|
||||
export function ToolsConfig({
|
||||
projectId,
|
||||
useComposioTools,
|
||||
|
|
@ -29,11 +33,28 @@ export function ToolsConfig({
|
|||
defaultActiveTab = 'composio';
|
||||
}
|
||||
const [activeTab, setActiveTab] = useState(defaultActiveTab);
|
||||
const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null);
|
||||
const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false);
|
||||
|
||||
const handleTabChange = (key: Key) => {
|
||||
setActiveTab(key.toString());
|
||||
};
|
||||
|
||||
const handleSelectToolkit = (toolkit: ToolkitType) => {
|
||||
setSelectedToolkit(toolkit);
|
||||
setIsToolsPanelOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseToolsPanel = () => {
|
||||
setSelectedToolkit(null);
|
||||
setIsToolsPanelOpen(false);
|
||||
};
|
||||
|
||||
const handleAddTool = (tool: z.infer<typeof WorkflowTool>) => {
|
||||
onAddTool(tool);
|
||||
handleCloseToolsPanel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs
|
||||
|
|
@ -46,10 +67,10 @@ export function ToolsConfig({
|
|||
{useComposioTools && (
|
||||
<Tab key="composio" title="Composio">
|
||||
<div className="mt-4 p-6">
|
||||
<Composio
|
||||
<SelectComposioToolkit
|
||||
projectId={projectId}
|
||||
tools={tools}
|
||||
onAddTool={onAddTool}
|
||||
onSelectToolkit={handleSelectToolkit}
|
||||
initialToolkitSlug={initialToolkitSlug}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -72,6 +93,17 @@ export function ToolsConfig({
|
|||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
{/* Tools Panel */}
|
||||
{selectedToolkit && (
|
||||
<ComposioToolsPanel
|
||||
toolkit={selectedToolkit}
|
||||
isOpen={isToolsPanelOpen}
|
||||
onClose={handleCloseToolsPanel}
|
||||
tools={tools}
|
||||
onAddTool={handleAddTool}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Button, Card, CardBody, CardHeader, Spinner } from '@heroui/react';
|
||||
import { ChevronLeft, ChevronRight, ZapIcon, ArrowLeft } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
|
||||
import { listComposioTriggerTypes } from '@/app/actions/composio_actions';
|
||||
import { ZToolkit } from '@/app/lib/composio/composio';
|
||||
|
||||
interface ComposioTriggerTypesPanelProps {
|
||||
toolkit: z.infer<typeof ZToolkit>;
|
||||
onBack: () => void;
|
||||
onSelectTriggerType: (triggerType: z.infer<typeof ComposioTriggerType>) => void;
|
||||
}
|
||||
|
||||
type TriggerType = z.infer<typeof ComposioTriggerType>;
|
||||
|
||||
export function ComposioTriggerTypesPanel({
|
||||
toolkit,
|
||||
onBack,
|
||||
onSelectTriggerType,
|
||||
}: ComposioTriggerTypesPanelProps) {
|
||||
const [triggerTypes, setTriggerTypes] = useState<TriggerType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const loadTriggerTypes = useCallback(async (resetList = false, nextCursor?: string) => {
|
||||
try {
|
||||
if (resetList) {
|
||||
setLoading(true);
|
||||
setTriggerTypes([]);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const response = await listComposioTriggerTypes(toolkit.slug, nextCursor);
|
||||
|
||||
if (resetList) {
|
||||
setTriggerTypes(response.items);
|
||||
} else {
|
||||
setTriggerTypes(prev => [...prev, ...response.items]);
|
||||
}
|
||||
|
||||
setCursor(response.nextCursor);
|
||||
setHasNextPage(!!response.nextCursor);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading trigger types:', err);
|
||||
setError('Failed to load trigger types. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [toolkit.slug]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (cursor && !loadingMore) {
|
||||
loadTriggerTypes(false, cursor);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerTypeSelect = (triggerType: TriggerType) => {
|
||||
onSelectTriggerType(triggerType);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTriggerTypes(true);
|
||||
}, [loadTriggerTypes]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="light" isIconOnly onPress={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{toolkit.name} Triggers
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select a trigger type to set up
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="lg" />
|
||||
<span className="ml-2">Loading trigger types...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="light" isIconOnly onPress={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{toolkit.name} Triggers
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select a trigger type to set up
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button variant="flat" onPress={() => loadTriggerTypes(true)}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="light" isIconOnly onPress={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{toolkit.name} Triggers
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select a trigger type to set up ({triggerTypes.length} available)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{triggerTypes.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<ZapIcon className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
No trigger types available
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
This toolkit doesn't have any trigger types configured.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{triggerTypes.map((triggerType) => (
|
||||
<Card
|
||||
key={triggerType.slug}
|
||||
className="cursor-pointer hover:shadow-md transition-shadow"
|
||||
isPressable
|
||||
onPress={() => handleTriggerTypeSelect(triggerType)}
|
||||
>
|
||||
<CardHeader className="flex gap-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<ZapIcon className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
{triggerType.name}
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
|
||||
{triggerType.description}
|
||||
</p>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onPress={() => handleTriggerTypeSelect(triggerType)}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
variant="flat"
|
||||
onPress={handleLoadMore}
|
||||
isLoading={loadingMore}
|
||||
startContent={!loadingMore ? <ChevronRight className="w-4 h-4" /> : null}
|
||||
>
|
||||
{loadingMore ? 'Loading...' : 'Load More'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Button, Input, Card, CardBody, CardHeader } from '@heroui/react';
|
||||
import { ArrowLeft, ZapIcon, CheckCircleIcon } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { ZToolkit } from '@/app/lib/composio/composio';
|
||||
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
|
||||
|
||||
interface TriggerConfigFormProps {
|
||||
toolkit: z.infer<typeof ZToolkit>;
|
||||
triggerType: z.infer<typeof ComposioTriggerType>;
|
||||
onBack: () => void;
|
||||
onSubmit: (config: Record<string, unknown>) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
interface JsonSchemaProperty {
|
||||
type: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
default?: any;
|
||||
enum?: any[];
|
||||
}
|
||||
|
||||
interface JsonSchema {
|
||||
type: 'object';
|
||||
properties: Record<string, JsonSchemaProperty>;
|
||||
required?: string[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function TriggerConfigForm({
|
||||
toolkit,
|
||||
triggerType,
|
||||
onBack,
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
}: TriggerConfigFormProps) {
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Parse the JSON schema from triggerType.config
|
||||
const schema = triggerType.config as JsonSchema;
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
// Validate required fields
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (schema.required) {
|
||||
schema.required.forEach(fieldName => {
|
||||
if (!formData[fieldName] || formData[fieldName].trim() === '') {
|
||||
const field = schema.properties[fieldName];
|
||||
newErrors[fieldName] = `${field?.title || fieldName} is required`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
// If no errors, submit the form
|
||||
if (Object.keys(newErrors).length === 0) {
|
||||
// Convert form data to appropriate types based on schema
|
||||
const processedData: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
const property = schema.properties[key];
|
||||
if (property) {
|
||||
switch (property.type) {
|
||||
case 'number':
|
||||
case 'integer':
|
||||
processedData[key] = value ? Number(value) : undefined;
|
||||
break;
|
||||
case 'boolean':
|
||||
processedData[key] = value === 'true';
|
||||
break;
|
||||
default:
|
||||
processedData[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onSubmit(processedData);
|
||||
}
|
||||
}, [formData, schema, onSubmit]);
|
||||
|
||||
const handleFieldChange = useCallback((fieldName: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [fieldName]: value }));
|
||||
|
||||
// Clear error for this field if it exists
|
||||
if (errors[fieldName]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[fieldName];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
}, [errors]);
|
||||
|
||||
// Check if trigger requires configuration
|
||||
const hasConfigFields = schema && schema.properties && Object.keys(schema.properties).length > 0;
|
||||
|
||||
if (!hasConfigFields) {
|
||||
// No configuration needed - show success state
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="light" isIconOnly onPress={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{triggerType.name} Configuration
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
No additional configuration required
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
|
||||
<ZapIcon className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="absolute -top-1 -right-1 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<CheckCircleIcon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Ready to Create Trigger!
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
This trigger type doesn't require additional configuration. You can create it directly.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
size="lg"
|
||||
onPress={() => onSubmit({})}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Creating Trigger...' : 'Create Trigger'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="light" isIconOnly onPress={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Configure {triggerType.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{triggerType.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h4 className="text-base font-medium text-gray-900 dark:text-gray-100">
|
||||
Trigger Configuration
|
||||
</h4>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Configure the settings for your {toolkit.name} trigger:
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(schema.properties).map(([fieldName, property]) => {
|
||||
const isRequired = schema.required?.includes(fieldName) || false;
|
||||
const fieldValue = formData[fieldName] || '';
|
||||
const fieldError = errors[fieldName];
|
||||
|
||||
// Handle different input types based on property type
|
||||
if (property.enum) {
|
||||
// Render select for enum fields
|
||||
return (
|
||||
<div key={fieldName}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{property.title || fieldName}
|
||||
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<select
|
||||
value={fieldValue}
|
||||
onChange={(e) => handleFieldChange(fieldName, 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:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
|
||||
required={isRequired}
|
||||
>
|
||||
<option value="">Select {property.title || fieldName}</option>
|
||||
{property.enum.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{property.description && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{property.description}
|
||||
</p>
|
||||
)}
|
||||
{fieldError && (
|
||||
<p className="mt-1 text-xs text-red-500">{fieldError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={fieldName}
|
||||
label={property.title || fieldName}
|
||||
placeholder={property.description || `Enter ${property.title || fieldName}`}
|
||||
value={fieldValue}
|
||||
onValueChange={(value) => handleFieldChange(fieldName, value)}
|
||||
isRequired={isRequired}
|
||||
type={property.type === 'number' || property.type === 'integer' ? 'number' : 'text'}
|
||||
variant="bordered"
|
||||
description={property.description}
|
||||
isInvalid={!!fieldError}
|
||||
errorMessage={fieldError}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="bordered"
|
||||
onPress={onBack}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleSubmit}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Creating Trigger...' : 'Create Trigger'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, Spinner, Card, CardBody, CardHeader } from '@heroui/react';
|
||||
import { Plus, Trash2, ZapIcon } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { ComposioTriggerDeployment } from '@/src/entities/models/composio-trigger-deployment';
|
||||
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
|
||||
import { listComposioTriggerDeployments, deleteComposioTriggerDeployment, createComposioTriggerDeployment } from '@/app/actions/composio_actions';
|
||||
import { SelectComposioToolkit } from '../../tools/components/SelectComposioToolkit';
|
||||
import { ComposioTriggerTypesPanel } from './ComposioTriggerTypesPanel';
|
||||
import { TriggerConfigForm } from './TriggerConfigForm';
|
||||
import { ToolkitAuthModal } from '../../tools/components/ToolkitAuthModal';
|
||||
import { ZToolkit } from '@/app/lib/composio/composio';
|
||||
import { Project } from '@/app/lib/types/project_types';
|
||||
|
||||
interface TriggersModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectId: string;
|
||||
projectConfig: z.infer<typeof Project>;
|
||||
onProjectConfigUpdated?: () => void;
|
||||
}
|
||||
|
||||
type TriggerDeployment = z.infer<typeof ComposioTriggerDeployment>;
|
||||
|
||||
export function TriggersModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
projectId,
|
||||
projectConfig,
|
||||
onProjectConfigUpdated,
|
||||
}: TriggersModalProps) {
|
||||
const [triggers, setTriggers] = useState<TriggerDeployment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreateFlow, setShowCreateFlow] = useState(false);
|
||||
const [selectedToolkit, setSelectedToolkit] = useState<z.infer<typeof ZToolkit> | null>(null);
|
||||
const [selectedTriggerType, setSelectedTriggerType] = useState<z.infer<typeof ComposioTriggerType> | null>(null);
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [isSubmittingTrigger, setIsSubmittingTrigger] = useState(false);
|
||||
const [deletingTrigger, setDeletingTrigger] = useState<string | null>(null);
|
||||
|
||||
const loadTriggers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await listComposioTriggerDeployments({ projectId });
|
||||
setTriggers(response.items);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading triggers:', err);
|
||||
setError('Failed to load triggers. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const handleDeleteTrigger = async (deploymentId: string) => {
|
||||
if (!window.confirm('Are you sure you want to delete this trigger?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeletingTrigger(deploymentId);
|
||||
await deleteComposioTriggerDeployment({ projectId, deploymentId });
|
||||
await loadTriggers(); // Reload the list
|
||||
} catch (err: any) {
|
||||
console.error('Error deleting trigger:', err);
|
||||
setError('Failed to delete trigger. Please try again.');
|
||||
} finally {
|
||||
setDeletingTrigger(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setShowCreateFlow(true);
|
||||
};
|
||||
|
||||
const handleBackToList = () => {
|
||||
setShowCreateFlow(false);
|
||||
setSelectedToolkit(null);
|
||||
setSelectedTriggerType(null);
|
||||
setShowAuthModal(false);
|
||||
setIsSubmittingTrigger(false);
|
||||
loadTriggers(); // Reload in case any triggers were created
|
||||
};
|
||||
|
||||
const handleSelectToolkit = (toolkit: z.infer<typeof ZToolkit>) => {
|
||||
setSelectedToolkit(toolkit);
|
||||
};
|
||||
|
||||
const handleBackToToolkitSelection = () => {
|
||||
setSelectedToolkit(null);
|
||||
setSelectedTriggerType(null);
|
||||
setIsSubmittingTrigger(false);
|
||||
};
|
||||
|
||||
const handleSelectTriggerType = (triggerType: z.infer<typeof ComposioTriggerType>) => {
|
||||
if (!selectedToolkit) return;
|
||||
|
||||
setSelectedTriggerType(triggerType);
|
||||
|
||||
// Check if toolkit requires auth and if connected account exists
|
||||
const needsAuth = !selectedToolkit.no_auth;
|
||||
const hasConnection = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status === 'ACTIVE';
|
||||
|
||||
if (needsAuth && !hasConnection) {
|
||||
// Show auth modal
|
||||
setShowAuthModal(true);
|
||||
} else {
|
||||
// Proceed to trigger configuration
|
||||
// For now this is just the placeholder, but will be actual config later
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthComplete = async () => {
|
||||
setShowAuthModal(false);
|
||||
onProjectConfigUpdated?.();
|
||||
};
|
||||
|
||||
const handleTriggerSubmit = async (triggerConfig: Record<string, unknown>) => {
|
||||
if (!selectedToolkit || !selectedTriggerType) return;
|
||||
|
||||
try {
|
||||
setIsSubmittingTrigger(true);
|
||||
|
||||
// Get the connected account ID for this toolkit
|
||||
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.id;
|
||||
|
||||
if (!connectedAccountId) {
|
||||
throw new Error('No connected account found for this toolkit');
|
||||
}
|
||||
|
||||
// Create the trigger deployment
|
||||
await createComposioTriggerDeployment({
|
||||
projectId,
|
||||
toolkitSlug: selectedToolkit.slug,
|
||||
triggerTypeSlug: selectedTriggerType.slug,
|
||||
connectedAccountId,
|
||||
triggerConfig,
|
||||
});
|
||||
|
||||
// Success! Go back to triggers list and reload
|
||||
handleBackToList();
|
||||
} catch (err: any) {
|
||||
console.error('Error creating trigger:', err);
|
||||
setError('Failed to create trigger. Please try again.');
|
||||
} finally {
|
||||
setIsSubmittingTrigger(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !showCreateFlow) {
|
||||
loadTriggers();
|
||||
}
|
||||
}, [isOpen, showCreateFlow, loadTriggers]);
|
||||
|
||||
const renderTriggerList = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="lg" />
|
||||
<span className="ml-2">Loading triggers...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button variant="flat" onPress={loadTriggers}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (triggers.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<ZapIcon className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
No triggers configured
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||
Set up your first trigger to listen for events from your connected apps.
|
||||
</p>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="solid"
|
||||
startContent={<Plus className="w-4 h-4" />}
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
Create your first trigger
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Active Triggers ({triggers.length})
|
||||
</h3>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="solid"
|
||||
startContent={<Plus className="w-4 h-4" />}
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
Create New Trigger
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{triggers.map((trigger) => (
|
||||
<Card key={trigger.id} className="w-full">
|
||||
<CardHeader className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="text-base font-medium text-gray-900 dark:text-gray-100">
|
||||
{trigger.triggerTypeSlug}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Created {new Date(trigger.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
color="danger"
|
||||
size="sm"
|
||||
isLoading={deletingTrigger === trigger.id}
|
||||
onPress={() => handleDeleteTrigger(trigger.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<p><strong>Trigger ID:</strong> {trigger.triggerId}</p>
|
||||
<p><strong>Connected Account:</strong> {trigger.connectedAccountId}</p>
|
||||
{Object.keys(trigger.triggerConfig).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<strong>Configuration:</strong>
|
||||
<pre className="mt-1 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded">
|
||||
{JSON.stringify(trigger.triggerConfig, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCreateFlow = () => {
|
||||
// If trigger type is selected and auth is complete, show config
|
||||
if (selectedToolkit && selectedTriggerType && !showAuthModal) {
|
||||
const needsAuth = !selectedToolkit.no_auth;
|
||||
const hasConnection = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status === 'ACTIVE';
|
||||
|
||||
if (!needsAuth || hasConnection) {
|
||||
return (
|
||||
<TriggerConfigForm
|
||||
toolkit={selectedToolkit}
|
||||
triggerType={selectedTriggerType}
|
||||
onBack={handleBackToToolkitSelection}
|
||||
onSubmit={handleTriggerSubmit}
|
||||
isSubmitting={isSubmittingTrigger}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If no toolkit selected, show toolkit selection
|
||||
if (!selectedToolkit) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Select a Toolkit to Create Trigger
|
||||
</h3>
|
||||
<Button
|
||||
variant="flat"
|
||||
onPress={handleBackToList}
|
||||
>
|
||||
← Back to Triggers
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SelectComposioToolkit
|
||||
projectId={projectId}
|
||||
tools={[]} // Empty array since we're not using this for tools
|
||||
onSelectToolkit={handleSelectToolkit}
|
||||
initialToolkitSlug={null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If toolkit selected, show trigger types
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ComposioTriggerTypesPanel
|
||||
toolkit={selectedToolkit}
|
||||
onBack={handleBackToToolkitSelection}
|
||||
onSelectTriggerType={handleSelectTriggerType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="5xl"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent className="max-h-[90vh]">
|
||||
<ModalHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<ZapIcon className="w-5 h-5" />
|
||||
<span>Manage Triggers</span>
|
||||
</div>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{showCreateFlow ? renderCreateFlow() : renderTriggerList()}
|
||||
</ModalBody>
|
||||
{!showCreateFlow && (
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Auth Modal */}
|
||||
{selectedToolkit && (
|
||||
<ToolkitAuthModal
|
||||
isOpen={showAuthModal}
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
toolkitSlug={selectedToolkit.slug}
|
||||
projectId={projectId}
|
||||
onComplete={handleAuthComplete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ import { publishWorkflow } from "@/app/actions/project_actions";
|
|||
import { saveWorkflow } from "@/app/actions/project_actions";
|
||||
import { updateProjectName } from "@/app/actions/project_actions";
|
||||
import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/icons";
|
||||
import { CopyIcon, ImportIcon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon, SettingsIcon, ChevronDownIcon, PhoneIcon, MessageCircleIcon } from "lucide-react";
|
||||
import { CopyIcon, ImportIcon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon, SettingsIcon, ChevronDownIcon, PhoneIcon, MessageCircleIcon, ZapIcon } from "lucide-react";
|
||||
import { EntityList } from "./entity_list";
|
||||
import { ProductTour } from "@/components/common/product-tour";
|
||||
import { ModelsResponse } from "@/app/lib/types/billing_types";
|
||||
|
|
@ -37,6 +37,7 @@ import { ConfigApp } from "../config/app";
|
|||
import { InputField } from "@/app/lib/components/input-field";
|
||||
import { VoiceSection } from "../config/components/voice";
|
||||
import { ChatWidgetSection } from "../config/components/project";
|
||||
import { TriggersModal } from "./components/TriggersModal";
|
||||
|
||||
enablePatches();
|
||||
|
||||
|
|
@ -882,6 +883,9 @@ export function WorkflowEditor({
|
|||
// Modal state for chat widget configuration
|
||||
const { isOpen: isChatWidgetModalOpen, onOpen: onChatWidgetModalOpen, onClose: onChatWidgetModalClose } = useDisclosure();
|
||||
|
||||
// Modal state for triggers management
|
||||
const { isOpen: isTriggersModalOpen, onOpen: onTriggersModalOpen, onClose: onTriggersModalClose } = useDisclosure();
|
||||
|
||||
// Project name state
|
||||
const [localProjectName, setLocalProjectName] = useState<string>(projectConfig.name || '');
|
||||
const [projectNameError, setProjectNameError] = useState<string | null>(null);
|
||||
|
|
@ -1359,6 +1363,13 @@ export function WorkflowEditor({
|
|||
>
|
||||
Chat widget
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="manage-triggers"
|
||||
startContent={<ZapIcon size={16} />}
|
||||
onPress={onTriggersModalOpen}
|
||||
>
|
||||
Manage triggers
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
|
@ -1647,6 +1658,15 @@ export function WorkflowEditor({
|
|||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Triggers Management Modal */}
|
||||
<TriggersModal
|
||||
isOpen={isTriggersModalOpen}
|
||||
onClose={onTriggersModalClose}
|
||||
projectId={projectId}
|
||||
projectConfig={projectConfig}
|
||||
onProjectConfigUpdated={onProjectConfigUpdated}
|
||||
/>
|
||||
</div>
|
||||
</EntitySelectionContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ import {
|
|||
ChevronRightIcon,
|
||||
Moon,
|
||||
Sun,
|
||||
HelpCircle
|
||||
HelpCircle,
|
||||
MessageSquareIcon,
|
||||
LogsIcon
|
||||
} from "lucide-react";
|
||||
import { getProjectConfig } from "@/app/actions/project_actions";
|
||||
import { useTheme } from "@/app/providers/theme-provider";
|
||||
|
|
@ -60,6 +62,18 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
|
|||
icon: WorkflowIcon,
|
||||
requiresProject: true
|
||||
},
|
||||
{
|
||||
href: 'conversations',
|
||||
label: 'Conversations',
|
||||
icon: MessageSquareIcon,
|
||||
requiresProject: true
|
||||
},
|
||||
{
|
||||
href: 'jobs',
|
||||
label: 'Jobs',
|
||||
icon: LogsIcon,
|
||||
requiresProject: true
|
||||
},
|
||||
{
|
||||
href: 'config',
|
||||
label: 'Settings',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue