add rowboat app

This commit is contained in:
ramnique 2025-01-13 15:31:31 +05:30
parent b83b5f8a07
commit 10f76ef49f
117 changed files with 25370 additions and 0 deletions

View file

@ -0,0 +1,124 @@
'use client';
import { Metadata } from "next";
import { Secret } from "./secret";
import { Divider, Spinner } from "@nextui-org/react";
import { useEffect, useState } from "react";
import { Project } from "@/app/lib/types";
import { getProjectConfig } from "@/app/actions";
import { EmbedCode } from "./embed";
import { WebhookUrl } from "./webhook-url";
import { z } from 'zod';
export const metadata: Metadata = {
title: "Project config",
};
export default function App({
projectId,
}: {
projectId: string;
}) {
const [isLoading, setIsLoading] = useState(true);
const [project, setProject] = useState<z.infer<typeof Project> | null>(null);
useEffect(() => {
let ignore = false;
async function fetchProjectConfig() {
setIsLoading(true);
const project = await getProjectConfig(projectId);
if (!ignore) {
setProject(project);
setIsLoading(false);
}
}
fetchProjectConfig();
return () => {
ignore = true;
};
}, [projectId]);
const standardEmbedCode = `<!-- RowBoat Chat Widget -->
<script>
window.ROWBOAT_CONFIG = {
clientId: '${project?.chatClientId}'
};
(function(d) {
var s = d.createElement('script');
s.src = 'https://chat.rowboatlabs.com/bootstrap.js';
s.async = true;
d.getElementsByTagName('head')[0].appendChild(s);
})(document);
</script>`;
const nextJsEmbedCode = `// Add this to your Next.js page or layout
import Script from 'next/script'
export default function YourComponent() {
return (
<>
<Script id="rowboat-config">
{\`window.ROWBOAT_CONFIG = {
clientId: '${project?.chatClientId}'
};\`}
</Script>
<Script
src="https://chat.rowboatlabs.com/bootstrap.js"
strategy="lazyOnload"
/>
</>
)
}`
return <div className="flex flex-col h-full">
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-b-gray-100">
<div className="flex flex-col">
<h1 className="text-lg">Project config</h1>
</div>
</div>
<div className="grow overflow-auto py-4">
<div className="max-w-[768px] mx-auto">
{isLoading && <div className="flex items-center gap-1">
<Spinner size="sm" />
<div>Loading project config...</div>
</div>}
{!isLoading && project && <div className="flex flex-col gap-4">
<h2 className="font-semibold">Credentials</h2>
<Secret
initialSecret={project.secret}
projectId={projectId}
/>
<Divider />
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">Add the chat widget to your website</h2>
<p className="text-gray-600">Copy and paste this code snippet just before the closing &lt;/body&gt; tag of your website:</p>
<EmbedCode key="standard-embed-code" embedCode={standardEmbedCode} />
</div>
<div className="flex flex-col gap-4">
<h2 className="text-lg font-medium">Using Next.js?</h2>
<p className="text-gray-600">If you&apos;re using Next.js, use this code instead:</p>
<EmbedCode key="nextjs-embed-code" embedCode={nextJsEmbedCode} />
</div>
<Divider />
<div>
<h2 className="text-xl font-semibold">Webhook settings</h2>
<p className="mb-4">
You can configure a webhook that will respond to tool calls.
</p>
<WebhookUrl
initialUrl={project?.webhookUrl}
projectId={projectId}
/>
</div>
</div>}
</div>
</div>
</div>;
}

View file

@ -0,0 +1,42 @@
'use client';
import React from 'react';
import { Textarea, Button } from "@nextui-org/react";
import { CheckIcon, ClipboardIcon } from 'lucide-react';
interface EmbedCodeProps {
embedCode: string;
}
export function EmbedCode({ embedCode }: EmbedCodeProps) {
const [isCopied, setIsCopied] = React.useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(embedCode);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1000);
};
return (
<div className="relative">
<Textarea
labelPlacement="outside"
variant="bordered"
defaultValue={embedCode}
className="max-w-full cursor-pointer"
readOnly
onClick={handleCopy}
/>
<div className="absolute bottom-2 right-2">
<Button
variant="flat"
size="sm"
onClick={handleCopy}
isIconOnly
>
{isCopied ? <CheckIcon size={16} /> : <ClipboardIcon size={16} />}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,15 @@
import { Metadata } from "next";
import App from "./app";
export const metadata: Metadata = {
title: "Project config",
};
export default function Page({
params,
}: {
params: {
projectId: string;
};
}) {
return <App projectId={params.projectId} />;
}

View file

@ -0,0 +1,94 @@
'use client';
import { Button, Input } from "@nextui-org/react";
import { useState } from "react";
import { rotateSecret } from "@/app/actions";
import { CheckIcon, ClipboardIcon } from "lucide-react";
export function Secret({
initialSecret,
projectId
}: {
initialSecret: string,
projectId: string
}) {
const getMaskedSecret = (secret: string) => {
if (!secret) return '';
if (secret.length <= 8) return secret;
return `${secret.slice(0, 4)}${'•'.repeat(16)}${secret.slice(-4)}`;
};
const [maskedSecret, setMaskedSecret] = useState(getMaskedSecret(initialSecret));
const [showNewSecret, setShowNewSecret] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [showCopySuccess, setShowCopySuccess] = useState(false);
const handleRegenerate = async () => {
if (!window.confirm('Are you sure you want to regenerate the webhook secret? This will invalidate the current secret key immediately.')) {
return;
}
try {
setIsLoading(true);
const newSecret = await rotateSecret(projectId);
setShowNewSecret(newSecret);
setMaskedSecret(getMaskedSecret(newSecret));
} catch (error) {
console.error('Failed to regenerate webhook secret:', error);
// You might want to add a toast or error message here
} finally {
setIsLoading(false);
}
};
const handleCopy = async () => {
if (showNewSecret) {
await navigator.clipboard.writeText(showNewSecret);
setShowCopySuccess(true);
setTimeout(() => {
setShowCopySuccess(false);
}, 1500);
}
};
return (
<div className="mt-4">
<div className="text-sm text-gray-600 mb-2">Project Secret</div>
<div className="flex gap-2 items-center">
<Input
value={showNewSecret || maskedSecret}
readOnly
variant="bordered"
className="font-mono"
endContent={
showNewSecret ? (
<Button
isIconOnly
variant="light"
onClick={handleCopy}
>
{showCopySuccess ? (
<CheckIcon size={16} />
) : (
<ClipboardIcon size={16} />
)}
</Button>
) : null
}
/>
<Button
color="primary"
variant="flat"
onClick={handleRegenerate}
isLoading={isLoading}
>
Regenerate
</Button>
</div>
{showNewSecret && (
<div className="text-sm text-red-600 mt-2">
Make sure to copy your new secret key. It won&apos;t be shown again!
</div>
)}
</div>
);
}

View file

@ -0,0 +1,81 @@
'use client';
import { Button, Input } from "@nextui-org/react";
import { useState } from "react";
import { updateWebhookUrl } from "@/app/actions";
export function WebhookUrl({
initialUrl,
projectId
}: {
initialUrl?: string,
projectId: string
}) {
const [url, setUrl] = useState(initialUrl || '');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showSuccess, setShowSuccess] = useState(false);
const handleUpdate = async () => {
try {
setIsLoading(true);
setError(null);
setShowSuccess(false);
// URL validation
let parsedUrl;
try {
parsedUrl = new URL(url);
} catch {
setError('Please enter a valid URL');
return;
}
// Ensure HTTPS scheme
if (parsedUrl.protocol !== 'https:') {
setError('URL must use HTTPS');
return;
}
await updateWebhookUrl(projectId, url);
setShowSuccess(true);
setTimeout(() => {
setShowSuccess(false);
}, 3000);
} catch (error) {
console.error('Failed to update webhook URL:', error);
setError('Failed to update webhook URL');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-end">
<Input
label="Webhook URL"
labelPlacement="outside"
placeholder="https://example.com/webhook"
value={url}
onChange={(e) => {
setUrl(e.target.value);
setError(null);
setShowSuccess(false);
}}
className="flex-grow"
isInvalid={!!error}
errorMessage={error}
description={showSuccess ? "Webhook URL updated successfully" : undefined}
/>
<Button
color="primary"
onClick={handleUpdate}
isLoading={isLoading}
>
Update
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,16 @@
import { Nav } from "./nav";
export default async function Layout({
params,
children
}: {
params: { projectId: string }
children: React.ReactNode
}) {
return <div className="flex h-full">
<Nav projectId={params.projectId} />
<div className="grow p-4 overflow-auto">
{children}
</div>
</div >;
}

View file

@ -0,0 +1,85 @@
'use client';
import { usePathname } from "next/navigation";
import { Tooltip } from "@nextui-org/react";
import Link from "next/link";
import clsx from "clsx";
import { WorkflowIcon } from "@/app/lib/components/icons";
function NavLink({ href, label, icon, collapsed, selected = false }: { href: string, label: string, icon: React.ReactNode, collapsed: boolean, selected?: boolean }) {
return <Link
href={href}
className={clsx("flex px-2 py-3 gap-3 items-center rounded-lg hover:bg-gray-200", {
"bg-gray-200": selected,
"justify-center": collapsed,
})}
>
{collapsed && Tooltip && <Tooltip content={label} showArrow placement="right">
<div className="shrink-0">
{icon}
</div>
</Tooltip>}
{!collapsed && <div className="shrink-0">
{icon}
</div>}
{!collapsed && <div className="truncate">
{label}
</div>}
</Link>;
}
export default function Menu({
projectId,
collapsed,
}: {
projectId: string;
collapsed: boolean;
}) {
const pathname = usePathname();
return <div className="flex flex-col">
{/* <NavLink
href={`/projects/${projectId}/playground`}
label="Playground"
collapsed={collapsed}
icon=<svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M9 17h6l3 3v-3h2V9h-2M4 4h11v8H9l-3 3v-3H4V4Z" />
</svg>
selected={pathname.startsWith(`/projects/${projectId}/playground`)}
/> */}
<NavLink
href={`/projects/${projectId}/workflow`}
label="Workflow"
collapsed={collapsed}
icon={<WorkflowIcon />}
selected={pathname.startsWith(`/projects/${projectId}/workflow`)}
/>
<NavLink
href={`/projects/${projectId}/sources`}
label="Data sources"
collapsed={collapsed}
icon=<svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 6c0 1.657-3.134 3-7 3S5 7.657 5 6m14 0c0-1.657-3.134-3-7-3S5 4.343 5 6m14 0v6M5 6v6m0 0c0 1.657 3.134 3 7 3s7-1.343 7-3M5 12v6c0 1.657 3.134 3 7 3s7-1.343 7-3v-6" />
</svg>
selected={pathname.startsWith(`/projects/${projectId}/sources`)}
/>
<NavLink
href={`/projects/${projectId}/config`}
label="Config"
collapsed={collapsed}
icon=<svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M21 13v-2a1 1 0 0 0-1-1h-.757l-.707-1.707.535-.536a1 1 0 0 0 0-1.414l-1.414-1.414a1 1 0 0 0-1.414 0l-.536.535L14 4.757V4a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v.757l-1.707.707-.536-.535a1 1 0 0 0-1.414 0L4.929 6.343a1 1 0 0 0 0 1.414l.536.536L4.757 10H4a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h.757l.707 1.707-.535.536a1 1 0 0 0 0 1.414l1.414 1.414a1 1 0 0 0 1.414 0l.536-.535 1.707.707V20a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-.757l1.707-.708.536.536a1 1 0 0 0 1.414 0l1.414-1.414a1 1 0 0 0 0-1.414l-.535-.536.707-1.707H20a1 1 0 0 0 1-1Z" />
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
</svg>
selected={pathname.startsWith(`/projects/${projectId}/config`)}
/>
{/*<NavLink
href={`/projects/${projectId}/integrate`}
label="Integrate"
collapsed={collapsed}
icon=<svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="m8 8-4 4 4 4m8 0 4-4-4-4m-2-3-4 14" />
</svg>
selected={pathname.startsWith(`/projects/${projectId}/integrate`)}
/>*/}
</div>;
}

View file

@ -0,0 +1,68 @@
'use client';
import { Tooltip } from "@nextui-org/react";
import Link from "next/link";
import { useEffect, useState } from "react";
import clsx from "clsx";
import Menu from "./menu";
import { Project } from "@/app/lib/types";
import { z } from "zod";
import { getProjectConfig } from "@/app/actions";
export function Nav({
projectId,
}: {
projectId: string;
}) {
const [collapsed, setCollapsed] = useState(false);
const [project, setProject] = useState<z.infer<typeof Project> | null>(null);
useEffect(() => {
let ignore = false;
async function getProject() {
const project = await getProjectConfig(projectId);
if (ignore) {
return;
}
setProject(project);
}
getProject();
return () => {
ignore = true;
};
}, [projectId]);
function toggleCollapse() {
setCollapsed(!collapsed);
}
return <div className={clsx("bg-gray-50 shrink-0 flex flex-col gap-6 border-r-1 border-gray-100 relative p-4", {
"w-64": !collapsed,
"w-16": collapsed
})}>
<Tooltip content={collapsed ? "Expand" : "Collapse"} showArrow placement="right">
<button onClick={toggleCollapse} className="absolute bottom-[100px] right-[-16px] rounded-full border bg-white text-gray-400 border-gray-400 hover:border-black hover:text-black w-[28px] h-[28px] shadow-sm">
{!collapsed && <svg className="m-auto w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="m17 16-4-4 4-4m-6 8-4-4 4-4" />
</svg>}
{collapsed && <svg className="m-auto w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="m7 16 4-4-4-4m6 8 4-4-4-4" />
</svg>}
</button>
</Tooltip>
{!collapsed && project && <div className="flex flex-col gap-1">
<Tooltip content="Change project" showArrow placement="bottom-end">
<Link className="relative group flex flex-col px-3 py-3 border border-gray-200 rounded-md hover:border-gray-500" href="/projects">
<div className="absolute top-[-7px] left-1 px-2 bg-gray-50 text-xs text-gray-400 group-hover:text-gray-600">
Project
</div>
<div className="truncate">
{project.name}
</div>
</Link>
</Tooltip>
</div>}
<Menu projectId={projectId} collapsed={collapsed} />
</div>;
}

View file

@ -0,0 +1,9 @@
import { redirect } from "next/navigation";
export default function Page({
params
}: {
params: { projectId: string }
}) {
redirect(`/projects/${params.projectId}/workflow`);
}

View file

@ -0,0 +1,110 @@
'use client';
import { Spinner } from "@nextui-org/react";
import { useEffect, useState, useMemo } from "react";
import { z } from "zod";
import { PlaygroundChat, SimulationData, Workflow } from "@/app/lib/types";
import { SimulateScenarioOption, SimulateURLOption } from "./simulation-options";
import { Chat } from "./chat";
import { useSearchParams } from "next/navigation";
import { ActionButton, Pane } from "../workflow/pane";
import { apiV1 } from "rowboat-shared";
function SimulateLabel() {
return <span>Simulate<sup className="pl-1">beta</sup></span>;
}
const defaultSystemMessage = '';
export function App({
hidden = false,
projectId,
workflow,
messageSubscriber,
}: {
hidden?: boolean;
projectId: string;
workflow: z.infer<typeof Workflow>;
messageSubscriber?: (messages: z.infer<typeof apiV1.ChatMessage>[]) => void;
}) {
const searchParams = useSearchParams();
const initialChatId = useMemo(() => searchParams.get('chatId'), [searchParams]);
const [existingChatId, setExistingChatId] = useState<string | null>(initialChatId);
const [loadingChat, setLoadingChat] = useState<boolean>(false);
const [viewSimulationMenu, setViewSimulationMenu] = useState<boolean>(false);
const [counter, setCounter] = useState<number>(0);
const [chat, setChat] = useState<z.infer<typeof PlaygroundChat>>({
projectId,
createdAt: new Date().toISOString(),
messages: [],
simulated: false,
systemMessage: defaultSystemMessage,
});
function handleSimulateButtonClick() {
setViewSimulationMenu(true);
}
function handleNewChatButtonClick() {
setExistingChatId(null);
setViewSimulationMenu(false);
setCounter(counter + 1);
setChat({
projectId,
createdAt: new Date().toISOString(),
messages: [],
simulated: false,
systemMessage: defaultSystemMessage,
});
}
function beginSimulation(data: z.infer<typeof SimulationData>) {
setExistingChatId(null);
setViewSimulationMenu(false);
setCounter(counter + 1);
setChat({
projectId,
createdAt: new Date().toISOString(),
messages: [],
simulated: true,
simulationData: data,
});
}
if (hidden) {
return <></>;
}
return <Pane title={viewSimulationMenu ? <SimulateLabel /> : "Playground"} actions={[
<ActionButton
key="new-chat"
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 10.5h.01m-4.01 0h.01M8 10.5h.01M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-6.6a1 1 0 0 0-.69.275l-2.866 2.723A.5.5 0 0 1 8 18.635V17a1 1 0 0 0-1-1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
</svg>}
onClick={handleNewChatButtonClick}
>
New chat
</ActionButton>,
!viewSimulationMenu && <ActionButton
key="simulate"
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 18V6l8 6-8 6Z" />
</svg>}
onClick={handleSimulateButtonClick}
>
Simulate
</ActionButton>,
]}>
<div className="h-full overflow-auto">
{!viewSimulationMenu && loadingChat && <div className="flex justify-center items-center h-full">
<Spinner />
</div>}
{!viewSimulationMenu && !loadingChat && <Chat
key={existingChatId || 'chat-' + counter}
chat={chat}
initialChatId={existingChatId || null}
projectId={projectId}
workflow={workflow}
messageSubscriber={messageSubscriber}
/>}
{viewSimulationMenu && <SimulateScenarioOption beginSimulation={beginSimulation} projectId={projectId} />}
</div>
</Pane>;
}

View file

@ -0,0 +1,319 @@
'use client';
import { getAssistantResponse, simulateUserResponse } from "@/app/actions";
import { useEffect, useState } from "react";
import { Messages } from "./messages";
import z from "zod";
import { AgenticAPIChatRequest, convertToAgenticAPIChatMessages, convertWorkflowToAgenticAPI, PlaygroundChat, Workflow } from "@/app/lib/types";
import { ComposeBox } from "./compose-box";
import { Button } from "@nextui-org/react";
import { apiV1 } from "rowboat-shared";
import { CheckIcon, ClipboardIcon } from "lucide-react";
import { CopyIcon } from "lucide-react";
export function Chat({
chat,
initialChatId = null,
projectId,
workflow,
messageSubscriber,
}: {
chat: z.infer<typeof PlaygroundChat>;
initialChatId?: string | null;
projectId: string;
workflow: z.infer<typeof Workflow>;
messageSubscriber?: (messages: z.infer<typeof apiV1.ChatMessage>[]) => void;
}) {
const [chatId, setChatId] = useState<string | null>(initialChatId);
const [messages, setMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages);
const [loadingAssistantResponse, setLoadingAssistantResponse] = useState<boolean>(false);
const [loadingUserResponse, setLoadingUserResponse] = useState<boolean>(false);
const [simulationComplete, setSimulationComplete] = useState<boolean>(chat.simulationComplete || false);
const [agenticState, setAgenticState] = useState<unknown>(chat.agenticState || {
last_agent_name: workflow.startAgent,
});
const [showCopySuccess, setShowCopySuccess] = useState(false);
const [assistantResponseError, setAssistantResponseError] = useState<string | null>(null);
const [lastAgenticRequest, setLastAgenticRequest] = useState<unknown | null>(null);
const [lastAgenticResponse, setLastAgenticResponse] = useState<unknown | null>(null);
const [systemMessage, setSystemMessage] = useState<string | undefined>(chat.systemMessage);
// collect published tool call results
const toolCallResults: Record<string, z.infer<typeof apiV1.ToolMessage>> = {};
messages
.filter((message) => message.role == 'tool')
.forEach((message) => {
toolCallResults[message.tool_call_id] = message;
});
function handleUserMessage(prompt: string) {
const updatedMessages: z.infer<typeof apiV1.ChatMessage>[] = [...messages, {
role: 'user',
content: prompt,
version: 'v1',
chatId: chatId ?? '',
createdAt: new Date().toISOString(),
}];
setMessages(updatedMessages);
}
function handleToolCallResults(results: z.infer<typeof apiV1.ToolMessage>[]) {
setMessages([...messages, ...results.map((result) => ({
...result,
version: 'v1' as const,
chatId: chatId ?? '',
createdAt: new Date().toISOString(),
}))]);
}
// reset state when workflow changes
useEffect(() => {
setMessages([]);
setAgenticState({
last_agent_name: workflow.startAgent,
});
}, [workflow]);
// publish messages to subscriber
useEffect(() => {
if (messageSubscriber) {
messageSubscriber(messages);
}
}, [messages, messageSubscriber]);
// get agent response
useEffect(() => {
let ignore = false;
async function process() {
setLoadingAssistantResponse(true);
setAssistantResponseError(null);
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow);
const request: z.infer<typeof AgenticAPIChatRequest> = {
messages: convertToAgenticAPIChatMessages([{
role: 'system',
content: systemMessage || '',
version: 'v1' as const,
chatId: chatId ?? '',
createdAt: new Date().toISOString(),
}, ...messages]),
state: agenticState,
agents,
tools,
prompts,
startAgent,
};
setLastAgenticRequest(request);
setLastAgenticResponse(null);
try {
const response = await getAssistantResponse(projectId, request);
if (ignore) {
return;
}
setLastAgenticResponse(response.rawAPIResponse);
setMessages([...messages, ...response.messages.map((message) => ({
...message,
version: 'v1' as const,
chatId: chatId ?? '',
createdAt: new Date().toISOString(),
}))]);
setAgenticState(response.state);
} catch (err) {
if (!ignore) {
setAssistantResponseError(`Failed to get assistant response: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
} finally {
setLoadingAssistantResponse(false);
}
}
// if no messages, return
if (messages.length === 0) {
return;
}
// if last message is not from role user
// or tool, return
const last = messages[messages.length - 1];
if (assistantResponseError) {
return;
}
if (last.role !== 'user' && last.role !== 'tool') {
return;
}
process();
return () => {
ignore = true;
};
}, [chatId, chat.simulated, messages, projectId, agenticState, workflow, assistantResponseError, systemMessage]);
// simulate user turn
useEffect(() => {
let ignore = false;
function process() {
if (chat.simulationData === undefined) {
return;
}
// fetch next user prompt
setLoadingUserResponse(true);
simulateUserResponse(projectId, messages, chat.simulationData)
.then(response => {
//console.log('User response:', response);
if (ignore) {
return;
}
if (response.trim() === 'EXIT') {
setSimulationComplete(true);
return;
}
setMessages([...messages, {
role: 'user',
content: response,
version: 'v1' as const,
chatId: chatId ?? '',
createdAt: new Date().toISOString(),
}]);
})
.finally(() => {
setLoadingUserResponse(false);
});
}
// proceed only if chat is simulated
if (!chat.simulated) {
return;
}
// dont proceed if simulation is complete
if (chat.simulated && simulationComplete) {
return;
}
// check if there are no messages yet OR
// check if the last message is an assistant
// message containing a text response. If so,
// call the simulate user turn api to fetch
// user response
let last = messages[messages.length - 1];
if (last && last.role !== 'assistant') {
return;
}
if (last && 'tool_calls' in last) {
return;
}
process();
return () => {
ignore = true;
};
}, [chatId, chat.simulated, messages, projectId, simulationComplete, chat.simulationData]);
// save chat on every assistant message
// useEffect(() => {
// let ignore = false;
// function process() {
// savePlaygroundChat(projectId, {
// ...chat,
// messages,
// simulationComplete,
// agenticState,
// }, chatId)
// .then((insertedChatId) => {
// if (!chatId) {
// setChatId(insertedChatId);
// }
// });
// }
// if (messages.length === 0) {
// return;
// }
// const lastMessage = messages[messages.length - 1];
// if (lastMessage && lastMessage.role !== 'assistant') {
// return;
// }
// process();
// }, [chatId, chat, messages, projectId, simulationComplete, agenticState]);
const handleCopyChat = () => {
const jsonString = JSON.stringify({
messages: [{
role: 'system',
content: systemMessage,
}, ...messages],
lastRequest: lastAgenticRequest,
lastResponse: lastAgenticResponse,
}, null, 2);
navigator.clipboard.writeText(jsonString)
.then(() => {
setShowCopySuccess(true);
setTimeout(() => {
setShowCopySuccess(false);
}, 1500);
})
.catch(err => {
console.error('Failed to copy chat to clipboard:', err);
});
};
function handleSystemMessageChange(message: string) {
setSystemMessage(message);
}
return <div className="relative h-full flex flex-col gap-8 pt-8 overflow-auto">
<Button
size="sm"
variant="bordered"
isIconOnly
onClick={handleCopyChat}
className="absolute top-2 right-0"
>
{showCopySuccess ? (
<CheckIcon size={16} />
) : (
<ClipboardIcon size={16} />
)}
</Button>
<Messages
projectId={projectId}
messages={messages}
systemMessage={systemMessage}
toolCallResults={toolCallResults}
handleToolCallResults={handleToolCallResults}
loadingAssistantResponse={loadingAssistantResponse}
loadingUserResponse={loadingUserResponse}
workflow={workflow}
onSystemMessageChange={handleSystemMessageChange}
/>
<div className="shrink-0">
{assistantResponseError && (
<div className="max-w-[768px] mx-auto mb-4 p-2 bg-red-50 border border-red-200 rounded-lg flex gap-2 justify-between items-center">
<p className="text-red-600">{assistantResponseError}</p>
<Button
size="sm"
color="danger"
onClick={() => {
setAssistantResponseError(null);
}}
>
Retry
</Button>
</div>
)}
{!chat.simulated && <div className="max-w-[768px] mx-auto">
<ComposeBox
handleUserMessage={handleUserMessage}
messages={messages}
/>
</div>}
{chat.simulated && simulationComplete && <p className="text-center">Simulation complete.</p>}
</div>
</div>;
}

View file

@ -0,0 +1,69 @@
'use client';
import { Button, Spinner, Textarea } from "@nextui-org/react";
import { useRef, useState, useEffect } from "react";
import { apiV1 } from "rowboat-shared";
import { z } from "zod";
export function ComposeBox({
minRows=3,
disabled=false,
loading=false,
handleUserMessage,
messages,
}: {
minRows?: number;
disabled?: boolean;
loading?: boolean;
handleUserMessage: (prompt: string) => void;
messages: z.infer<typeof apiV1.ChatMessage>[];
}) {
const [input, setInput] = useState('');
const inputRef = useRef<HTMLTextAreaElement>(null);
function handleInput() {
const prompt = input.trim();
if (!prompt) {
return;
}
setInput('');
handleUserMessage(prompt);
}
function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleInput();
}
}
// focus on the input field
useEffect(() => {
inputRef.current?.focus();
}, [messages]);
return <Textarea
required
ref={inputRef}
variant="bordered"
placeholder="Enter message..."
minRows={minRows}
maxRows={5}
value={input}
onValueChange={setInput}
onKeyDown={handleInputKeyDown}
disabled={disabled}
className="w-full"
endContent={<Button
isIconOnly
disabled={disabled}
onClick={handleInput}
className="bg-gray-100"
>
{!loading && <svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M12 6v13m0-13 4 4m-4-4-4 4" />
</svg>}
{loading && <Spinner size="sm" />}
</Button>}
/>;
}

View file

@ -0,0 +1,763 @@
'use client';
import { Button, Spinner, Textarea } from "@nextui-org/react";
import { useEffect, useRef, useState } from "react";
import z from "zod";
import { GetInformationToolResult, WebpageCrawlResponse, Workflow, WorkflowTool } from "@/app/lib/types";
import { executeClientTool, getInformationTool, scrapeWebpage, suggestToolResponse } from "@/app/actions";
import MarkdownContent from "@/app/lib/components/markdown-content";
import Link from "next/link";
import { apiV1 } from "rowboat-shared";
import { EditableField } from "@/app/lib/components/editable-field";
function UserMessage({ content }: { content: string }) {
return <div className="self-end ml-[30%] flex flex-col">
<div className="text-right text-gray-500 text-sm mr-3">
User
</div>
<div className="bg-gray-100 px-3 py-1 rounded-lg rounded-br-none">
<MarkdownContent content={content} />
</div>
</div>;
}
function InternalAssistantMessage({ content, sender, latency }: { content: string, sender: string | undefined, latency: number }) {
const [expanded, setExpanded] = useState(false);
// show a message icon with a + symbol to expand and show the content
return <div className="self-start mr-[30%]">
{!expanded && <button className="flex items-center text-gray-400 hover:text-gray-600 gap-1 group" onClick={() => setExpanded(true)}>
<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M16 10.5h.01m-4.01 0h.01M8 10.5h.01M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-6.6a1 1 0 0 0-.69.275l-2.866 2.723A.5.5 0 0 1 8 18.635V17a1 1 0 0 0-1-1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
</svg>
<svg className="group-hover:hidden w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeWidth="2" d="M6 12h.01m6 0h.01m5.99 0h.01" />
</svg>
<span className="hidden group-hover:block text-xs">Show debug message</span>
</button>}
{expanded && <div className="flex flex-col">
<div className="flex gap-2 justify-between items-center">
<div className="text-gray-500 text-sm pl-3">
{sender ?? 'Assistant'}
</div>
<button className="flex items-center gap-1 text-gray-400 hover:text-gray-600" onClick={() => setExpanded(false)}>
<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
</svg>
</button>
</div>
<div className="border border-gray-300 border-dashed px-3 py-1 rounded-lg rounded-bl-none">
<pre className="text-sm whitespace-pre-wrap">{content}</pre>
</div>
</div>}
</div>;
}
function AssistantMessage({ content, sender, latency }: { content: string, sender: string | undefined, latency: number }) {
return <div className="self-start mr-[30%] flex flex-col">
<div className="flex gap-2 justify-between items-center">
<div className="text-gray-500 text-sm pl-3">
{sender ?? 'Assistant'}
</div>
<div className="text-gray-400 text-xs pr-3">
{Math.round(latency / 1000)}s
</div>
</div>
<div className="bg-gray-100 px-3 py-1 rounded-lg rounded-bl-none">
<MarkdownContent content={content} />
</div>
</div>;
}
function AssistantMessageLoading() {
return <div className="self-start mr-[30%] flex flex-col">
<div className="text-gray-500 text-sm ml-3">
Assistant
</div>
<div className="bg-gray-100 p-3 rounded-lg rounded-bl-none animate-pulse w-20">
<Spinner />
</div>
</div>;
}
function UserMessageLoading() {
return <div className="self-end ml-[30%] flex flex-col">
<div className="text-right text-gray-500 text-sm mr-3">
User
</div>
<div className="bg-gray-100 p-3 rounded-lg rounded-br-none animate-pulse w-20">
<Spinner />
</div>
</div>;
}
function ToolCalls({
toolCalls,
results,
handleResults,
projectId,
messages,
sender,
workflow,
}: {
toolCalls: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'];
results: Record<string, z.infer<typeof apiV1.ToolMessage>>;
handleResults: (results: z.infer<typeof apiV1.ToolMessage>[]) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | undefined;
workflow: z.infer<typeof Workflow>;
}) {
const resultsMap: Record<string, z.infer<typeof apiV1.ToolMessage>> = {};
function handleToolCallResult(result: z.infer<typeof apiV1.ToolMessage>) {
resultsMap[result.tool_call_id] = result;
if (Object.keys(resultsMap).length === toolCalls.length) {
const results = Object.values(resultsMap);
handleResults(results);
}
}
return <div className="flex flex-col gap-4">
{toolCalls.map(toolCall => {
return <ToolCall
key={toolCall.id}
toolCall={toolCall}
result={results[toolCall.id]}
handleResult={handleToolCallResult}
projectId={projectId}
messages={messages}
sender={sender}
workflow={workflow}
/>
})}
</div>;
}
function ToolCall({
toolCall,
result,
handleResult,
projectId,
messages,
sender,
workflow,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | undefined;
workflow: z.infer<typeof Workflow>;
}) {
let matchingWorkflowTool: z.infer<typeof WorkflowTool> | undefined;
for (const tool of workflow.tools) {
if (tool.name === toolCall.function.name) {
matchingWorkflowTool = tool;
break;
}
}
switch (toolCall.function.name) {
case 'retrieve_url_info':
return <RetrieveUrlInfoToolCall
toolCall={toolCall}
result={result}
handleResult={handleResult}
projectId={projectId}
messages={messages}
sender={sender}
/>;
case 'getArticleInfo':
return <GetInformationToolCall
toolCall={toolCall}
result={result}
handleResult={handleResult}
projectId={projectId}
messages={messages}
sender={sender}
workflow={workflow}
/>;
default:
if (toolCall.function.name.startsWith('transfer_to_')) {
return <TransferToAgentToolCall
toolCall={toolCall}
result={result}
handleResult={handleResult}
projectId={projectId}
messages={messages}
sender={sender}
/>;
}
if (matchingWorkflowTool && !matchingWorkflowTool.mockInPlayground) {
return <ClientToolCall
toolCall={toolCall}
result={result}
handleResult={handleResult}
projectId={projectId}
messages={messages}
sender={sender}
/>;
}
return <MockToolCall
toolCall={toolCall}
result={result}
handleResult={handleResult}
projectId={projectId}
messages={messages}
sender={sender}
/>;
}
}
function GetInformationToolCall({
toolCall,
result: availableResult,
handleResult,
projectId,
messages,
sender,
workflow,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | undefined;
workflow: z.infer<typeof Workflow>;
}) {
const [result, setResult] = useState<z.infer<typeof apiV1.ToolMessage> | undefined>(availableResult);
const args = JSON.parse(toolCall.function.arguments) as { question: string };
let typedResult: z.infer<typeof GetInformationToolResult> | undefined;
if (result) {
typedResult = JSON.parse(result.content) as z.infer<typeof GetInformationToolResult>;
}
useEffect(() => {
if (result) {
return;
}
let ignore = false;
async function process() {
const result: z.infer<typeof apiV1.ToolMessage> = {
role: 'tool',
tool_call_id: toolCall.id,
tool_name: toolCall.function.name,
content: '',
};
// find target agent
const agent = workflow.agents.find(agent => agent.name == sender);
if (!agent || !agent.ragDataSources) {
result.content = JSON.stringify({
results: [],
});
} else {
const matches = await getInformationTool(projectId, args.question, agent.ragDataSources, agent.ragReturnType, agent.ragK);
if (ignore) {
return;
}
result.content = JSON.stringify(matches);
}
setResult(result);
handleResult(result);
}
process();
return () => {
ignore = true;
};
}, [result, toolCall.id, toolCall.function.name, projectId, args.question, workflow.agents, sender, handleResult]);
return <div className="flex flex-col gap-1">
{sender && <div className='text-gray-500 text-sm ml-3'>{sender}</div>}
<div className='border border-gray-300 p-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%]'>
<div className='flex gap-2 items-center'>
{!result && <Spinner />}
{result && <svg className="w-[16px] h-[16px] text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.707-1.293a1 1 0 0 0-1.414-1.414L11 12.586l-1.793-1.793a1 1 0 0 0-1.414 1.414l2.5 2.5a1 1 0 0 0 1.414 0l4-4Z" clipRule="evenodd" />
</svg>}
<div className='font-semibold'>
Function Call: <span className='bg-gray-100 px-2 py-1 rounded-lg font-mono font-medium'>{toolCall.function.name}</span>
</div>
</div>
<div className='mt-1'>
{result ? 'Fetched' : 'Fetch'} information for question: <span className='font-mono font-semibold'>{args['question']}</span>
{result && <div className='flex flex-col gap-2 mt-2 pt-2 border-t border-t-gray-200'>
{typedResult && typedResult.results.length === 0 && <div>No matches found.</div>}
{typedResult && typedResult.results.length > 0 && <ul className="list-disc ml-6">
{typedResult.results.map((result, index) => {
return <li key={'' + index}>
<Link target="_blank" className="underline" href={result.url}>
{result.url}
</Link>
</li>
})}
</ul>
}
</div>}
</div>
</div>
</div>;
}
function RetrieveUrlInfoToolCall({
toolCall,
result: availableResult,
handleResult,
projectId,
messages,
sender,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | undefined;
}) {
const [result, setResult] = useState<z.infer<typeof apiV1.ToolMessage> | undefined>(availableResult);
const args = JSON.parse(toolCall.function.arguments) as { url: string };
let typedResult: z.infer<typeof WebpageCrawlResponse> | undefined;
if (result) {
typedResult = JSON.parse(result.content) as z.infer<typeof WebpageCrawlResponse>;
}
useEffect(() => {
if (result) {
return;
}
let ignore = false;
function process() {
// parse args
scrapeWebpage(args.url)
.then(page => {
if (ignore) {
return;
}
const result: z.infer<typeof apiV1.ToolMessage> = {
role: 'tool',
tool_call_id: toolCall.id,
tool_name: toolCall.function.name,
content: JSON.stringify(page),
};
setResult(result);
handleResult(result);
});
}
process();
return () => {
ignore = true;
};
}, [result, toolCall.id, toolCall.function.name, projectId, args.url, handleResult]);
return <div className="flex flex-col gap-1">
{sender && <div className='text-gray-500 text-sm ml-3'>{sender}</div>}
<div className='border border-gray-300 p-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%]'>
<div className='flex gap-2 items-center'>
{!result && <Spinner />}
{result && <svg className="w-[16px] h-[16px] text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.707-1.293a1 1 0 0 0-1.414-1.414L11 12.586l-1.793-1.793a1 1 0 0 0-1.414 1.414l2.5 2.5a1 1 0 0 0 1.414 0l4-4Z" clipRule="evenodd" />
</svg>}
<div className='font-semibold'>
Function Call: <span className='bg-gray-100 px-2 py-1 rounded-lg font-mono font-medium'>{toolCall.function.name}</span>
</div>
</div>
<div className='mt-1 flex flex-col gap-2'>
<div className="flex gap-1">
URL: <a className="inline-flex items-center gap-1" target="_blank" href={args.url}>
<span className='underline'>
{args.url}
</span>
<svg className="w-[16px] h-[16px] shrink-0" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M18 14v4.833A1.166 1.166 0 0 1 16.833 20H5.167A1.167 1.167 0 0 1 4 18.833V7.167A1.166 1.166 0 0 1 5.167 6h4.618m4.447-2H20v5.768m-7.889 2.121 7.778-7.778" />
</svg>
</a>
</div>
{result && (
<ExpandableContent
label='Content'
content={JSON.stringify(typedResult, null, 2)}
expanded={false}
/>
)}
</div>
</div>
</div>;
}
function TransferToAgentToolCall({
toolCall,
result: availableResult,
handleResult,
projectId,
messages,
sender,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | undefined;
}) {
const typedResult = availableResult ? JSON.parse(availableResult.content) as { assistant: string } : undefined;
if (!typedResult) {
return <></>;
}
return <div className="flex gap-1 items-center text-gray-500 text-sm justify-center">
<div>{sender}</div>
<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 12H5m14 0-4 4m4-4-4-4" />
</svg>
<div>{typedResult.assistant}</div>
</div>;
}
function ClientToolCall({
toolCall,
result: availableResult,
handleResult,
projectId,
messages,
sender,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | undefined;
}) {
const [result, setResult] = useState<z.infer<typeof apiV1.ToolMessage> | undefined>(availableResult);
useEffect(() => {
if (result) {
return;
}
let ignore = false;
async function process() {
let response;
try {
response = await executeClientTool(
toolCall,
projectId,
);
} catch (e) {
response = {
error: (e as Error).message,
};
}
if (ignore) {
return;
}
const result: z.infer<typeof apiV1.ToolMessage> = {
role: 'tool',
tool_call_id: toolCall.id,
tool_name: toolCall.function.name,
content: JSON.stringify(response),
};
setResult(result);
handleResult(result);
}
process();
return () => {
ignore = true;
};
}, [result, toolCall, projectId, messages, handleResult]);
return <div className="flex flex-col gap-1">
{sender && <div className='text-gray-500 text-sm ml-3'>{sender}</div>}
<div className='border border-gray-300 p-2 pt-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%]'>
<div className='shrink-0 flex gap-2 items-center'>
{!result && <Spinner />}
{result && <svg className="w-[16px] h-[16px] text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.707-1.293a1 1 0 0 0-1.414-1.414L11 12.586l-1.793-1.793a1 1 0 0 0-1.414 1.414l2.5 2.5a1 1 0 0 0 1.414 0l4-4Z" clipRule="evenodd" />
</svg>}
<div className='font-semibold'>
Function Call: <span className='bg-gray-100 px-2 py-1 rounded-lg font-mono font-medium'>{toolCall.function.name}</span>
</div>
</div>
<div className='flex flex-col gap-2'>
<ExpandableContent label='Arguments' content={JSON.stringify(toolCall.function.arguments, null, 2)} expanded={Boolean(!result)} />
{result && <ExpandableContent label='Result' content={JSON.stringify(result.content, null, 2)} expanded={true} />}
</div>
</div>
</div>;
}
function MockToolCall({
toolCall,
result: availableResult,
handleResult,
projectId,
messages,
sender,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | undefined;
}) {
const [result, setResult] = useState<z.infer<typeof apiV1.ToolMessage> | undefined>(availableResult);
const [response, setResponse] = useState('');
const [generatingResponse, setGeneratingResponse] = useState(false);
useEffect(() => {
if (result) {
return;
}
if (response) {
return;
}
let ignore = false;
function process() {
setGeneratingResponse(true);
suggestToolResponse(toolCall.id, projectId, messages)
.then((object) => {
if (ignore) {
return;
}
setResponse(JSON.stringify(object));
})
.finally(() => {
if (ignore) {
return;
}
setGeneratingResponse(false);
})
}
process();
return () => {
ignore = true;
};
}, [result, response, toolCall.id, projectId, messages]);
function handleSubmit() {
let parsed;
try {
parsed = JSON.parse(response);
} catch (e) {
alert('Invalid JSON');
return;
}
const result: z.infer<typeof apiV1.ToolMessage> = {
role: 'tool',
tool_call_id: toolCall.id,
tool_name: toolCall.function.name,
content: JSON.stringify(parsed),
};
setResult(result);
handleResult(result);
}
return <div className="flex flex-col gap-1">
{sender && <div className='text-gray-500 text-sm ml-3'>{sender}</div>}
<div className='border border-gray-300 p-2 pt-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%]'>
<div className='shrink-0 flex gap-2 items-center'>
{!result && <Spinner />}
{result && <svg className="w-[16px] h-[16px] text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.707-1.293a1 1 0 0 0-1.414-1.414L11 12.586l-1.793-1.793a1 1 0 0 0-1.414 1.414l2.5 2.5a1 1 0 0 0 1.414 0l4-4Z" clipRule="evenodd" />
</svg>}
<div className='font-semibold'>
Function Call: <span className='bg-gray-100 px-2 py-1 rounded-lg font-mono font-medium'>{toolCall.function.name}</span>
</div>
</div>
<div className='flex flex-col gap-2'>
<ExpandableContent label='Arguments' content={JSON.stringify(toolCall.function.arguments, null, 2)} expanded={Boolean(!result)} />
{result && <ExpandableContent label='Result' content={JSON.stringify(result.content, null, 2)} expanded={true} />}
</div>
{!result && <div className='flex flex-col gap-2 mt-2'>
<div>Response:</div>
<Textarea
maxRows={10}
placeholder='{}'
variant="bordered"
value={response}
disabled={generatingResponse}
onValueChange={(value) => setResponse(value)}
className='font-mono'
>
</Textarea>
<Button
onClick={handleSubmit}
disabled={generatingResponse}
isLoading={generatingResponse}
>
Submit result
</Button>
</div>}
</div>
</div>;
}
function ExpandableContent({
label,
content,
expanded = false
}: {
label: string,
content: string
expanded?: boolean
}) {
const [isExpanded, setIsExpanded] = useState(expanded);
function toggleExpanded() {
setIsExpanded(!isExpanded);
}
return <div className='flex flex-col gap-2'>
<div className='flex gap-2 items-start cursor-pointer' onClick={toggleExpanded}>
{!isExpanded && <svg className="mt-1 w-[16px] h-[16px] shrink-0" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M12 7.757v8.486M7.757 12h8.486M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>}
{isExpanded && <svg className="mt-1 w-[16px] h-[16px] shrink-0" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M7.757 12h8.486M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>}
<div className='text-left break-all'>{label}</div>
</div>
{isExpanded && <div className='text-sm font-mono bg-gray-100 p-2 rounded break-all'>
{content}
</div>}
</div>;
}
function SystemMessage({
content,
onChange,
locked
}: {
content: string,
onChange: (content: string) => void,
locked: boolean
}) {
return (
<div className="border border-gray-300 p-2 rounded-lg flex flex-col gap-2">
<EditableField
light
label="System message"
value={content}
onChange={onChange}
multiline
markdown
locked={locked}
placeholder={`Use this space to simulate user information provided to the assistant at start of chat. Example:
- userName: John Doe
- email: john@gmail.com
This is intended for testing only.`}
/>
</div>
);
}
export function Messages({
projectId,
systemMessage,
messages,
toolCallResults,
handleToolCallResults,
loadingAssistantResponse,
loadingUserResponse,
workflow,
onSystemMessageChange,
}: {
projectId: string;
systemMessage: string | undefined;
messages: z.infer<typeof apiV1.ChatMessage>[];
toolCallResults: Record<string, z.infer<typeof apiV1.ToolMessage>>;
handleToolCallResults: (results: z.infer<typeof apiV1.ToolMessage>[]) => void;
loadingAssistantResponse: boolean;
loadingUserResponse: boolean;
workflow: z.infer<typeof Workflow>;
onSystemMessageChange: (message: string) => void;
}) {
const messagesEndRef = useRef<HTMLDivElement>(null);
let lastUserMessageTimestamp = 0;
const systemMessageLocked = messages.length > 0;
// scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages, loadingAssistantResponse, loadingUserResponse]);
return <div className="grow pt-4 overflow-auto">
<div className="max-w-[768px] mx-auto flex flex-col gap-8">
<SystemMessage
content={systemMessage || ''}
onChange={onSystemMessageChange}
locked={systemMessageLocked}
/>
{messages.map((message, index) => {
if (message.role === 'assistant') {
if ('tool_calls' in message) {
return <ToolCalls
key={index}
toolCalls={message.tool_calls}
results={toolCallResults}
handleResults={handleToolCallResults}
projectId={projectId}
messages={messages}
sender={message.agenticSender}
workflow={workflow}
/>;
} else {
// the assistant message createdAt is an ISO string timestamp
const latency = new Date(message.createdAt).getTime() - lastUserMessageTimestamp;
if (message.agenticResponseType === 'internal') {
return (
<InternalAssistantMessage
key={index}
content={message.content}
sender={message.agenticSender}
latency={latency}
/>
);
} else {
return (
<AssistantMessage
key={index}
content={message.content}
sender={message.agenticSender}
latency={latency}
/>
);
}
}
}
if (message.role === 'user' && typeof message.content === 'string') {
lastUserMessageTimestamp = new Date(message.createdAt).getTime();
return <UserMessage key={index} content={message.content} />;
}
return <></>;
})}
{loadingAssistantResponse && <AssistantMessageLoading key="assistant-loading" />}
{loadingUserResponse && <UserMessageLoading key="user-loading" />}
<div ref={messagesEndRef} />
</div>
</div>;
}

View file

@ -0,0 +1,233 @@
'use client';
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Spinner, Textarea } from "@nextui-org/react";
import { useState, useEffect } from "react";
import { getScenarios, createScenario, updateScenario, deleteScenario } from "@/app/actions";
import { Scenario, WithStringId } from "@/app/lib/types";
import { z } from "zod";
import { EditableField } from "@/app/lib/components/editable-field";
import { HamburgerIcon } from "@/app/lib/components/icons";
import { EllipsisVerticalIcon } from "lucide-react";
export function AddScenarioForm({
onAdd,
}: {
onAdd: (name: string, description: string) => void;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const handleAdd = async () => {
try {
setSaving(true);
await onAdd(name, description);
setName("");
setDescription("");
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Invalid input");
} finally {
setSaving(false);
}
};
return <div className="flex flex-col gap-2 border rounded-lg p-4 shadow-sm">
<div className="font-semibold text-gray-500">Add Scenario</div>
<Input
label="Scenario Name"
labelPlacement="outside"
value={name}
placeholder="Provide a name for the scenario"
size="sm"
variant="bordered"
onChange={(e) => setName(e.target.value)}
isInvalid={!!error}
required
/>
<Textarea
label="Scenario Description"
labelPlacement="outside"
value={description}
placeholder="Describe the test scenario"
size="sm"
variant="bordered"
onChange={(e) => setDescription(e.target.value)}
isInvalid={!!error}
required
/>
{error && <div className="text-red-500 text-sm">{error}</div>}
<Button
onClick={handleAdd}
isLoading={saving}
isDisabled={saving}
size="sm"
className="self-start"
variant="bordered"
startContent={
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
</svg>
}
>
Add Scenario
</Button>
</div>
}
export function ScenarioList({
projectId,
onPlay,
}: {
projectId: string;
onPlay: (scenario: z.infer<typeof Scenario>) => void;
}) {
const [scenarios, setScenarios] = useState<WithStringId<z.infer<typeof Scenario> & {
tmp?: boolean;
}>[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [tmpScenarioId, setTmpScenarioId] = useState<number>(0);
const [showAddForm, setShowAddForm] = useState(false);
useEffect(() => {
getScenarios(projectId)
.then(setScenarios)
.finally(() => setLoading(false));
}, [projectId]);
async function handleAddScenario(name: string, description: string) {
try {
const tmpId = 'tmp-' + tmpScenarioId;
setTmpScenarioId(tmpScenarioId + 1);
setSaving(true);
setShowAddForm(false);
setScenarios([...scenarios, {
_id: tmpId,
name,
description,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
tmp: true,
}]);
const id = await createScenario(projectId, name, description);
setScenarios([...scenarios, {
_id: id,
name,
description,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
tmp: false,
}]);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Invalid input");
} finally {
setSaving(false);
}
};
async function handleEditScenario(scenarioId: string, name: string, description: string) {
setSaving(true);
setScenarios(scenarios.map(scenario => scenario._id === scenarioId ? { ...scenario, name, description } : scenario));
await updateScenario(projectId, scenarioId, name, description);
setSaving(false);
}
async function handleDeleteScenario(scenarioId: string) {
setSaving(true);
setScenarios(scenarios.filter(scenario => scenario._id !== scenarioId));
await deleteScenario(projectId, scenarioId);
setSaving(false);
}
return (
<div className="flex flex-col gap-4">
<div className="flex justify-between gap-2 items-center">
<div className="font-semibold text-gray-500">Scenarios</div>
{saving && <div className="flex items-center gap-2">
<Spinner />
<div className="text-sm text-gray-500">Saving...</div>
</div>}
{!showAddForm && <Button
onClick={() => setShowAddForm(true)}
size="sm"
variant="bordered"
>
Add Scenario
</Button>}
</div>
{loading && <div className="flex justify-center items-center p-8 gap-2">
<Spinner size="sm" />
<div className="text-sm text-gray-500">Loading scenarios...</div>
</div>}
{showAddForm && <AddScenarioForm onAdd={handleAddScenario} />}
{!loading && scenarios.length === 0 && <div className="flex justify-center items-center p-8 gap-2">
<div className="text-sm text-gray-500">No scenarios added</div>
</div>}
{scenarios.length > 0 && <div className="flex flex-col gap-2">
{scenarios.map((scenario) => (
<div key={scenario._id} className="flex justify-between gap-2 border p-2 rounded-md shadow-sm">
<div className="flex flex-col gap-1 grow">
<EditableField
key={'name'}
label="Name"
placeholder="Scenario Name"
value={scenario.name}
onChange={(value) => handleEditScenario(scenario._id, value, scenario.description)}
locked={scenario.tmp}
/>
<EditableField
key={'description'}
label="Description"
multiline
markdown
light
placeholder="Scenario Description"
value={scenario.description}
onChange={(value) => handleEditScenario(scenario._id, scenario.name, value)}
locked={scenario.tmp}
/>
</div>
<button
className="text-sm text-blue-500 hover:text-gray-700 font-semibold uppercase"
onClick={() => onPlay(scenario)}
>
Run &rarr;
</button>
<Dropdown>
<DropdownTrigger>
<button className="text-gray-300 hover:text-gray-700">
<EllipsisVerticalIcon size={16} />
</button>
</DropdownTrigger>
<DropdownMenu
disabledKeys={scenario.tmp ? ['delete'] : ['']}
onAction={(key) => {
if (key === 'delete') {
handleDeleteScenario(scenario._id);
}
}}
>
<DropdownItem
key="delete"
color="danger"
>
Delete
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
))}
</div>}
</div>
);
}

View file

@ -0,0 +1,107 @@
'use client';
import { Input, Textarea } from "@nextui-org/react";
import { FormStatusButton } from "@/app/lib/components/FormStatusButton";
import { SimulationData } from "@/app/lib/types";
import { z } from "zod";
import { scrapeWebpage } from "@/app/actions";
import { ScenarioList } from "./scenario-list";
export function SimulateURLOption({
projectId,
beginSimulation,
}: {
projectId: string;
beginSimulation: (data: z.infer<typeof SimulationData>) => void;
}) {
function handleUrlSimulationSubmit(formData: FormData) {
const url = formData.get('url') as string;
// fetch article content and title
scrapeWebpage(url).then((result) => {
beginSimulation({
articleUrl: url,
articleContent: result.content,
articleTitle: result.title,
});
});
}
return <form action={handleUrlSimulationSubmit} className="flex flex-col gap-2">
<div>Use a URL / article link:</div>
<input type="hidden" name="projectId" value={projectId} />
<Input
variant="bordered"
placeholder="https://acme.com/articles/product-detiails"
name="url"
required
endContent={<FormStatusButton
props={{
type: "submit",
endContent: <svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 12H5m14 0-4 4m4-4-4-4" />
</svg>,
children: "Go"
}}
/>}
/>
</form>;
}
export function SimulateScenarioOption({
projectId,
beginSimulation,
}: {
projectId: string;
beginSimulation: (data: z.infer<typeof SimulationData>) => void;
}) {
return (
<ScenarioList
projectId={projectId}
onPlay={(scenario) => beginSimulation({
scenario: scenario.description,
})}
/>
);
}
export function SimulateChatContextOption({
projectId,
beginSimulation,
}: {
projectId: string;
beginSimulation: (data: z.infer<typeof SimulationData>) => void;
}) {
function handleChatContextSimulationSubmit(formData: FormData) {
beginSimulation({
chatMessages: formData.get('context') as string,
});
}
return <form action={handleChatContextSimulationSubmit} className="flex flex-col gap-2">
<div>Use a previous chat context:</div>
<input type="hidden" name="projectId" value={projectId} />
<Textarea
variant="bordered"
minRows={3}
maxRows={10}
required
name="context"
placeholder={JSON.stringify([
{
"role": "assistant",
"content": "Hello! How can I help you today?"
},
{
"role": "user",
"content": "Hello! I need help with..."
}
], null, 2)}
endContent={<FormStatusButton
props={{
type: "submit",
endContent: <svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 12H5m14 0-4 4m4-4-4-4" />
</svg>,
children: "Go"
}}
/>}
/>
</form>;
}

View file

@ -0,0 +1,28 @@
'use client';
import { deleteDataSource } from "@/app/actions";
import { FormStatusButton } from "@/app/lib/components/FormStatusButton";
export function DeleteSource({
projectId,
sourceId,
}: {
projectId: string;
sourceId: string;
}) {
function handleDelete() {
if (window.confirm('Are you sure you want to delete this data source?')) {
deleteDataSource(projectId, sourceId);
}
}
return <form action={handleDelete}>
<FormStatusButton
props={{
type: "submit",
children: "Delete data source",
className: "text-red-800",
}}
/>
</form>;
}

View file

@ -0,0 +1,17 @@
import { notFound } from "next/navigation";
import { dataSourcesCollection } from "@/app/lib/mongodb";
import { ObjectId } from "mongodb";
import { Metadata } from "next";
import { SourcePage } from "./source-page";
import { getDataSource } from "@/app/actions";
export default async function Page({
params,
}: {
params: {
projectId: string,
sourceId: string
}
}) {
return <SourcePage projectId={params.projectId} sourceId={params.sourceId} />;
}

View file

@ -0,0 +1,215 @@
'use client';
import { DataSource } from "@/app/lib/types";
import { PageSection } from "@/app/lib/components/PageSection";
import { ToggleSource } from "../toggle-source";
import { Link, Spinner } from "@nextui-org/react";
import { SourceStatus } from "../source-status";
import { DeleteSource } from "./delete";
import { Recrawl } from "./web-recrawl";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { getDataSource, recrawlWebDataSource } from "@/app/actions";
import { DataSourceIcon } from "@/app/lib/components/datasource-icon";
import { z } from "zod";
function UrlList({ urls }: { urls: string }) {
return <pre className="max-w-[450px] border p-1 border-gray-300 rounded overflow-auto min-h-7 max-h-52 text-nowrap">
{urls}
</pre>;
}
function TableLabel({ children, className }: { children: React.ReactNode, className?: string }) {
return <th className={`font-medium text-gray-800 text-left align-top pr-4 py-4 ${className}`}>{children}</th>;
}
function TableValue({ children, className }: { children: React.ReactNode, className?: string }) {
return <td className={`align-top py-4 ${className}`}>{children}</td>;
}
export function SourcePage({
sourceId,
projectId,
}: {
sourceId: string;
projectId: string;
}) {
const searchParams = useSearchParams();
const [source, setSource] = useState<z.infer<typeof DataSource> | null>(null);
// fetch source daat first time
useEffect(() => {
let ignore = false;
async function fetchSource() {
const source = await getDataSource(projectId, sourceId);
if (!ignore) {
setSource(source);
}
}
fetchSource();
return () => {
ignore = true;
};
}, [projectId, sourceId]);
// refresh source data every 15 seconds
// under certain conditions
useEffect(() => {
let ignore = false;
let timeout: NodeJS.Timeout | null = null;
if (!source) {
return;
}
if (source.status !== 'processing' && source.status !== 'new') {
return;
}
async function refresh() {
if (timeout) {
clearTimeout(timeout);
}
const updatedSource = await getDataSource(projectId, sourceId);
if (!ignore) {
setSource(updatedSource);
timeout = setTimeout(refresh, 15 * 1000);
}
}
timeout = setTimeout(refresh, 15 * 1000);
return () => {
ignore = true;
if (timeout) {
clearTimeout(timeout);
}
};
}, [source, projectId, sourceId]);
async function handleRefresh() {
await recrawlWebDataSource(projectId, sourceId);
const updatedSource = await getDataSource(projectId, sourceId);
setSource(updatedSource);
}
if (!source) {
return <div className="flex items-center gap-2">
<Spinner size="sm" />
<div>Loading...</div>
</div>
}
return <div className="flex flex-col h-full">
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-b-gray-100">
<div className="flex flex-col">
<h1 className="text-lg">{source.name}</h1>
</div>
</div>
<div className="grow overflow-auto py-4">
<div className="max-w-[768px] mx-auto">
<PageSection title="Details">
<table className="table-auto">
<tbody>
<tr>
<TableLabel>Toggle:</TableLabel>
<TableValue>
<ToggleSource projectId={projectId} sourceId={sourceId} active={source.active} />
</TableValue>
</tr>
<tr>
<TableLabel>Type:</TableLabel>
<TableValue>
{source.data.type === 'crawl' && <div className="flex gap-1 items-center">
<DataSourceIcon type="crawl" />
<div>Crawl URLs</div>
</div>}
{source.data.type === 'urls' && <div className="flex gap-1 items-center">
<DataSourceIcon type="urls" />
<div>Specify URLs</div>
</div>}
</TableValue>
</tr>
<tr>
<TableLabel>Source:</TableLabel>
<TableValue>
<SourceStatus status={source.status} projectId={projectId} />
</TableValue>
</tr>
{source.data.type === 'urls' && source.data.missingUrls && <tr>
<TableLabel className="text-red-500">Errors:</TableLabel>
<TableValue>
<div>Some URLs could not be scraped. See the list below.</div>
</TableValue>
</tr>}
</tbody>
</table>
</PageSection>
{source.data.type === 'crawl' && <PageSection title="Crawl details">
<table className="table-auto">
<tbody>
<tr>
<TableLabel>Starting URL:</TableLabel>
<TableValue>
<Link
href={source.data.startUrl}
target="_blank"
showAnchorIcon
color="foreground"
underline="always"
>
{source.data.startUrl}
</Link>
</TableValue>
</tr>
<tr>
<TableLabel>Limit:</TableLabel>
<TableValue>
{source.data.limit} pages
</TableValue>
</tr>
{source.data.crawledUrls && <tr>
<TableLabel>Crawled URLs:</TableLabel>
<TableValue>
<UrlList urls={source.data.crawledUrls} />
</TableValue>
</tr>}
</tbody>
</table>
</PageSection>}
{source.data.type === 'urls' && <PageSection title="Index details">
<table className="table-auto">
<tbody>
<tr>
<TableLabel>Input URLs:</TableLabel>
<TableValue>
<UrlList urls={source.data.urls.join('\n')} />
</TableValue>
</tr>
{source.data.scrapedUrls && <tr>
<TableLabel>Scraped URLs:</TableLabel>
<TableValue>
<UrlList urls={source.data.scrapedUrls} />
</TableValue>
</tr>}
{source.data.missingUrls && <tr>
<TableLabel className="text-red-500">The following URLs could not be scraped:</TableLabel>
<TableValue>
<UrlList urls={source.data.missingUrls} />
</TableValue>
</tr>}
</tbody>
</table>
</PageSection>}
{(source.status === 'completed' || source.status === 'error') && (source.data.type === 'crawl' || source.data.type === 'urls') && <PageSection title="Refresh">
<div className="flex flex-col gap-2 items-start">
<p>{source.data.type === 'crawl' ? 'Crawl' : 'Scrape'} the URLs again to fetch updated content:</p>
<Recrawl projectId={projectId} sourceId={sourceId} handleRefresh={handleRefresh} />
</div>
</PageSection>}
<PageSection title="Danger zone">
<div className="flex flex-col gap-2 items-start">
<p>Delete this data source:</p>
<DeleteSource projectId={projectId} sourceId={sourceId} />
</div>
</PageSection>
</div>
</div>
</div>;
}

View file

@ -0,0 +1,26 @@
'use client';
import { recrawlWebDataSource } from "@/app/actions";
import { FormStatusButton } from "@/app/lib/components/FormStatusButton";
export function Recrawl({
projectId,
sourceId,
handleRefresh,
}: {
projectId: string;
sourceId: string;
handleRefresh: () => void;
}) {
return <form action={handleRefresh}>
<FormStatusButton
props={{
type: "submit",
startContent: <svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M17.651 7.65a7.131 7.131 0 0 0-12.68 3.15M18.001 4v4h-4m-7.652 8.35a7.13 7.13 0 0 0 12.68-3.15M6 20v-4h4" />
</svg>,
children: "Refresh",
}}
/>
</form>;
}

View file

@ -0,0 +1,146 @@
'use client';
import { Input, Select, SelectItem, Textarea } from "@nextui-org/react"
import { useState } from "react";
import { createCrawlDataSource, createUrlsDataSource } from "@/app/actions";
import { FormStatusButton } from "@/app/lib/components/FormStatusButton";
import { DataSourceIcon } from "@/app/lib/components/datasource-icon";
export function Form({
projectId
}: {
projectId: string;
}) {
const [sourceType, setSourceType] = useState("crawl");
const createCrawlDataSourceWithProjectId = createCrawlDataSource.bind(null, projectId);
const createUrlsDataSourceWithProjectId = createUrlsDataSource.bind(null, projectId);
function handleSourceTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
setSourceType(event.target.value);
}
return <div className="grow overflow-auto py-4">
<div className="max-w-[768px] mx-auto flex flex-col gap-4">
<Select
label="Select type"
selectedKeys={[sourceType]}
onChange={handleSourceTypeChange}
>
<SelectItem
key="crawl"
value="crawl"
startContent={<DataSourceIcon type="crawl" />}
>
Crawl URLs
</SelectItem>
<SelectItem
key="urls"
value="urls"
startContent={<DataSourceIcon type="urls" />}
>
Specify URLs
</SelectItem>
</Select>
{sourceType === "crawl" && <form
action={createCrawlDataSourceWithProjectId}
className="flex flex-col gap-4"
>
<Input
required
type="text"
name="url"
label="Specify starting URL to crawl"
labelPlacement="outside"
placeholder="https://example.com"
variant="bordered"
/>
<div className="self-start w-[200px]">
<Input
required
type="number"
min={1}
max={5000}
name="limit"
label="Maximum pages to crawl"
labelPlacement="outside"
placeholder="100"
defaultValue={"100"}
variant="bordered"
/>
</div>
<div className="self-start">
<Input
required
type="text"
name="name"
label="Name this data source"
labelPlacement="outside"
placeholder="e.g. Help articles"
variant="bordered"
/>
</div>
<div className="text-sm">
<p>Note:</p>
<ul className="list-disc ml-4">
<li>Expect about 5-10 minutes to crawl 100 pages</li>
</ul>
</div>
<FormStatusButton
props={{
type: "submit",
children: "Add data source",
className: "self-start",
startContent: <svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
</svg>,
}}
/>
</form>}
{sourceType === "urls" && <form
action={createUrlsDataSourceWithProjectId}
className="flex flex-col gap-4"
>
<Textarea
required
type="text"
name="urls"
label="Specify URLs (one per line)"
minRows={5}
maxRows={10}
labelPlacement="outside"
placeholder="https://example.com"
variant="bordered"
/>
<div className="self-start">
<Input
required
type="text"
name="name"
label="Name this data source"
labelPlacement="outside"
placeholder="e.g. Help articles"
variant="bordered"
/>
</div>
<div className="text-sm">
<p>Note:</p>
<ul className="list-disc ml-4">
<li>Expect about 5-10 minutes to scrape 100 pages</li>
<li>Only the first 100 URLs will be scraped</li>
</ul>
</div>
<FormStatusButton
props={{
type: "submit",
children: "Add data source",
className: "self-start",
startContent: <svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
</svg>,
}}
/>
</form>}
</div>
</div>;
}

View file

@ -0,0 +1,21 @@
import { Metadata } from "next";
import { Form } from "./form";
export const metadata: Metadata = {
title: "Add data source"
}
export default async function Page({
params
}: {
params: { projectId: string }
}) {
return <div className="flex flex-col h-full">
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-b-gray-100">
<div className="flex flex-col">
<h1 className="text-lg">Add data source</h1>
</div>
</div>
<Form projectId={params.projectId} />
</div>;
}

View file

@ -0,0 +1,16 @@
import { Metadata } from "next";
import { SourcesList } from "./sources-list";
export const metadata: Metadata = {
title: "Data sources",
}
export default async function Page({
params,
}: {
params: { projectId: string }
}) {
return <SourcesList
projectId={params.projectId}
/>;
}

View file

@ -0,0 +1,51 @@
'use client';
import { getUpdatedSourceStatus } from "@/app/actions";
import { DataSource } from "@/app/lib/types";
import { useEffect, useState } from "react";
import { z } from 'zod';
import { SourceStatus } from "./source-status";
export function SelfUpdatingSourceStatus({
projectId,
sourceId,
initialStatus,
compact = false,
}: {
projectId: string;
sourceId: string,
initialStatus: z.infer<typeof DataSource>['status'],
compact?: boolean;
}) {
const [status, setStatus] = useState(initialStatus);
useEffect(() => {
console.log("in effect i'm here")
let unmounted = false;
if (status !== 'processing' && status !== 'new') {
return;
}
function check() {
if (unmounted) {
return;
}
if (status !== 'processing' && status !== 'new') {
return;
}
console.log("i'm here")
getUpdatedSourceStatus(projectId, sourceId)
.then((updatedStatus) => {
console.log("updatedStatus", updatedStatus)
setStatus(updatedStatus);
setTimeout(check, 15 * 1000);
});
}
setTimeout(check, 15 * 1000);
return () => {
unmounted = true;
};
});
return <SourceStatus status={status} compact={compact} projectId={projectId} />;
}

View file

@ -0,0 +1,63 @@
import { DataSource } from "@/app/lib/types";
import { Spinner } from "@nextui-org/react";
import { Link } from "@nextui-org/react";
import { z } from 'zod';
export function SourceStatus({
status,
projectId,
compact = false,
}: {
status: z.infer<typeof DataSource>['status'],
projectId: string,
compact?: boolean;
}) {
return <div>
{status == 'error' && <div className="flex flex-col gap-1 items-start">
<div className="flex gap-1 items-center">
<svg className="w-[24px] h-[24px] text-red-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z" clipRule="evenodd" />
</svg>
<div>Error</div>
</div>
{!compact && <div className="text-sm text-gray-400">
There was an unexpected error while processing this resource.
</div>}
</div>}
{status == 'processing' && <div className="flex flex-col gap-1 items-start">
<div className="flex gap-1 items-center">
<Spinner size="sm" />
<div className="text-gray-400">
Processing&hellip;
</div>
</div>
{!compact && <div className="text-sm text-gray-400">
This source is being processed. This may take a few minutes.
</div>}
</div>}
{status == 'new' && <div className="flex flex-col gap-1 items-start">
<div className="flex gap-1 items-center">
<svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<div>
Queued
</div>
</div>
{!compact && <div className="text-sm text-gray-400">
This source is waiting to be processed.
</div>}
</div>}
{status === 'completed' && <div className="flex flex-col gap-1 items-start">
<div className="flex gap-1 items-center">
<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.707-1.293a1 1 0 0 0-1.414-1.414L11 12.586l-1.793-1.793a1 1 0 0 0-1.414 1.414l2.5 2.5a1 1 0 0 0 1.414 0l4-4Z" clipRule="evenodd" />
</svg>
<div>Ready</div>
</div>
{!compact && <div>
This source has been indexed and is ready to use.
</div>}
</div>}
</div>;
}

View file

@ -0,0 +1,106 @@
'use client';
import { Button, Link, Spinner } from "@nextui-org/react";
import { ToggleSource } from "./toggle-source";
import { SelfUpdatingSourceStatus } from "./self-updating-source-status";
import { DataSourceIcon } from "@/app/lib/components/datasource-icon";
import { useEffect, useState } from "react";
import { DataSource, WithStringId } from "@/app/lib/types";
import { z } from "zod";
import { listSources } from "@/app/actions";
export function SourcesList({
projectId,
}: {
projectId: string;
}) {
const [sources, setSources] = useState<WithStringId<z.infer<typeof DataSource>>[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let ignore = false;
async function fetchSources() {
setLoading(true);
const sources = await listSources(projectId);
if (!ignore) {
setSources(sources);
setLoading(false);
}
}
fetchSources();
return () => {
ignore = true;
};
}, [projectId]);
return <div className="flex flex-col h-full">
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-b-gray-100">
<div className="flex flex-col">
<h1 className="text-lg">Data sources</h1>
</div>
<div className="flex items-center gap-2">
<Button
href={`/projects/${projectId}/sources/new`}
as={Link}
startContent=<svg className="w-[24px] h-[24px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
</svg>
>
Add data source
</Button>
</div>
</div>
<div className="grow overflow-auto py-4">
<div className="max-w-[768px] mx-auto">
{loading && <div className="flex items-center gap-2">
<Spinner size="sm" />
<div>Loading...</div>
</div>}
{!loading && !sources.length && <p className="mt-4 text-center">You have not added any data sources.</p>}
{!loading && sources.length > 0 && <table className="w-full mt-2">
<thead className="pb-1 border-b border-b-gray-100">
<tr>
<th className="text-sm text-left font-medium text-gray-400">Name</th>
<th className="text-sm text-left font-medium text-gray-400">Type</th>
<th className="text-sm text-left font-medium text-gray-400">Status</th>
<th className="text-sm text-left font-medium text-gray-400"></th>
</tr>
</thead>
<tbody>
{sources.map((source) => {
return <tr key={source._id}>
<td className="py-4 text-left">
<Link
href={`/projects/${projectId}/sources/${source._id}`}
size="lg"
isBlock
>
{source.name}
</Link>
</td>
<td className="py-4">
{source.data.type == 'crawl' && <div className="flex gap-1 items-center">
<DataSourceIcon type="crawl" />
<div>Crawl URLs</div>
</div>}
{source.data.type == 'urls' && <div className="flex gap-1 items-center">
<DataSourceIcon type="urls" />
<div>Specify URLs</div>
</div>}
</td>
<td className="py-4">
<SelfUpdatingSourceStatus sourceId={source._id} projectId={projectId} initialStatus={source.status} compact={true} />
</td>
<td className="py-4 text-right">
<ToggleSource projectId={projectId} sourceId={source._id} active={source.active} compact={true} />
</td>
</tr>;
})}
</tbody>
</table>}
</div>
</div>
</div>;
}

View file

@ -0,0 +1,44 @@
'use client';
import { toggleDataSource } from "@/app/actions";
import { Spinner } from "@nextui-org/react";
import { Switch } from "@nextui-org/react";
import { useState } from "react";
export function ToggleSource({
projectId,
sourceId,
active,
compact=false,
}: {
projectId: string;
sourceId: string;
active: boolean;
compact?: boolean;
}) {
const [loading, setLoading] = useState(false);
const [isActive, setIsActive] = useState(active);
function handleActiveSwitchChange(isSelected: boolean) {
setIsActive(isSelected);
setLoading(true);
toggleDataSource(projectId, sourceId, isSelected)
.finally(() => {
setLoading(false);
});
}
return <div className="flex flex-col gap-1 items-start">
<div className="flex items-center gap-1">
<Switch
size={compact ? 'sm' : 'md'}
disabled={loading}
isSelected={isActive}
onValueChange={handleActiveSwitchChange}
>
{isActive ? 'Active' : 'Inactive'}
</Switch>
{loading && <Spinner size="sm" />}
</div>
{!compact && !isActive && <p className="text-sm text-red-800">This data source will not be used in chats.</p>}
</div>;
}

View file

@ -0,0 +1,380 @@
"use client";
import { AgenticAPITool, DataSource, WithStringId, WorkflowAgent, WorkflowPrompt } from "@/app/lib/types";
import { Accordion, AccordionItem, Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Radio, RadioGroup, Select, SelectItem, Textarea } from "@nextui-org/react";
import { z } from "zod";
import { DataSourceIcon } from "@/app/lib/components/datasource-icon";
import { ActionButton, Pane } from "./pane";
import { EditableField } from "@/app/lib/components/editable-field";
import MarkdownContent from "@/app/lib/components/markdown-content";
export function AgentConfig({
agent,
usedAgentNames,
agents,
tools,
prompts,
dataSources,
handleUpdate,
handleClose,
}: {
agent: z.infer<typeof WorkflowAgent>,
usedAgentNames: Set<string>,
agents: z.infer<typeof WorkflowAgent>[],
tools: z.infer<typeof AgenticAPITool>[],
prompts: z.infer<typeof WorkflowPrompt>[],
dataSources: WithStringId<z.infer<typeof DataSource>>[],
handleUpdate: (agent: z.infer<typeof WorkflowAgent>) => void,
handleClose: () => void,
}) {
return <Pane title={agent.name} actions={[
<ActionButton
key="close"
onClick={handleClose}
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
</svg>}
>
Close
</ActionButton>
]}>
<div className="flex flex-col gap-4">
{!agent.locked && (
<EditableField
key="name"
label="Name"
value={agent.name}
onChange={(value) => {
handleUpdate({
...agent,
name: value
});
}}
placeholder="Enter agent name"
validate={(value) => {
if (value.length === 0) {
return { valid: false, errorMessage: "Name cannot be empty" };
}
if (usedAgentNames.has(value)) {
return { valid: false, errorMessage: "This name is already taken" };
}
return { valid: true };
}}
/>
)}
<EditableField
key="description"
label="Description"
value={agent.description || ""}
onChange={(value) => {
handleUpdate({
...agent,
description: value
});
}}
placeholder="Enter a description for this agent"
/>
<div className="w-full flex flex-col">
<EditableField
key="instructions"
value={agent.instructions}
onChange={(value) => {
handleUpdate({
...agent,
instructions: value
});
}}
markdown
label="Instructions"
multiline
/>
</div>
<div className="w-full flex flex-col">
<EditableField
key="examples"
value={agent.examples || ""}
onChange={(value) => {
handleUpdate({
...agent,
examples: value
});
}}
placeholder="Enter examples for this agent"
markdown
label="Examples"
multiline
/>
</div>
<div className="flex flex-col gap-2 items-start">
<div className="text-sm">Attach prompts:</div>
<div className="flex gap-4 flex-wrap">
{agent.prompts.map((prompt) => (
<div key={prompt} className="bg-gray-100 border-1 border-gray-200 shadow-sm rounded-lg px-2 py-1 flex items-center gap-2">
<div>{prompt}</div>
<button
onClick={() => {
const newPrompts = agent.prompts.filter((p) => p !== prompt);
handleUpdate({
...agent,
prompts: newPrompts
});
}}
className="bg-white rounded-md text-gray-500 hover:text-gray-800"
>
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
</svg>
</button>
</div>
))}
</div>
<Dropdown>
<DropdownTrigger>
<Button
variant="bordered"
size="sm"
startContent={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
</svg>}
>
Add prompt
</Button>
</DropdownTrigger>
<DropdownMenu onAction={(key) => handleUpdate({
...agent,
prompts: [...agent.prompts, key as string]
})}>
{prompts.filter((prompt) => !agent.prompts.includes(prompt.name)).map((prompt) => (
<DropdownItem key={prompt.name}>
{prompt.name}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
</div>
<div className="flex flex-col gap-2 items-start">
<div className="text-sm">RAG:</div>
<div className="flex gap-4 flex-wrap">
{agent.ragDataSources?.map((source) => (
<div key={source} className="bg-gray-100 border-1 border-gray-200 shadow-sm rounded-lg px-2 py-1 flex items-center gap-2">
<div className="flex items-center gap-1">
<DataSourceIcon type={dataSources.find((ds) => ds._id === source)?.data.type} />
<div>{dataSources.find((ds) => ds._id === source)?.name || "Unknown"}</div>
</div>
<button
onClick={() => {
const newSources = agent.ragDataSources?.filter((s) => s !== source);
handleUpdate({
...agent,
ragDataSources: newSources
});
}}
className="bg-white rounded-md text-gray-500 hover:text-gray-800"
>
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
</svg>
</button>
</div>
))}
</div>
<Dropdown>
<DropdownTrigger>
<Button
variant="bordered"
size="sm"
startContent={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
</svg>}
>
Add data source
</Button>
</DropdownTrigger>
<DropdownMenu onAction={(key) => handleUpdate({
...agent,
ragDataSources: [...(agent.ragDataSources || []), key as string]
})}>
{dataSources.filter((ds) => !(agent.ragDataSources || []).includes(ds._id)).map((ds) => (
<DropdownItem
key={ds._id}
startContent={<DataSourceIcon type={ds.data.type} />}
>
{ds.name}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
{agent.ragDataSources !== undefined && agent.ragDataSources.length > 0 && <Accordion>
<AccordionItem
key="rag"
isCompact
aria-label="Advanced RAG configuration"
title="Advanced RAG configuration"
>
<div className="flex flex-col gap-4">
<RadioGroup
label="Return type:"
orientation="horizontal"
value={agent.ragReturnType}
onValueChange={(value) => handleUpdate({
...agent,
ragReturnType: value as z.infer<typeof WorkflowAgent>['ragReturnType']
})}
>
<Radio value="chunks">Chunks</Radio>
<Radio value="content">Content</Radio>
</RadioGroup>
<Input
label="No. of matches:"
labelPlacement="outside"
variant="bordered"
value={agent.ragK.toString()}
onValueChange={(value) => handleUpdate({
...agent,
ragK: parseInt(value)
})}
type="number"
/>
</div>
</AccordionItem>
</Accordion>}
</div>
<div className="flex flex-col gap-2 items-start">
<div className="text-sm">Tools:</div>
<div className="flex gap-4 flex-wrap">
{agent.tools.map((tool) => (
<div key={tool} className="bg-gray-100 border-1 border-gray-200 shadow-sm rounded-lg px-2 py-1 flex items-center gap-2">
<div className="font-mono">{tool}</div>
<button
onClick={() => {
const newTools = agent.tools.filter((t) => t !== tool);
handleUpdate({
...agent,
tools: newTools
});
}}
className="bg-white rounded-md text-gray-500 hover:text-gray-800"
>
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
</svg>
</button>
</div>
))}
</div>
<Dropdown>
<DropdownTrigger>
<Button
variant="bordered"
size="sm"
startContent={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
</svg>}
>
Add tool
</Button>
</DropdownTrigger>
<DropdownMenu onAction={(key) => handleUpdate({
...agent,
tools: [...(agent.tools || []), key as string]
})}>
{tools.filter((tool) => !(agent.tools || []).includes(tool.name)).map((tool) => (
<DropdownItem key={tool.name}>
<div className="font-mono">{tool.name}</div>
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
</div>
<div className="flex flex-col gap-2 items-start">
<div className="text-sm">Connected agents:</div>
<div className="flex gap-4 flex-wrap">
{agent.connectedAgents?.map((connectedAgentName) => (
<div key={connectedAgentName} className="bg-gray-100 border-1 border-gray-200 shadow-sm rounded-lg px-2 py-1 flex items-center gap-2">
<div>{connectedAgentName}</div>
<button
onClick={() => {
const newAgents = (agent.connectedAgents || []).filter((a) => a !== connectedAgentName);
handleUpdate({
...agent,
connectedAgents: newAgents
});
}}
className="bg-white rounded-md text-gray-500 hover:text-gray-800"
>
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
</svg>
</button>
</div>
))}
</div>
<Dropdown>
<DropdownTrigger>
<Button
variant="bordered"
size="sm"
startContent={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
</svg>}
>
Connect agent
</Button>
</DropdownTrigger>
<DropdownMenu onAction={(key) => handleUpdate({
...agent,
connectedAgents: [...(agent.connectedAgents || []), key as string]
})}>
{agents.filter((a) =>
a.name !== agent.name &&
!(agent.connectedAgents || []).includes(a.name) &&
!a.global
).map((a) => (
<DropdownItem key={a.name}>
<div>{a.name}</div>
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
</div>
<div className="flex flex-col gap-2 items-start">
<EditableField
label="Model:"
value={agent.model}
onChange={(value) => {
handleUpdate({
...agent,
model: value
});
}}
validate={(value) => {
if (value.length === 0) {
return { valid: false, errorMessage: "Model cannot be empty" };
}
return { valid: true };
}}
className="w-40"
/>
</div>
<div className="flex flex-col gap-2 items-start">
<div className="text-sm">Conversation control after turn:</div>
<Select
variant="bordered"
selectedKeys={[agent.controlType]}
size="sm"
onSelectionChange={(keys) => handleUpdate({
...agent,
controlType: keys.currentKey! as z.infer<typeof WorkflowAgent>['controlType']
})}
className="w-60"
>
<SelectItem key="retain" value="retain">Retain control</SelectItem>
<SelectItem key="relinquish_to_parent" value="relinquish_to_parent">Relinquish to parent</SelectItem>
<SelectItem key="relinquish_to_start" value="relinquish_to_start">Relinquish to &apos;start&apos; agent</SelectItem>
</Select>
</div>
</div>
</Pane>;
}

View file

@ -0,0 +1,93 @@
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@nextui-org/react";
import { WorkflowAgent } from "@/app/lib/types";
import { z } from "zod";
import { useRef, useEffect } from "react";
import { ActionButton, Pane } from "./pane";
export function AgentsList({
agents,
handleSelectAgent,
handleAddAgent,
handleToggleAgent,
selectedAgent,
handleSetMainAgent,
handleDeleteAgent,
startAgentName,
}: {
agents: z.infer<typeof WorkflowAgent>[];
handleSelectAgent: (name: string) => void;
handleAddAgent: (agent: Partial<z.infer<typeof WorkflowAgent>>) => void;
handleToggleAgent: (name: string) => void;
selectedAgent: string | null;
handleSetMainAgent: (name: string) => void;
handleDeleteAgent: (name: string) => void;
startAgentName: string | null;
}) {
const selectedAgentRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const selectedAgentIndex = agents.findIndex(agent => agent.name === selectedAgent);
if (selectedAgentIndex !== -1 && selectedAgentRef.current) {
selectedAgentRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [selectedAgent, agents]);
return <Pane title="Agents" actions={[
<ActionButton
key="add"
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7 7V5" />
</svg>}
onClick={() => handleAddAgent({})}
>
Add
</ActionButton>
]}>
<div className="overflow-auto flex flex-col justify-start">
{agents.map((agent, index) => (
<button
key={index}
ref={selectedAgent === agent.name ? selectedAgentRef : null}
onClick={() => handleSelectAgent(agent.name)}
className={`flex items-center justify-between rounded-md px-3 py-2 ${selectedAgent === agent.name ? 'bg-gray-200' : 'hover:bg-gray-100'}`}
>
<div className={`truncate ${agent.disabled ? 'text-gray-400' : ''}`}>{agent.name}</div>
<div className="flex items-center gap-2">
{startAgentName === agent.name && <div className="text-xs border bg-blue-500 text-white px-2 py-1 rounded-md">Start</div>}
<Dropdown key={agent.name}>
<DropdownTrigger>
<svg className="w-6 h-6 text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeWidth="3" d="M12 6h.01M12 12h.01M12 18h.01" />
</svg>
</DropdownTrigger>
<DropdownMenu
disabledKeys={[
...(!agent.toggleAble ? ['toggle'] : []),
...(agent.locked ? ['delete', 'set-main-agent'] : []),
...(startAgentName === agent.name ? ['set-main-agent', 'delete', 'toggle'] : []),
]}
onAction={(key) => {
switch (key) {
case 'set-main-agent':
handleSetMainAgent(agent.name);
break;
case 'delete':
handleDeleteAgent(agent.name);
break;
case 'toggle':
handleToggleAgent(agent.name);
break;
}
}}
>
<DropdownItem key="set-main-agent">Set as start agent</DropdownItem>
<DropdownItem key="toggle">{agent.disabled ? 'Enable' : 'Disable'}</DropdownItem>
<DropdownItem key="delete" className="text-danger">Delete</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</button>
))}
</div>
</Pane>;
}

View file

@ -0,0 +1,111 @@
"use client";
import { DataSource, Workflow, WithStringId } from "@/app/lib/types";
import { z } from "zod";
import { useCallback, useEffect, useState } from "react";
import { WorkflowEditor } from "./workflow_editor";
import { WorkflowSelector } from "./workflow_selector";
import { Spinner } from "@nextui-org/react";
import { cloneWorkflow, createWorkflow, fetchPublishedWorkflowId, fetchWorkflow, listSources } from "@/app/actions";
export function App({
projectId,
startWithWorkflowId,
}: {
projectId: string;
startWithWorkflowId: string | null;
}) {
const [selectorKey, setSelectorKey] = useState(0);
const [workflow, setWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
const [publishedWorkflowId, setPublishedWorkflowId] = useState<string | null>(null);
const [dataSources, setDataSources] = useState<WithStringId<z.infer<typeof DataSource>>[] | null>(null);
const [loading, setLoading] = useState(false);
const handleSelect = useCallback(async (workflowId: string) => {
setLoading(true);
const workflow = await fetchWorkflow(projectId, workflowId);
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
const dataSources = await listSources(projectId);
// Store the selected workflow ID in local storage
localStorage.setItem(`lastWorkflowId_${projectId}`, workflowId);
setWorkflow(workflow);
setPublishedWorkflowId(publishedWorkflowId);
setDataSources(dataSources);
setLoading(false);
}, [projectId]);
function handleShowSelector() {
// clear the last workflow id from local storage
localStorage.removeItem(`lastWorkflowId_${projectId}`);
setWorkflow(null);
}
async function handleCreateNewVersion() {
setLoading(true);
const workflow = await createWorkflow(projectId);
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
const dataSources = await listSources(projectId);
// Store the selected workflow ID in local storage
localStorage.setItem(`lastWorkflowId_${projectId}`, workflow._id);
setWorkflow(workflow);
setPublishedWorkflowId(publishedWorkflowId);
setDataSources(dataSources);
setLoading(false);
}
async function handleCloneVersion(workflowId: string) {
setLoading(true);
const workflow = await cloneWorkflow(projectId, workflowId);
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
const dataSources = await listSources(projectId);
// Store the selected workflow ID in local storage
localStorage.setItem(`lastWorkflowId_${projectId}`, workflow._id);
setWorkflow(workflow);
setPublishedWorkflowId(publishedWorkflowId);
setDataSources(dataSources);
setLoading(false);
}
// whenever workflow becomes null, increment selectorKey
useEffect(() => {
if (!workflow) {
setSelectorKey(s => s + 1);
}
}, [workflow]);
// Add this useEffect for initial load
useEffect(() => {
// if startWithWorkflowId is provided, use it
if (startWithWorkflowId) {
handleSelect(startWithWorkflowId);
return;
}
// Check localStorage first, fall back to lastWorkflowId prop
const storedWorkflowId = localStorage.getItem(`lastWorkflowId_${projectId}`);
if (storedWorkflowId) {
handleSelect(storedWorkflowId);
}
}, [handleSelect, projectId, startWithWorkflowId]);
// if workflow is null, show the selector
// else show workflow editor
return <>
{loading && <div className="flex items-center gap-1">
<Spinner size="sm" />
<div>Loading workflow...</div>
</div>}
{!loading && workflow == null && <WorkflowSelector
projectId={projectId}
key={selectorKey}
handleSelect={handleSelect}
handleCreateNewVersion={handleCreateNewVersion}
/>}
{!loading && workflow && (dataSources !== null) && <WorkflowEditor
key={workflow._id}
workflow={workflow}
dataSources={dataSources}
publishedWorkflowId={publishedWorkflowId}
handleShowSelector={handleShowSelector}
handleCloneVersion={handleCloneVersion}
/>}
</>
}

View file

@ -0,0 +1,497 @@
'use client';
import { Button, Textarea } from "@nextui-org/react";
import { ActionButton, Pane } from "./pane";
import { useEffect, useRef, useState, createContext, useContext, useCallback } from "react";
import { CopilotAssistantMessage, CopilotMessage, CopilotUserMessage, Workflow, CopilotChatContext, CopilotAssistantMessageActionPart } from "@/app/lib/types";
import { z } from "zod";
import { getCopilotResponse } from "@/app/actions";
import { Action } from "./copilot_actions";
import clsx from "clsx";
import { Action as WorkflowDispatch } from "./workflow_editor";
import MarkdownContent from "@/app/lib/components/markdown-content";
const CopilotContext = createContext<{
workflow: z.infer<typeof Workflow> | null;
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void;
appliedChanges: Record<string, boolean>;
}>({ workflow: null, handleApplyChange: () => {}, appliedChanges: {} });
export function getAppliedChangeKey(messageIndex: number, actionIndex: number, field: string) {
return `${messageIndex}-${actionIndex}-${field}`;
}
function AnimatedEllipsis() {
const [dots, setDots] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setDots(prev => prev === 3 ? 0 : prev + 1);
}, 500);
return () => clearInterval(interval);
}, []);
return <span className="inline-block w-8">{'.'.repeat(dots)}</span>;
}
function ComposeBox({
handleUserMessage,
messages,
}: {
handleUserMessage: (prompt: string) => void;
messages: z.infer<typeof CopilotMessage>[];
}) {
const [input, setInput] = useState('');
const inputRef = useRef<HTMLTextAreaElement>(null);
function handleInput() {
const prompt = input.trim();
if (!prompt) {
return;
}
setInput('');
handleUserMessage(prompt);
}
function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleInput();
}
}
// focus on the input field
// only when there is at least one message
useEffect(() => {
if (messages.length > 0) {
inputRef.current?.focus();
}
}, [messages]);
return <Textarea
required
ref={inputRef}
variant="bordered"
placeholder="Enter message..."
minRows={3}
maxRows={5}
value={input}
onValueChange={setInput}
onKeyDown={handleInputKeyDown}
className="w-full"
endContent={<Button
isIconOnly
onClick={handleInput}
className="bg-gray-100"
>
<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M12 6v13m0-13 4 4m-4-4-4 4" />
</svg>
</Button>}
/>
}
function RawJsonResponse({
message,
}: {
message: z.infer<typeof CopilotAssistantMessage>;
}) {
const [expanded, setExpanded] = useState(false);
return <div className="flex flex-col gap-2">
<button
className="w-4 text-gray-300 hover:text-gray-600"
onClick={() => setExpanded(!expanded)}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-rectangle-ellipsis"><rect width="20" height="12" x="2" y="6" rx="2" /><path d="M12 12h.01" /><path d="M17 12h.01" /><path d="M7 12h.01" /></svg>
</button>
<pre className={clsx("text-sm bg-gray-50 border border-gray-200 rounded-sm p-2 overflow-x-auto", {
'hidden': !expanded,
})}>
{JSON.stringify(message.content, null, 2)}
</pre>
</div>;
}
function AssistantMessage({
message,
msgIndex,
stale,
}: {
message: z.infer<typeof CopilotAssistantMessage>;
msgIndex: number;
stale: boolean;
}) {
const { workflow, handleApplyChange, appliedChanges } = useContext(CopilotContext);
if (!workflow) {
return <></>;
}
return <div className="flex flex-col gap-2 mb-8">
<RawJsonResponse message={message} />
<div className="flex flex-col gap-3">
{message.content.response.map((part, index) => {
if (part.type === "text") {
return <div key={index}>
<MarkdownContent content={part.content} />
</div>;
} else if (part.type === "action") {
return <Action
key={index}
msgIndex={msgIndex}
actionIndex={index}
action={part.content}
workflow={workflow}
handleApplyChange={handleApplyChange}
appliedChanges={appliedChanges}
stale={stale}
/>;
}
})}
</div>
</div>;
}
function UserMessage({
message,
}: {
message: z.infer<typeof CopilotUserMessage>;
}) {
return <div className="bg-gray-50 border border-gray-200 rounded-sm px-2">
<MarkdownContent content={message.content} />
</div>
}
function App({
projectId,
workflow,
dispatch,
chatContext=undefined,
}: {
projectId: string;
workflow: z.infer<typeof Workflow>;
dispatch: (action: WorkflowDispatch) => void;
chatContext?: z.infer<typeof CopilotChatContext>;
}) {
const [messages, setMessages] = useState<z.infer<typeof CopilotMessage>[]>([]);
const [loadingResponse, setLoadingResponse] = useState(false);
const [loadingMessage, setLoadingMessage] = useState("Thinking...");
const [responseError, setResponseError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [appliedChanges, setAppliedChanges] = useState<Record<string, boolean>>({});
const [discardContext, setDiscardContext] = useState(false);
// Cycle through loading messages until reaching the last one
useEffect(() => {
setLoadingMessage("Thinking");
if (!loadingResponse) return;
const loadingMessages = [
"Thinking",
"Planning",
"Generating",
];
let messageIndex = 0;
const interval = setInterval(() => {
if (messageIndex < loadingMessages.length - 1) {
messageIndex++;
setLoadingMessage(loadingMessages[messageIndex]);
}
}, 4000);
return () => clearInterval(interval);
}, [loadingResponse, messages]);
// Reset discardContext when chatContext changes
useEffect(() => {
setDiscardContext(false);
}, [chatContext]);
// Get the effective context based on user preference
const effectiveContext = discardContext ? null : chatContext;
function handleUserMessage(prompt: string) {
setMessages([...messages, {
role: 'user',
content: prompt,
}]);
}
const handleApplyChange = useCallback((
messageIndex: number,
actionIndex: number,
field?: string
) => {
// validate
console.log('apply change', messageIndex, actionIndex, field);
const msg = messages[messageIndex];
if (!msg) {
console.log('no message');
return;
}
if (msg.role !== 'assistant') {
console.log('not assistant');
return;
}
const action = msg.content.response[actionIndex].content as z.infer<typeof CopilotAssistantMessageActionPart>['content'];
if (!action) {
console.log('no action');
return;
}
console.log('reached here');
if (action.action === 'create_new') {
switch (action.config_type) {
case 'agent':
dispatch({
type: 'add_agent',
agent: {
name: action.name,
...action.config_changes
}
});
break;
case 'tool':
dispatch({
type: 'add_tool',
tool: {
name: action.name,
...action.config_changes
}
});
break;
case 'prompt':
dispatch({
type: 'add_prompt',
prompt: {
name: action.name,
...action.config_changes
}
});
break;
}
const appliedKeys = Object.keys(action.config_changes).reduce((acc, key) => {
acc[getAppliedChangeKey(messageIndex, actionIndex, key)] = true;
return acc;
}, {} as Record<string, boolean>);
setAppliedChanges({
...appliedChanges,
...appliedKeys,
});
} else if (action.action === 'edit') {
const changes = field
? { [field]: action.config_changes[field] }
: action.config_changes;
switch (action.config_type) {
case 'agent':
dispatch({
type: 'update_agent',
name: action.name,
agent: changes
});
break;
case 'tool':
dispatch({
type: 'update_tool',
name: action.name,
tool: changes
});
break;
case 'prompt':
dispatch({
type: 'update_prompt',
name: action.name,
prompt: changes
});
break;
}
const appliedKeys = Object.keys(changes).reduce((acc, key) => {
acc[getAppliedChangeKey(messageIndex, actionIndex, key)] = true;
return acc;
}, {} as Record<string, boolean>);
setAppliedChanges({
...appliedChanges,
...appliedKeys,
});
}
}, [dispatch, appliedChanges, messages]);
// get copilot response
useEffect(() => {
let ignore = false;
async function process() {
setLoadingResponse(true);
setResponseError(null);
try {
const copilotMessage = await getCopilotResponse(
projectId,
messages,
workflow,
effectiveContext || null,
);
if (ignore) {
return;
}
setMessages([...messages, copilotMessage]);
} catch (err) {
if (!ignore) {
setResponseError(`Failed to get copilot response: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
} finally {
setLoadingResponse(false);
}
}
// if no messages, return
if (messages.length === 0) {
return;
}
// if last message is not from role user
// or tool, return
const last = messages[messages.length - 1];
if (responseError) {
return;
}
if (last.role !== 'user') {
return;
}
process();
return () => {
ignore = true;
};
}, [messages, projectId, responseError, workflow, effectiveContext]);
// scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages, loadingResponse]);
return <div className="h-full flex flex-col">
<CopilotContext.Provider value={{ workflow, handleApplyChange, appliedChanges }}>
<div className="grow flex flex-col gap-2 overflow-auto px-2">
{messages.map((m, index) => {
// Calculate if this assistant message is stale
const isStale = m.role === 'assistant' && messages.slice(index + 1).some(
laterMsg => laterMsg.role === 'assistant' &&
'response' in laterMsg.content &&
laterMsg.content.response.filter(part => part.type === 'action').length > 0
);
return <>
{m.role === 'user' && (
<UserMessage
key={index}
message={m}
/>
)}
{m.role === 'assistant' && (
<AssistantMessage
key={index}
message={m}
msgIndex={index}
stale={isStale}
/>
)}
</>;
})}
{loadingResponse && <div className="p-2 flex items-center animate-pulse text-gray-600">
<div>
{loadingMessage}
</div>
<AnimatedEllipsis />
</div>}
<div ref={messagesEndRef} />
</div>
<div className="shrink-0">
{responseError && (
<div className="max-w-[768px] mx-auto mb-4 p-2 bg-red-50 border border-red-200 rounded-lg flex gap-2 justify-between items-center">
<p className="text-red-600">{responseError}</p>
<Button
size="sm"
color="danger"
onClick={() => {
setResponseError(null);
}}
>
Retry
</Button>
</div>
)}
{effectiveContext && <div className="flex items-start">
<div className="flex items-center gap-1 bg-gray-100 text-sm px-2 py-1 rounded-sm shadow-sm mb-2">
<div>
{effectiveContext.type === 'chat' && "Chat"}
{effectiveContext.type === 'agent' && `Agent: ${effectiveContext.name}`}
{effectiveContext.type === 'tool' && `Tool: ${effectiveContext.name}`}
{effectiveContext.type === 'prompt' && `Prompt: ${effectiveContext.name}`}
</div>
<button
className="text-gray-500 hover:text-gray-600"
onClick={() => setDiscardContext(true)}
>
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>}
<ComposeBox
handleUserMessage={handleUserMessage}
messages={messages}
/>
</div>
</CopilotContext.Provider>
</div>;
}
export function Copilot({
projectId,
workflow,
chatContext=undefined,
dispatch,
}: {
projectId: string;
workflow: z.infer<typeof Workflow>;
chatContext?: z.infer<typeof CopilotChatContext>;
dispatch: (action: WorkflowDispatch) => void;
}) {
const [key, setKey] = useState(0);
function handleNewChat() {
setKey(key + 1);
}
return (
<Pane fancy title="Copilot" actions={[
<ActionButton
key="ask"
primary
icon={
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7 7V5" />
</svg>
}
onClick={handleNewChat}
>
Ask
</ActionButton>
]}>
<App
key={key}
projectId={projectId}
workflow={workflow}
dispatch={dispatch}
chatContext={chatContext}
/>
</Pane>
);
}

View file

@ -0,0 +1,298 @@
'use client';
import { createContext, useContext, useState } from "react";
import clsx from "clsx";
import { z } from "zod";
import { Workflow, CopilotAssistantMessage, CopilotAssistantMessageActionPart } from "@/app/lib/types";
import { PreviewModalProvider, usePreviewModal } from './preview-modal';
import { getAppliedChangeKey } from "./copilot";
const ActionContext = createContext<{
msgIndex: number;
actionIndex: number;
action: z.infer<typeof CopilotAssistantMessageActionPart>['content'] | null;
workflow: z.infer<typeof Workflow> | null;
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void;
appliedFields: string[];
stale: boolean;
}>({ msgIndex: 0, actionIndex: 0, action: null, workflow: null, handleApplyChange: () => {}, appliedFields: [], stale: false });
export function Action({
msgIndex,
actionIndex,
action,
workflow,
handleApplyChange,
appliedChanges,
stale,
}: {
msgIndex: number;
actionIndex: number;
action: z.infer<typeof CopilotAssistantMessageActionPart>['content'];
workflow: z.infer<typeof Workflow>;
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void;
appliedChanges: Record<string, boolean>;
stale: boolean;
}) {
const [expanded, setExpanded] = useState(Object.entries(action.config_changes).length <= 2);
const changes = Object.entries(action.config_changes).slice(0, expanded ? undefined : 2);
// determine whether all changes contained in this action are applied
const appliedFields = Object.keys(action.config_changes).filter(key => appliedChanges[getAppliedChangeKey(msgIndex, actionIndex, key)]);
console.log('appliedFields', appliedFields);
return <div className={clsx('flex flex-col rounded-sm border shadow-sm', {
'bg-blue-50 border-blue-200': action.action === 'create_new',
'bg-amber-50 border-amber-200': action.action === 'edit',
'bg-gray-50 border-gray-200': stale,
})}>
<ActionContext.Provider value={{ msgIndex, actionIndex, action, workflow, handleApplyChange, appliedFields, stale }}>
<ActionHeader />
<PreviewModalProvider>
<ActionBody>
{changes.map(([key, value]) => {
return <ActionField key={key} field={key} />
})}
</ActionBody>
</PreviewModalProvider>
{Object.entries(action.config_changes).length > 2 && <button className={clsx('flex rounded-b-sm flex-col items-center justify-center', {
'bg-blue-100 hover:bg-blue-200 text-blue-600': action.action === 'create_new',
'bg-amber-100 hover:bg-amber-200 text-amber-600': action.action === 'edit',
'bg-gray-100 hover:bg-gray-200 text-gray-600': stale,
})} onClick={() => setExpanded(!expanded)}>
{expanded ? (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-chevrons-up"><path d="m17 11-5-5-5 5" /><path d="m17 18-5-5-5 5" /></svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-chevrons-down"><path d="m7 6 5 5 5-5" /><path d="m7 13 5 5 5-5" /></svg>
)}
</button>}
</ActionContext.Provider>
</div>;
}
export function ActionHeader() {
const { msgIndex, actionIndex, action, workflow, handleApplyChange, appliedFields, stale } = useContext(ActionContext);
if (!action || !workflow) return null;
const targetType = action.config_type === 'tool' ? 'tool' : action.config_type === 'agent' ? 'agent' : 'prompt';
const change = action.action === 'create_new' ? 'Create' : 'Edit';
// determine whether all changes contained in this action are applied
const allApplied = Object.keys(action.config_changes).every(key => appliedFields.includes(key));
// generate apply change function
const applyChangeHandler = () => {
handleApplyChange(msgIndex, actionIndex);
}
return <div className={clsx('flex justify-between items-center px-2 py-1 rounded-t-sm', {
'bg-blue-100': action.action === 'create_new',
'bg-amber-100': action.action === 'edit',
'bg-gray-100': stale,
})}>
<div className={clsx('text-sm truncate', {
'text-blue-600': action.action === 'create_new',
'text-amber-600': action.action === 'edit',
'text-gray-600': stale,
})}>{`${change} ${targetType}`}: <span className="font-medium">{action.name}</span></div>
<button className={clsx('flex gap-1 items-center text-sm hover:text-black', {
'text-blue-600': action.action === 'create_new',
'text-amber-600': action.action === 'edit',
'text-green-600': allApplied,
'text-gray-600': stale,
})}
onClick={applyChangeHandler}
disabled={stale || allApplied}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-check-check"><path d="M18 6 7 17l-5-5" /><path d="m22 10-7.5 7.5L13 16" /></svg>
{!allApplied && <div className="font-medium">Apply</div>}
</button>
</div>;
}
export function ActionBody({
children,
}: {
children: React.ReactNode;
}) {
return <div className="flex flex-col gap-2 p-2">{children}</div>;
}
export function ActionField({
field,
}: {
field: string;
}) {
const { msgIndex, actionIndex, action, workflow, handleApplyChange, appliedFields, stale } = useContext(ActionContext);
const { showPreview } = usePreviewModal();
if (!action || !workflow) return null;
// determine whether this field is applied
const applied = appliedFields.includes(field);
const newValue = action.config_changes[field];
// Get the old value if this is an edit action
let oldValue = undefined;
if (action.action === 'edit') {
if (action.config_type === 'tool') {
// Find the tool in the workflow
const tool = workflow.tools.find(t => t.name === action.name);
if (tool) {
oldValue = tool[field as keyof typeof tool];
}
} else if (action.config_type === 'agent') {
// Find the agent in the workflow
const agent = workflow.agents.find(a => a.name === action.name);
if (agent) {
oldValue = agent[field as keyof typeof agent];
}
} else if (action.config_type === 'prompt') {
// Find the prompt in the workflow
const prompt = workflow.prompts.find(p => p.name === action.name);
if (prompt) {
oldValue = prompt[field as keyof typeof prompt];
}
}
}
// if edit type of action, preview is enabled
const previewCondition = action.action === 'edit' ||
(action.config_type === 'agent' && field === 'instructions');
// enable markdown preview for some fields
const markdownPreviewCondition = (action.config_type === 'agent' && field === 'instructions') ||
(action.config_type === 'agent' && field === 'examples') ||
(action.config_type === 'prompt' && field === 'prompt') ||
(action.config_type === 'tool' && field === 'description');
// generate preview modal function
const previewModalHandler = () => {
if (previewCondition) {
showPreview(
oldValue ? (typeof oldValue === 'string' ? oldValue : JSON.stringify(oldValue)) : undefined,
(typeof newValue === 'string' ? newValue : JSON.stringify(newValue)),
markdownPreviewCondition,
`${action.name} - ${field}`
);
}
}
// generate apply change function
const applyChangeHandler = () => {
handleApplyChange(msgIndex, actionIndex, field);
}
return <div className="flex flex-col bg-white rounded-sm">
<div className="flex justify-between items-start">
<div className={clsx('text-xs font-semibold px-2 py-1', {
'text-blue-600': action.action === 'create_new',
'text-amber-600': action.action === 'edit',
'text-gray-600': stale,
})}>{field}</div>
{previewCondition && <div className="flex gap-4 items-center bg-gray-50 rounded-bl-sm rounded-tr-sm px-2 py-1">
<button
className="text-gray-500 hover:text-black"
onClick={previewModalHandler}
>
<svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeWidth="1.5" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z" />
<path stroke="currentColor" strokeWidth="1.5" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</button>
{action.action === 'edit' && <button
className={clsx("text-gray-500 hover:text-black", {
'text-green-600': applied,
'text-gray-600': stale,
})}
onClick={applyChangeHandler}
disabled={stale || applied}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-check"><path d="M20 6 9 17l-5-5" /></svg>
</button>}
</div>}
</div>
<div className="px-2 pb-1">
<div className="text-sm italic truncate">
{JSON.stringify(newValue)}
</div>
</div>
</div>;
}
// function ActionToolParamsView({
// params,
// }: {
// params: z.infer<typeof Workflow>['tools'][number]['parameters'];
// }) {
// const required = params?.required || [];
// return <ActionField label="parameters">
// <div className="flex flex-col gap-2 text-sm">
// {Object.entries(params?.properties || {}).map(([paramName, paramConfig]) => {
// return <div className="flex flex-col gap-1">
// <div className="flex gap-1 items-center">
// <svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
// <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14" />
// </svg>
// <div>{paramName}{required.includes(paramName) && <sup>*</sup>}</div>
// <div className="text-gray-500">{paramConfig.type}</div>
// </div>
// <div className="flex gap-1 ml-4">
// <div className="text-gray-500 italic">{paramConfig.description}</div>
// </div>
// </div>;
// })}
// </div>
// </ActionField>;
// }
// function ActionAgentToolsView({
// action,
// tools,
// }: {
// action: z.infer<typeof CopilotAssistantMessage>['content']['Actions'][number];
// tools: z.infer<typeof Workflow>['agents'][number]['tools'];
// }) {
// const { workflow } = useContext(CopilotContext);
// if (!workflow) {
// return <></>;
// }
// // find the agent in the workflow
// const agent = workflow.agents.find((agent) => agent.name === action.name);
// if (!agent) {
// return <></>;
// }
// // find the tools that were removed
// const removedTools = agent.tools.filter((tool) => !tools.includes(tool));
// return <ActionField label="tools">
// {removedTools.length > 0 && <div className="flex flex-col gap-1 text-sm">
// <div className="text-gray-500 italic">The following tools were removed:</div>
// <div className="flex flex-col gap-1">
// {removedTools.map((tool) => {
// return <div className="flex gap-1 items-center">
// <svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
// <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14" />
// </svg>
// <div>{tool}</div>
// </div>;
// })}
// </div>
// </div>}
// <div className="flex flex-col gap-1 text-sm">
// <div className="text-gray-500 italic">The following tools were added:</div>
// <div className="flex flex-col gap-1">
// {tools.map((tool) => {
// return <div className="flex gap-1 items-center">
// <svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
// <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14" />
// </svg>
// <div>{tool}</div>
// </div>;
// })}
// </div>
// </div>
// </ActionField>;
// }

View file

@ -0,0 +1,51 @@
import { Metadata } from "next";
import { agentWorkflowsCollection, dataSourcesCollection, projectsCollection } from "@/app/lib/mongodb";
import { App } from "./app";
import { baseWorkflow } from "@/app/lib/utils";
export const metadata: Metadata = {
title: "Workflow"
}
export default async function Page({
params,
}: {
params: { projectId: string };
}) {
let startWithWorkflowId = null;
const count = await agentWorkflowsCollection.countDocuments({
projectId: params.projectId,
});
if (count === 0) {
// get the next workflow number
const doc = await projectsCollection.findOneAndUpdate({
_id: params.projectId,
}, {
$inc: {
nextWorkflowNumber: 1,
},
}, {
returnDocument: 'after'
});
if (!doc) {
throw new Error('Project not found');
}
const nextWorkflowNumber = doc.nextWorkflowNumber;
// create the workflow
const workflow = {
...baseWorkflow,
projectId: params.projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
name: `Version ${nextWorkflowNumber}`,
};
const { insertedId } = await agentWorkflowsCollection.insertOne(workflow);
startWithWorkflowId = insertedId.toString();
}
return <App
projectId={params.projectId}
startWithWorkflowId={startWithWorkflowId}
/>;
}

View file

@ -0,0 +1,48 @@
export function Pane({
title,
actions,
children,
fancy = false,
}: {
title: React.ReactNode;
actions: React.ReactNode[];
children: React.ReactNode;
fancy?: boolean;
}) {
return <div className={`h-full flex flex-col overflow-auto border rounded-md ${fancy ? 'border-blue-200' : 'border-gray-200'}`}>
<div className={`shrink-0 flex justify-between items-center gap-2 px-2 py-1 bg-gray-50 rounded-t-md ${fancy ? 'bg-blue-50' : ''}`}>
<div className={`text-sm ${fancy ? 'text-blue-600' : 'text-gray-600'} uppercase font-semibold`}>
{title}
</div>
<div className="rounded-md hover:text-gray-800 px-2 py-1 text-gray-600 text-sm flex items-center gap-1">
{actions}
</div>
</div>
<div className="grow overflow-auto flex flex-col justify-start p-2">
{children}
</div>
</div>;
}
export function ActionButton({
icon = null,
children,
onClick,
disabled = false,
primary = false,
}: {
icon?: React.ReactNode;
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
primary?: boolean;
}) {
return <button
disabled={disabled}
className={`rounded-md hover:text-gray-800 px-2 py-1 ${primary ? 'text-blue-600' : 'text-gray-600'} text-sm flex items-center gap-1 disabled:text-gray-300`}
onClick={onClick}
>
{icon}
{children}
</button>;
}

View file

@ -0,0 +1,144 @@
import { createContext, useContext, useEffect, useState } from "react";
import clsx from "clsx";
import MarkdownContent from "@/app/lib/components/markdown-content";
import React, { PureComponent } from 'react';
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued';
// Create the context type
export type PreviewModalContextType = {
showPreview: (
oldValue: string | undefined,
newValue: string,
markdown: boolean,
title: string
) => void;
};
// Create the context
export const PreviewModalContext = createContext<PreviewModalContextType>({
showPreview: () => {}
});
// Export the hook for easy usage
export const usePreviewModal = () => useContext(PreviewModalContext);
// Create the provider component
export function PreviewModalProvider({ children }: { children: React.ReactNode }) {
const [modalProps, setModalProps] = useState<{
oldValue?: string;
newValue: string;
markdown: boolean;
title: string;
isOpen: boolean;
}>({
newValue: '',
markdown: false,
title: '',
isOpen: false
});
// Handle Esc key
useEffect(() => {
const handleEsc = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setModalProps(prev => ({ ...prev, isOpen: false }));
}
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, []);
const showPreview = (oldValue: string | undefined, newValue: string, markdown: boolean, title: string) => {
setModalProps({ oldValue, newValue, markdown, title, isOpen: true });
};
return (
<PreviewModalContext.Provider value={{ showPreview }}>
{children}
{modalProps.isOpen && (
<PreviewModal
{...modalProps}
onClose={() => setModalProps(prev => ({ ...prev, isOpen: false }))}
/>
)}
</PreviewModalContext.Provider>
);
}
// The modal component
function PreviewModal({
oldValue = undefined,
newValue,
markdown = false,
title,
onClose,
}: {
oldValue?: string | undefined;
newValue: string;
markdown?: boolean;
title: string;
onClose: () => void;
}) {
const buttonLabel = oldValue === undefined ? 'Preview' : 'Diff';
const [view, setView] = useState<'preview' | 'markdown'>('preview');
console.log(oldValue, newValue);
return <div className="fixed left-0 top-0 w-full h-full bg-gray-500/50 backdrop-blur-sm flex justify-center items-center z-50">
<div className="bg-gray-100 rounded-md p-2 flex flex-col w-[90%] h-[90%] max-w-7xl max-h-[800px]">
<button className="self-end text-gray-500 hover:text-gray-700 flex items-center gap-1"
onClick={onClose}
>
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
</svg>
<div className="text-sm">Close</div>
</button>
<div className="flex flex-col overflow-auto">
<div className="flex justify-between items-center">
<div className="text-md font-semibold">{title}</div>
<div className="flex items-center">
<button className={clsx("text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded-t-md", {
'bg-white': view === 'preview',
})} onClick={() => setView('preview')}>{buttonLabel}</button>
{markdown && <button className={clsx("text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded-t-md", {
'bg-white': view === 'markdown',
})} onClick={() => setView('markdown')}>Markdown</button>}
</div>
</div>
<div className="bg-white rounded-md grow overflow-auto">
<div className="h-full flex flex-col overflow-auto">
{view === 'preview' && <div className="flex gap-1 overflow-auto text-sm">
{oldValue !== undefined && <ReactDiffViewer
oldValue={oldValue}
newValue={newValue}
splitView={true}
compareMethod={DiffMethod.WORDS_WITH_SPACE}
/>}
{oldValue === undefined && <pre className="p-2 overflow-auto">{newValue}</pre>}
</div>}
{view === 'markdown' && <div className="flex gap-1">
{oldValue !== undefined && <div className="w-1/2 flex flex-col border-r-2 border-gray-200 overflow-auto">
<div className="text-gray-800 font-semibold italic text-sm px-2 py-1 border-b-1 border-gray-200">Old</div>
<div className="p-2 overflow-auto">
<MarkdownContent
content={oldValue}
/>
</div>
</div>}
<div className={clsx("flex flex-col", {
'w-1/2': oldValue !== undefined
})}>
{oldValue !== undefined && <div className="text-gray-800 font-semibold italic text-sm px-2 py-1 border-b-1 border-gray-200">New</div>}
<div className="p-2 overflow-auto">
<MarkdownContent
content={newValue}
/>
</div>
</div>
</div>}
</div>
</div>
</div>
</div>
</div>;
}

View file

@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { WorkflowPrompt } from "@/app/lib/types";
import { Input, Textarea } from "@nextui-org/react";
import { z } from "zod";
import MarkdownContent from "@/app/lib/components/markdown-content";
import { ActionButton, Pane } from "./pane";
import { EditableField } from "@/app/lib/components/editable-field";
export function PromptConfig({
prompt,
usedPromptNames,
handleUpdate,
handleClose,
}: {
prompt: z.infer<typeof WorkflowPrompt>,
usedPromptNames: Set<string>,
handleUpdate: (prompt: z.infer<typeof WorkflowPrompt>) => void,
handleClose: () => void,
}) {
return <Pane title={prompt.name} actions={[
<ActionButton
key="close"
onClick={handleClose}
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
</svg>}
>
Close
</ActionButton>
]}>
<div className="flex flex-col gap-4">
{prompt.type === "base_prompt" && (
<EditableField
label="Name"
value={prompt.name}
onChange={(value) => {
handleUpdate({
...prompt,
name: value
});
}}
placeholder="Enter prompt name"
validate={(value) => {
if (value.length === 0) {
return { valid: false, errorMessage: "Name cannot be empty" };
}
if (usedPromptNames.has(value)) {
return { valid: false, errorMessage: "This name is already taken" };
}
return { valid: true };
}}
/>
)}
<EditableField
value={prompt.prompt}
onChange={(value) => {
handleUpdate({
...prompt,
prompt: value
});
}}
placeholder="Edit prompt here..."
markdown
label="Prompt"
multiline
/>
</div>
</Pane>;
}

View file

@ -0,0 +1,74 @@
import { z } from "zod";
import { WorkflowPrompt } from "@/app/lib/types";
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@nextui-org/react";
import { useRef, useEffect } from "react";
import { ActionButton, Pane } from "./pane";
export function PromptsList({
prompts,
handleSelectPrompt,
handleAddPrompt,
selectedPrompt,
handleDeletePrompt,
}: {
prompts: z.infer<typeof WorkflowPrompt>[];
handleSelectPrompt: (name: string) => void;
handleAddPrompt: (prompt: Partial<z.infer<typeof WorkflowPrompt>>) => void;
selectedPrompt: string | null;
handleDeletePrompt: (name: string) => void;
}) {
const selectedPromptRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const selectedPromptIndex = prompts.findIndex(prompt => prompt.name === selectedPrompt);
if (selectedPromptIndex !== -1 && selectedPromptRef.current) {
selectedPromptRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [selectedPrompt, prompts]);
return <Pane title="Prompts" actions={[
<ActionButton
key="add"
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7 7V5" />
</svg>}
onClick={() => handleAddPrompt({})}
>
Add
</ActionButton>
]}>
<div className="overflow-auto flex flex-col justify-start">
{prompts.map((prompt, index) => (
<button
key={index}
ref={selectedPrompt === prompt.name ? selectedPromptRef : null}
onClick={() => handleSelectPrompt(prompt.name)}
className={`flex items-center justify-between rounded-md px-3 py-2 ${selectedPrompt === prompt.name ? 'bg-gray-200' : 'hover:bg-gray-100'}`}
>
<div className="flex items-center gap-2">
{prompt.type === 'style_prompt' && <svg className="w-5 h-5 text-gray-500" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeWidth="1" d="M20 6H10m0 0a2 2 0 1 0-4 0m4 0a2 2 0 1 1-4 0m0 0H4m16 6h-2m0 0a2 2 0 1 0-4 0m4 0a2 2 0 1 1-4 0m0 0H4m16 6H10m0 0a2 2 0 1 0-4 0m4 0a2 2 0 1 1-4 0m0 0H4" />
</svg>}
<div className="truncate">{prompt.name}</div>
</div>
<Dropdown key={prompt.name}>
<DropdownTrigger>
<svg className="w-6 h-6 text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeWidth="3" d="M12 6h.01M12 12h.01M12 18h.01" />
</svg>
</DropdownTrigger>
<DropdownMenu
onAction={(key) => {
if (key === 'delete') {
handleDeletePrompt(prompt.name);
}
}}
>
<DropdownItem key="delete" className="text-danger">Delete</DropdownItem>
</DropdownMenu>
</Dropdown>
</button>
))}
</div>
</Pane>;
}

View file

@ -0,0 +1,10 @@
import { RadioIcon } from "lucide-react";
export function PublishedBadge() {
return (
<div className="bg-green-500/10 rounded-md px-2 py-1 flex items-center gap-1">
<RadioIcon size={16} className="text-green-500" />
<div className="text-green-500 text-xs font-medium uppercase">Live</div>
</div>
);
}

View file

@ -0,0 +1,236 @@
"use client";
import { WorkflowTool } from "@/app/lib/types";
import { Button, Select, SelectItem, Switch } from "@nextui-org/react";
import { z } from "zod";
import { ActionButton, Pane } from "./pane";
import { EditableField } from "@/app/lib/components/editable-field";
export function ToolConfig({
tool,
usedToolNames,
handleUpdate,
handleClose
}: {
tool: z.infer<typeof WorkflowTool>,
usedToolNames: Set<string>,
handleUpdate: (tool: z.infer<typeof WorkflowTool>) => void,
handleClose: () => void
}) {
return (
<Pane title={tool.name} actions={[
<ActionButton
key="close"
onClick={handleClose}
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
</svg>}
>
Close
</ActionButton>
]}>
<div className="flex flex-col gap-4">
<EditableField
label="Name"
value={tool.name}
onChange={(value) => handleUpdate({
...tool,
name: value
})}
validate={(value) => {
if (value.length === 0) {
return { valid: false, errorMessage: "Name cannot be empty" };
}
if (usedToolNames.has(value)) {
return { valid: false, errorMessage: "Tool name already exists" };
}
return { valid: true };
}}
/>
<EditableField
label="Description"
value={tool.description}
onChange={(value) => handleUpdate({
...tool,
description: value
})}
placeholder="Describe what this tool does..."
/>
<div className="flex items-center gap-2">
<Switch
size="sm"
isSelected={tool.mockInPlayground ?? false}
onValueChange={(value) => handleUpdate({
...tool,
mockInPlayground: value
})}
/>
<span>Mock tool in Playground</span>
</div>
<div className="flex flex-col gap-4 w-full">
<div className="text-sm">Parameters:</div>
{Object.entries(tool.parameters?.properties || {}).map(([paramName, param], index) => (
<div key={index} className="border border-gray-300 rounded p-4">
<div className="flex flex-col gap-4">
<EditableField
label="Parameter Name"
value={paramName}
onChange={(newName) => {
if (newName && newName !== paramName) {
const newProperties = { ...tool.parameters!.properties };
newProperties[newName] = newProperties[paramName];
delete newProperties[paramName];
handleUpdate({
...tool,
parameters: {
...tool.parameters!,
properties: newProperties,
required: tool.parameters!.required?.map(
name => name === paramName ? newName : name
) || []
}
});
}
}}
/>
<Select
label="Type"
labelPlacement="outside"
variant="bordered"
selectedKeys={new Set([param.type])}
onSelectionChange={(keys) => {
const newProperties = { ...tool.parameters!.properties };
newProperties[paramName] = {
...newProperties[paramName],
type: Array.from(keys)[0] as string
};
handleUpdate({
...tool,
parameters: {
...tool.parameters!,
properties: newProperties
}
});
}}
>
{['string', 'number', 'boolean', 'array', 'object'].map(type => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</Select>
<EditableField
label="Description"
value={param.description}
onChange={(desc) => {
const newProperties = { ...tool.parameters!.properties };
newProperties[paramName] = {
...newProperties[paramName],
description: desc
};
handleUpdate({
...tool,
parameters: {
...tool.parameters!,
properties: newProperties
}
});
}}
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch
size="sm"
isSelected={tool.parameters?.required?.includes(paramName)}
onValueChange={() => {
const required = [...(tool.parameters?.required || [])];
const index = required.indexOf(paramName);
if (index === -1) {
required.push(paramName);
} else {
required.splice(index, 1);
}
handleUpdate({
...tool,
parameters: {
...tool.parameters!,
required
}
});
}}
/>
<span>Required</span>
</div>
<Button
variant="bordered"
isIconOnly
onClick={() => {
const newProperties = { ...tool.parameters!.properties };
delete newProperties[paramName];
handleUpdate({
...tool,
parameters: {
...tool.parameters!,
properties: newProperties,
required: tool.parameters!.required?.filter(
name => name !== paramName
) || []
}
});
}}
>
<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z" />
</svg>
</Button>
</div>
</div>
</div>
))}
<div className="flex justify-end items-center">
<Button
variant="bordered"
startContent={<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
</svg>}
onClick={() => {
const newParamName = `param${Object.keys(tool.parameters?.properties || {}).length + 1}`;
const newProperties = {
...(tool.parameters?.properties || {}),
[newParamName]: {
type: 'string',
description: ''
}
};
handleUpdate({
...tool,
parameters: {
type: 'object',
properties: newProperties,
required: [...(tool.parameters?.required || []), newParamName]
}
});
}}
>
Add Parameter
</Button>
</div>
</div>
</div>
</Pane>
);
}

View file

@ -0,0 +1,71 @@
import { z } from "zod";
import { AgenticAPITool } from "@/app/lib/types";
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@nextui-org/react";
import { useRef, useEffect } from "react";
import { ActionButton, Pane } from "./pane";
export function ToolsList({
tools,
handleSelectTool,
handleAddTool,
selectedTool,
handleDeleteTool,
}: {
tools: z.infer<typeof AgenticAPITool>[];
handleSelectTool: (name: string) => void;
handleAddTool: (tool: Partial<z.infer<typeof AgenticAPITool>>) => void;
selectedTool: string | null;
handleDeleteTool: (name: string) => void;
}) {
const selectedToolRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const selectedToolIndex = tools.findIndex(tool => tool.name === selectedTool);
if (selectedToolIndex !== -1 && selectedToolRef.current) {
selectedToolRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [selectedTool, tools]);
return <Pane title="Tools" actions={[
<ActionButton
key="add"
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7 7V5" />
</svg>}
onClick={() => handleAddTool({})}
>
Add
</ActionButton>
]}>
<div className="overflow-auto flex flex-col justify-start">
{tools.map((tool, index) => (
<button
key={index}
ref={selectedTool === tool.name ? selectedToolRef : null}
onClick={() => handleSelectTool(tool.name)}
className={`flex items-center justify-between rounded-md px-3 py-2 ${selectedTool === tool.name ? 'bg-gray-200' : 'hover:bg-gray-100'}`}
>
<div className="flex items-center gap-2">
<div>{tool.name}</div>
</div>
<Dropdown key={tool.name}>
<DropdownTrigger>
<svg className="w-6 h-6 text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeWidth="3" d="M12 6h.01M12 12h.01M12 18h.01" />
</svg>
</DropdownTrigger>
<DropdownMenu
onAction={(key) => {
if (key === 'delete') {
handleDeleteTool(tool.name);
}
}}
>
<DropdownItem key="delete" className="text-danger">Delete</DropdownItem>
</DropdownMenu>
</Dropdown>
</button>
))}
</div>
</Pane>;
}

View file

@ -0,0 +1,860 @@
"use client";
import { DataSource, Workflow, WorkflowAgent, WorkflowPrompt, WorkflowTool, WithStringId } from "@/app/lib/types";
import { useReducer, Reducer, useState, useCallback, useEffect, useRef, Dispatch } from "react";
import { produce, applyPatches, enablePatches, produceWithPatches, Patch } from 'immer';
import { AgentConfig } from "./agent_config";
import { ToolConfig } from "./tool_config";
import { App as ChatApp } from "../playground/app";
import { z } from "zod";
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownSection, DropdownTrigger, Spinner } from "@nextui-org/react";
import { PromptConfig } from "./prompt_config";
import { AgentsList } from "./agents_list";
import { PromptsList } from "./prompts_list";
import { ToolsList } from "./tools_list";
import { EditableField } from "@/app/lib/components/editable-field";
import { RelativeTime } from "@primer/react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { Copilot } from "./copilot";
import { apiV1 } from "rowboat-shared";
import { publishWorkflow, renameWorkflow, saveWorkflow } from "@/app/actions";
import { PublishedBadge } from "./published_badge";
import { BackIcon, HamburgerIcon, WorkflowIcon } from "@/app/lib/components/icons";
import { ClipboardIcon, Layers2Icon, RadioIcon } from "lucide-react";
enablePatches();
interface StateItem {
workflow: WithStringId<z.infer<typeof Workflow>>;
publishedWorkflowId: string | null;
publishing: boolean;
selection: {
type: "agent" | "tool" | "prompt";
name: string;
} | null;
saving: boolean;
publishError: string | null;
publishSuccess: boolean;
pendingChanges: boolean;
chatKey: number;
}
interface State {
present: StateItem;
patches: Patch[][];
inversePatches: Patch[][];
currentIndex: number;
}
export type Action = {
type: "update_workflow_name";
name: string;
} | {
type: "set_publishing";
publishing: boolean;
} | {
type: "set_published_workflow_id";
workflowId: string;
} | {
type: "add_agent";
agent: Partial<z.infer<typeof WorkflowAgent>>;
} | {
type: "add_tool";
tool: Partial<z.infer<typeof WorkflowTool>>;
} | {
type: "add_prompt";
prompt: Partial<z.infer<typeof WorkflowPrompt>>;
} | {
type: "select_agent";
name: string;
} | {
type: "select_tool";
name: string;
} | {
type: "delete_agent";
name: string;
} | {
type: "delete_tool";
name: string;
} | {
type: "update_agent";
name: string;
agent: Partial<z.infer<typeof WorkflowAgent>>;
} | {
type: "update_tool";
name: string;
tool: Partial<z.infer<typeof WorkflowTool>>;
} | {
type: "set_saving";
saving: boolean;
} | {
type: "unselect_agent";
} | {
type: "unselect_tool";
} | {
type: "undo";
} | {
type: "redo";
} | {
type: "select_prompt";
name: string;
} | {
type: "unselect_prompt";
} | {
type: "delete_prompt";
name: string;
} | {
type: "update_prompt";
name: string;
prompt: Partial<z.infer<typeof WorkflowPrompt>>;
} | {
type: "toggle_agent";
name: string;
} | {
type: "set_main_agent";
name: string;
} | {
type: "set_publish_error";
error: string | null;
} | {
type: "set_publish_success";
success: boolean;
} | {
type: "restore_state";
state: StateItem;
};
function reducer(state: State, action: Action): State {
console.log('running reducer', action);
let newState: State;
if (action.type === "restore_state") {
return {
present: action.state,
patches: [],
inversePatches: [],
currentIndex: 0
};
}
const isLive = state.present.workflow._id == state.present.publishedWorkflowId;
switch (action.type) {
case "undo": {
if (state.currentIndex <= 0) return state;
newState = produce(state, draft => {
const inverse = state.inversePatches[state.currentIndex - 1];
draft.present = applyPatches(state.present, inverse);
draft.currentIndex--;
});
break;
}
case "redo": {
if (state.currentIndex >= state.patches.length) return state;
newState = produce(state, draft => {
const patch = state.patches[state.currentIndex];
draft.present = applyPatches(state.present, patch);
draft.currentIndex++;
});
break;
}
case "update_workflow_name": {
newState = produce(state, draft => {
draft.present.workflow.name = action.name;
});
break;
}
case "set_publishing": {
newState = produce(state, draft => {
draft.present.publishing = action.publishing;
});
break;
}
case "set_published_workflow_id": {
newState = produce(state, draft => {
draft.present.publishedWorkflowId = action.workflowId;
});
break;
}
case "set_publish_error": {
newState = produce(state, draft => {
draft.present.publishError = action.error;
});
break;
}
case "set_publish_success": {
newState = produce(state, draft => {
draft.present.publishSuccess = action.success;
});
break;
}
case "set_saving": {
newState = produce(state, draft => {
draft.present.saving = action.saving;
draft.present.pendingChanges = action.saving;
draft.present.workflow.lastUpdatedAt = !action.saving ? new Date().toISOString() : state.present.workflow.lastUpdatedAt;
});
break;
}
default: {
const [nextState, patches, inversePatches] = produceWithPatches(
state.present,
(draft) => {
switch (action.type) {
case "select_agent":
draft.selection = {
type: "agent",
name: action.name
};
break;
case "select_tool":
draft.selection = {
type: "tool",
name: action.name
};
break;
case "select_prompt":
draft.selection = {
type: "prompt",
name: action.name
};
break;
case "unselect_agent":
case "unselect_tool":
case "unselect_prompt":
draft.selection = null;
break;
case "add_agent": {
if (isLive) {
break;
}
let newAgentName = "New agent";
if (draft.workflow?.agents.some((agent) => agent.name === newAgentName)) {
newAgentName = `New agent ${draft.workflow.agents.filter((agent) =>
agent.name.startsWith("New agent")).length + 1}`;
}
draft.workflow?.agents.push({
name: newAgentName,
type: "conversation",
description: "",
disabled: false,
instructions: "",
prompts: [],
tools: [],
model: "gpt-4o-mini",
locked: false,
toggleAble: true,
ragReturnType: "chunks",
ragK: 3,
connectedAgents: [],
controlType: "retain",
...action.agent
});
draft.selection = {
type: "agent",
name: action.agent.name || newAgentName
};
draft.pendingChanges = true;
break;
}
case "add_tool": {
if (isLive) {
break;
}
let newToolName = "New tool";
if (draft.workflow?.tools.some((tool) => tool.name === newToolName)) {
newToolName = `New tool ${draft.workflow.tools.filter((tool) =>
tool.name.startsWith("New tool")).length + 1}`;
}
draft.workflow?.tools.push({
name: newToolName,
description: "",
parameters: undefined,
mockInPlayground: true,
...action.tool
});
draft.selection = {
type: "tool",
name: action.tool.name || newToolName
};
draft.pendingChanges = true;
break;
}
case "add_prompt": {
if (isLive) {
break;
}
let newPromptName = "New prompt";
if (draft.workflow?.prompts.some((prompt) => prompt.name === newPromptName)) {
newPromptName = `New prompt ${draft.workflow?.prompts.filter((prompt) =>
prompt.name.startsWith("New prompt")).length + 1}`;
}
draft.workflow?.prompts.push({
name: newPromptName,
type: "base_prompt",
prompt: "",
...action.prompt
});
draft.selection = {
type: "prompt",
name: action.prompt.name || newPromptName
};
draft.pendingChanges = true;
break;
}
case "delete_agent":
if (isLive) {
break;
}
draft.workflow.agents = draft.workflow.agents.filter(
(agent) => agent.name !== action.name
);
draft.selection = null;
draft.pendingChanges = true;
draft.chatKey++;
break;
case "delete_tool":
if (isLive) {
break;
}
draft.workflow.tools = draft.workflow.tools.filter(
(tool) => tool.name !== action.name
);
draft.workflow.agents = draft.workflow.agents.map(agent => ({
...agent,
tools: agent.tools.filter(toolName => toolName !== action.name)
}));
draft.selection = null;
draft.pendingChanges = true;
draft.chatKey++;
break;
case "delete_prompt":
if (isLive) {
break;
}
draft.workflow.prompts = draft.workflow.prompts.filter(
(prompt) => prompt.name !== action.name
);
draft.workflow.agents = draft.workflow.agents.map(agent => ({
...agent,
prompts: agent.prompts.filter(promptName => promptName !== action.name)
}));
draft.selection = null;
draft.pendingChanges = true;
draft.chatKey++;
break;
case "update_agent":
if (isLive) {
break;
}
draft.workflow.agents = draft.workflow.agents.map((agent) =>
agent.name === action.name ? { ...agent, ...action.agent } : agent
);
if (action.agent.name && draft.workflow.startAgent === action.name) {
draft.workflow.startAgent = action.agent.name;
}
if (action.agent.name && action.agent.name !== action.name) {
draft.workflow.agents = draft.workflow.agents.map(agent => ({
...agent,
connectedAgents: agent.connectedAgents.map(connectedAgent =>
connectedAgent === action.name ? action.agent.name! : connectedAgent
)
}));
}
if (action.agent.name && draft.selection?.type === "agent" && draft.selection.name === action.name) {
draft.selection = {
type: "agent",
name: action.agent.name
};
}
draft.selection = {
type: "agent",
name: action.agent.name || action.name,
};
draft.pendingChanges = true;
draft.chatKey++;
break;
case "update_tool":
if (isLive) {
break;
}
draft.workflow.tools = draft.workflow.tools.map((tool) =>
tool.name === action.name ? { ...tool, ...action.tool } : tool
);
if (action.tool.name && action.tool.name !== action.name) {
draft.workflow.agents = draft.workflow.agents.map(agent => ({
...agent,
tools: agent.tools.map(toolName =>
toolName === action.name ? action.tool.name! : toolName
)
}));
}
if (action.tool.name && draft.selection?.type === "tool" && draft.selection.name === action.name) {
draft.selection = {
type: "tool",
name: action.tool.name
};
}
draft.selection = {
type: "tool",
name: action.tool.name || action.name,
};
draft.pendingChanges = true;
draft.chatKey++;
break;
case "update_prompt":
if (isLive) {
break;
}
draft.workflow.prompts = draft.workflow.prompts.map((prompt) =>
prompt.name === action.name ? { ...prompt, ...action.prompt } : prompt
);
draft.workflow.agents = draft.workflow.agents.map(agent => ({
...agent,
prompts: agent.prompts.map(promptName =>
promptName === action.name ? action.prompt.name! : promptName
)
}));
if (action.prompt.name && draft.selection?.type === "prompt" && draft.selection.name === action.name) {
draft.selection = {
type: "prompt",
name: action.prompt.name
};
}
draft.selection = {
type: "prompt",
name: action.prompt.name || action.name,
};
draft.pendingChanges = true;
draft.chatKey++;
break;
case "toggle_agent":
if (isLive) {
break;
}
draft.workflow.agents = draft.workflow.agents.map(agent =>
agent.name === action.name ? { ...agent, disabled: !agent.disabled } : agent
);
draft.chatKey++;
break;
case "set_main_agent":
if (isLive) {
break;
}
draft.workflow.startAgent = action.name;
draft.chatKey++;
break;
}
}
);
newState = produce(state, draft => {
draft.patches.splice(state.currentIndex);
draft.inversePatches.splice(state.currentIndex);
draft.patches.push(patches);
draft.inversePatches.push(inversePatches);
draft.currentIndex++;
draft.present = nextState;
});
}
}
return newState;
}
export function WorkflowEditor({
dataSources,
workflow,
publishedWorkflowId,
handleShowSelector,
handleCloneVersion,
}: {
dataSources: WithStringId<z.infer<typeof DataSource>>[];
workflow: WithStringId<z.infer<typeof Workflow>>;
publishedWorkflowId: string | null;
handleShowSelector: () => void;
handleCloneVersion: (workflowId: string) => void;
}) {
const [state, dispatch] = useReducer<Reducer<State, Action>>(reducer, {
patches: [],
inversePatches: [],
currentIndex: 0,
present: {
publishing: false,
selection: null,
workflow: workflow,
publishedWorkflowId: publishedWorkflowId,
saving: false,
publishError: null,
publishSuccess: false,
pendingChanges: false,
chatKey: 0,
}
});
const [chatMessages, setChatMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>([]);
const updateChatMessages = useCallback((messages: z.infer<typeof apiV1.ChatMessage>[]) => {
setChatMessages(messages);
}, []);
const saveQueue = useRef<z.infer<typeof Workflow>[]>([]);
const saving = useRef(false);
const isLive = state.present.workflow._id == state.present.publishedWorkflowId;
const [showCopySuccess, setShowCopySuccess] = useState(false);
console.log(`workflow editor chat key: ${state.present.chatKey}`);
function handleSelectAgent(name: string) {
dispatch({ type: "select_agent", name });
}
function handleSelectTool(name: string) {
dispatch({ type: "select_tool", name });
}
function handleSelectPrompt(name: string) {
dispatch({ type: "select_prompt", name });
}
function handleUnselectAgent() {
dispatch({ type: "unselect_agent" });
}
function handleUnselectTool() {
dispatch({ type: "unselect_tool" });
}
function handleUnselectPrompt() {
dispatch({ type: "unselect_prompt" });
}
function handleAddAgent(agent: Partial<z.infer<typeof WorkflowAgent>> = {}) {
dispatch({ type: "add_agent", agent });
}
function handleAddTool(tool: Partial<z.infer<typeof WorkflowTool>> = {}) {
dispatch({ type: "add_tool", tool });
}
function handleAddPrompt(prompt: Partial<z.infer<typeof WorkflowPrompt>> = {}) {
dispatch({ type: "add_prompt", prompt });
}
function handleUpdateAgent(name: string, agent: Partial<z.infer<typeof WorkflowAgent>>) {
dispatch({ type: "update_agent", name, agent });
}
function handleDeleteAgent(name: string) {
if (window.confirm(`Are you sure you want to delete the agent "${name}"?`)) {
dispatch({ type: "delete_agent", name });
}
}
function handleUpdateTool(name: string, tool: Partial<z.infer<typeof WorkflowTool>>) {
dispatch({ type: "update_tool", name, tool });
}
function handleDeleteTool(name: string) {
if (window.confirm(`Are you sure you want to delete the tool "${name}"?`)) {
dispatch({ type: "delete_tool", name });
}
}
function handleUpdatePrompt(name: string, prompt: Partial<z.infer<typeof WorkflowPrompt>>) {
dispatch({ type: "update_prompt", name, prompt });
}
function handleDeletePrompt(name: string) {
if (window.confirm(`Are you sure you want to delete the prompt "${name}"?`)) {
dispatch({ type: "delete_prompt", name });
}
}
function handleToggleAgent(name: string) {
dispatch({ type: "toggle_agent", name });
}
function handleSetMainAgent(name: string) {
dispatch({ type: "set_main_agent", name });
}
async function handleRenameWorkflow(name: string) {
await renameWorkflow(state.present.workflow.projectId, state.present.workflow._id, name);
dispatch({ type: "update_workflow_name", name });
}
async function handlePublishWorkflow() {
dispatch({ type: "set_publishing", publishing: true });
await publishWorkflow(state.present.workflow.projectId, state.present.workflow._id);
dispatch({ type: "set_publishing", publishing: false });
dispatch({ type: "set_published_workflow_id", workflowId: state.present.workflow._id });
}
function handleCopyJSON() {
const { _id, projectId, ...workflow } = state.present.workflow;
const json = JSON.stringify(workflow, null, 2);
navigator.clipboard.writeText(json);
setShowCopySuccess(true);
setTimeout(() => {
setShowCopySuccess(false);
}, 1500);
}
const processQueue = useCallback(async (state: State, dispatch: React.Dispatch<Action>) => {
if (saving.current || saveQueue.current.length === 0) return;
saving.current = true;
const workflowToSave = saveQueue.current[saveQueue.current.length - 1];
saveQueue.current = [];
try {
if (isLive) {
return;
} else {
await saveWorkflow(state.present.workflow.projectId, state.present.workflow._id, workflowToSave);
}
} finally {
saving.current = false;
if (saveQueue.current.length > 0) {
processQueue(state, dispatch);
} else {
dispatch({ type: "set_saving", saving: false });
}
}
}, [isLive]);
useEffect(() => {
if (state.present.pendingChanges && state.present.workflow) {
saveQueue.current.push(state.present.workflow);
const timeoutId = setTimeout(() => {
dispatch({ type: "set_saving", saving: true });
processQueue(state, dispatch);
}, 2000);
return () => clearTimeout(timeoutId);
}
}, [state.present.workflow, state.present.pendingChanges, processQueue, state]);
return <div className="flex flex-col h-full relative">
<div className="shrink-0 flex justify-between items-center pb-2">
<div className="flex items-center gap-2">
<div className="font-semibold">Workflow</div>
<div className="flex items-center gap-1">
<WorkflowIcon />
<div className="font-semibold">
<EditableField
key={state.present.workflow._id}
value={state.present.workflow?.name || ''}
onChange={handleRenameWorkflow}
placeholder="Name this version"
/>
</div>
{state.present.publishing && <Spinner size="sm" />}
{isLive && <PublishedBadge />}
</div>
<Dropdown>
<DropdownTrigger>
<Button
isIconOnly
variant="bordered"
size="sm"
>
<HamburgerIcon size={16} />
</Button>
</DropdownTrigger>
<DropdownMenu
disabledKeys={[
...(state.present.pendingChanges ? ['switch', 'clone'] : []),
...(isLive ? ['publish'] : []),
]}
onAction={(key) => {
if (key === 'switch') {
handleShowSelector();
}
if (key === 'clone') {
handleCloneVersion(state.present.workflow._id);
}
if (key === 'publish') {
handlePublishWorkflow();
}
if (key === 'clipboard') {
handleCopyJSON();
}
}}
>
<DropdownItem
key="switch"
startContent={<BackIcon size={16} />}
>
Switch version
</DropdownItem>
<DropdownItem
key="clone"
startContent={<Layers2Icon size={16} />}
>
Clone this version
</DropdownItem>
<DropdownItem
key="publish"
color="danger"
startContent={<RadioIcon size={16} />}
>
Deploy to Production
</DropdownItem>
<DropdownItem
key="clipboard"
startContent={<ClipboardIcon size={16} />}
>
Copy as JSON
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
{showCopySuccess && <div className="flex items-center gap-2">
<div className="text-green-500">Copied to clipboard</div>
</div>}
<div className="flex items-center gap-2">
{isLive && <div className="flex items-center gap-2">
<div className="bg-yellow-50 text-yellow-500 px-2 py-1 rounded-md text-sm">
This version is locked. You cannot make changes.
</div>
<Button
variant="bordered"
size="sm"
onClick={() => handleCloneVersion(state.present.workflow._id)}
>
Clone this version
</Button>
</div>}
{!isLive && <>
{state.present.saving && <div className="flex items-center gap-2">
<Spinner size="sm" />
<div className="text-sm text-gray-500">Saving...</div>
</div>}
{!state.present.saving && state.present.workflow && <div className="text-sm text-gray-500">
Updated <RelativeTime date={new Date(state.present.workflow.lastUpdatedAt)} />
</div>}
</>}
{!isLive && <>
<Button
isIconOnly
variant="bordered"
title="Undo"
size="sm"
disabled={state.currentIndex <= 0}
onClick={() => dispatch({ type: "undo" })}
>
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M3 9h13a5 5 0 0 1 0 10H7M3 9l4-4M3 9l4 4" />
</svg>
</Button>
<Button
isIconOnly
variant="bordered"
title="Redo"
size="sm"
disabled={state.currentIndex >= state.patches.length}
onClick={() => dispatch({ type: "redo" })}
>
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M21 9H8a5 5 0 0 0 0 10h9m4-10-4-4m4 4-4 4" />
</svg>
</Button>
</>}
</div>
</div>
<ResizablePanelGroup direction="horizontal" className="grow flex overflow-auto gap-1">
<ResizablePanel minSize={10} defaultSize={20}>
<ResizablePanelGroup direction="vertical" className="flex flex-col gap-1">
<ResizablePanel minSize={10} defaultSize={50}>
<AgentsList
agents={state.present.workflow.agents}
handleSelectAgent={handleSelectAgent}
handleAddAgent={handleAddAgent}
selectedAgent={state.present.selection?.type === "agent" ? state.present.selection.name : null}
handleToggleAgent={handleToggleAgent}
handleSetMainAgent={handleSetMainAgent}
handleDeleteAgent={handleDeleteAgent}
startAgentName={state.present.workflow.startAgent}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={10} defaultSize={30}>
<ToolsList
tools={state.present.workflow.tools}
handleSelectTool={handleSelectTool}
handleAddTool={handleAddTool}
selectedTool={state.present.selection?.type === "tool" ? state.present.selection.name : null}
handleDeleteTool={handleDeleteTool}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={10} defaultSize={20}>
<PromptsList
prompts={state.present.workflow.prompts}
handleSelectPrompt={handleSelectPrompt}
handleAddPrompt={handleAddPrompt}
selectedPrompt={state.present.selection?.type === "prompt" ? state.present.selection.name : null}
handleDeletePrompt={handleDeletePrompt}
/>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={20} defaultSize={50} className="overflow-auto">
<ChatApp
key={'' + state.present.chatKey}
hidden={state.present.selection !== null}
projectId={state.present.workflow.projectId}
workflow={state.present.workflow}
messageSubscriber={updateChatMessages}
/>
{state.present.selection?.type === "agent" && <AgentConfig
key={state.present.selection.name}
agent={state.present.workflow.agents.find((agent) => agent.name === state.present.selection!.name)!}
usedAgentNames={new Set(state.present.workflow.agents.filter((agent) => agent.name !== state.present.selection!.name).map((agent) => agent.name))}
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
prompts={state.present.workflow.prompts}
dataSources={dataSources}
handleUpdate={handleUpdateAgent.bind(null, state.present.selection.name)}
handleClose={handleUnselectAgent}
/>}
{state.present.selection?.type === "tool" && <ToolConfig
key={state.present.selection.name}
tool={state.present.workflow.tools.find((tool) => tool.name === state.present.selection!.name)!}
usedToolNames={new Set(state.present.workflow.tools.filter((tool) => tool.name !== state.present.selection!.name).map((tool) => tool.name))}
handleUpdate={handleUpdateTool.bind(null, state.present.selection.name)}
handleClose={handleUnselectTool}
/>}
{state.present.selection?.type === "prompt" && <PromptConfig
key={state.present.selection.name}
prompt={state.present.workflow.prompts.find((prompt) => prompt.name === state.present.selection!.name)!}
usedPromptNames={new Set(state.present.workflow.prompts.filter((prompt) => prompt.name !== state.present.selection!.name).map((prompt) => prompt.name))}
handleUpdate={handleUpdatePrompt.bind(null, state.present.selection.name)}
handleClose={handleUnselectPrompt}
/>}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={10} defaultSize={30}>
<Copilot
projectId={state.present.workflow.projectId}
workflow={state.present.workflow}
dispatch={dispatch}
chatContext={
state.present.selection ? {
type: state.present.selection.type,
name: state.present.selection.name
} : chatMessages.length > 0 ? {
type: 'chat',
messages: chatMessages
} : undefined
}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>;
}

View file

@ -0,0 +1,161 @@
"use client";
import { Workflow, WithStringId } from "@/app/lib/types";
import { z } from "zod";
import { useEffect, useState, useCallback } from "react";
import { PublishedBadge } from "./published_badge";
import { RelativeTime } from "@primer/react";
import { listWorkflows } from "@/app/actions";
import { Button, Divider, Pagination } from "@nextui-org/react";
import { WorkflowIcon } from "@/app/lib/components/icons";
import { PlusIcon } from "lucide-react";
const pageSize = 5;
function WorkflowCard({
workflow,
live = false,
handleSelect,
}: {
workflow: WithStringId<z.infer<typeof Workflow>>;
live?: boolean;
handleSelect: (workflowId: string) => void;
}) {
return <button className="flex items-center gap-2 p-2 rounded hover:bg-gray-100 cursor-pointer" onClick={() => handleSelect(workflow._id)}>
<div className="flex flex-col gap-1 items-start">
<div className="flex items-center gap-1">
<WorkflowIcon />
<div className="text-black truncate">{workflow.name || 'Unnamed workflow'}</div>
{live && <PublishedBadge />}
</div>
<div className="text-xs text-gray-400">
updated <RelativeTime date={new Date(workflow.lastUpdatedAt)} />
</div>
</div>
</button>;
}
export function WorkflowSelector({
projectId,
handleSelect,
handleCreateNewVersion,
}: {
projectId: string;
handleSelect: (workflowId: string) => void;
handleCreateNewVersion: () => void;
}) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [workflows, setWorkflows] = useState<(WithStringId<z.infer<typeof Workflow>>)[]>([]);
const [publishedWorkflowId, setPublishedWorkflowId] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [retryCount, setRetryCount] = useState(0);
function handlePageChange(page: number) {
setCurrentPage(page);
setWorkflows([]);
}
function handleRetry() {
setRetryCount(retryCount + 1);
}
useEffect(() => {
let ignore = false;
async function fetchWorkflows() {
setError(null);
setLoading(true);
try {
const { workflows, total, publishedWorkflowId } = await listWorkflows(projectId, currentPage, pageSize);
if (ignore) {
console.log('ignoring', currentPage);
return;
}
setWorkflows(workflows);
setTotalPages(Math.ceil(total / pageSize));
setPublishedWorkflowId(publishedWorkflowId);
setError(null);
} catch (e) {
setError('Failed to load workflows');
} finally {
if (!ignore) {
setLoading(false);
}
}
}
fetchWorkflows();
return () => {
ignore = true;
}
}, [projectId, currentPage, retryCount]);
return <div className="flex flex-col gap-2 max-w-[768px] mx-auto w-full border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-2 justify-between">
<div className="text-lg">Select a workflow version</div>
<Button
color="primary"
startContent={<PlusIcon size={16} />}
onClick={handleCreateNewVersion}
>
Create new version
</Button>
</div>
<Divider />
{loading && <div className="flex flex-col gap-2">
{[...Array(pageSize)].map((_, i) => {
const widths = ['w-32', 'w-40', 'w-48', 'w-56'];
const randomWidth = widths[Math.floor(Math.random() * widths.length)];
return (
<div
key={i}
className="flex items-center justify-between gap-2 p-2 rounded"
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className={`h-5 ${randomWidth} bg-gray-200 rounded animate-pulse`}></div>
</div>
<div className="h-4 w-32 bg-gray-200 rounded animate-pulse"></div>
</div>
</div>
);
})}
</div>}
{error && <div className="flex flex-col items-center gap-2 text-red-600">
<div>{error}</div>
<button
onClick={handleRetry}
className="px-4 py-2 text-sm bg-red-100 hover:bg-red-200 rounded"
>
Retry
</button>
</div>}
{!loading && !error && workflows.length == 0 && <div className="flex flex-col items-center gap-2">
<div className="text-sm text-gray-500">No versions found. Create a new version to get started.</div>
</div>}
{!loading && !error && workflows.length > 0 && <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
{workflows.map((workflow) => (
<WorkflowCard
key={workflow._id}
workflow={workflow}
live={publishedWorkflowId == workflow._id}
handleSelect={handleSelect}
/>
))}
</div>
</div>}
{totalPages > 1 && (
<div className="flex justify-center mt-4">
<Pagination
total={totalPages}
page={currentPage}
onChange={handlePageChange}
/>
</div>
)}
</div>
}