mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-13 17:22:37 +02:00
add rowboat app
This commit is contained in:
parent
b83b5f8a07
commit
10f76ef49f
117 changed files with 25370 additions and 0 deletions
124
apps/rowboat/app/projects/[projectId]/config/app.tsx
Normal file
124
apps/rowboat/app/projects/[projectId]/config/app.tsx
Normal 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 </body> 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'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>;
|
||||
}
|
||||
42
apps/rowboat/app/projects/[projectId]/config/embed.tsx
Normal file
42
apps/rowboat/app/projects/[projectId]/config/embed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/rowboat/app/projects/[projectId]/config/page.tsx
Normal file
15
apps/rowboat/app/projects/[projectId]/config/page.tsx
Normal 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} />;
|
||||
}
|
||||
94
apps/rowboat/app/projects/[projectId]/config/secret.tsx
Normal file
94
apps/rowboat/app/projects/[projectId]/config/secret.tsx
Normal 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't be shown again!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
apps/rowboat/app/projects/[projectId]/config/webhook-url.tsx
Normal file
81
apps/rowboat/app/projects/[projectId]/config/webhook-url.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/rowboat/app/projects/[projectId]/layout.tsx
Normal file
16
apps/rowboat/app/projects/[projectId]/layout.tsx
Normal 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 >;
|
||||
}
|
||||
85
apps/rowboat/app/projects/[projectId]/menu.tsx
Normal file
85
apps/rowboat/app/projects/[projectId]/menu.tsx
Normal 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>;
|
||||
}
|
||||
68
apps/rowboat/app/projects/[projectId]/nav.tsx
Normal file
68
apps/rowboat/app/projects/[projectId]/nav.tsx
Normal 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>;
|
||||
}
|
||||
9
apps/rowboat/app/projects/[projectId]/page.tsx
Normal file
9
apps/rowboat/app/projects/[projectId]/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Page({
|
||||
params
|
||||
}: {
|
||||
params: { projectId: string }
|
||||
}) {
|
||||
redirect(`/projects/${params.projectId}/workflow`);
|
||||
}
|
||||
110
apps/rowboat/app/projects/[projectId]/playground/app.tsx
Normal file
110
apps/rowboat/app/projects/[projectId]/playground/app.tsx
Normal 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>;
|
||||
}
|
||||
319
apps/rowboat/app/projects/[projectId]/playground/chat.tsx
Normal file
319
apps/rowboat/app/projects/[projectId]/playground/chat.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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>}
|
||||
/>;
|
||||
}
|
||||
763
apps/rowboat/app/projects/[projectId]/playground/messages.tsx
Normal file
763
apps/rowboat/app/projects/[projectId]/playground/messages.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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 →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
146
apps/rowboat/app/projects/[projectId]/sources/new/form.tsx
Normal file
146
apps/rowboat/app/projects/[projectId]/sources/new/form.tsx
Normal 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>;
|
||||
}
|
||||
21
apps/rowboat/app/projects/[projectId]/sources/new/page.tsx
Normal file
21
apps/rowboat/app/projects/[projectId]/sources/new/page.tsx
Normal 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>;
|
||||
}
|
||||
16
apps/rowboat/app/projects/[projectId]/sources/page.tsx
Normal file
16
apps/rowboat/app/projects/[projectId]/sources/page.tsx
Normal 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}
|
||||
/>;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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…
|
||||
</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>;
|
||||
}
|
||||
106
apps/rowboat/app/projects/[projectId]/sources/sources-list.tsx
Normal file
106
apps/rowboat/app/projects/[projectId]/sources/sources-list.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
380
apps/rowboat/app/projects/[projectId]/workflow/agent_config.tsx
Normal file
380
apps/rowboat/app/projects/[projectId]/workflow/agent_config.tsx
Normal 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 'start' agent</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
111
apps/rowboat/app/projects/[projectId]/workflow/app.tsx
Normal file
111
apps/rowboat/app/projects/[projectId]/workflow/app.tsx
Normal 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}
|
||||
/>}
|
||||
</>
|
||||
}
|
||||
497
apps/rowboat/app/projects/[projectId]/workflow/copilot.tsx
Normal file
497
apps/rowboat/app/projects/[projectId]/workflow/copilot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
// }
|
||||
51
apps/rowboat/app/projects/[projectId]/workflow/page.tsx
Normal file
51
apps/rowboat/app/projects/[projectId]/workflow/page.tsx
Normal 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}
|
||||
/>;
|
||||
}
|
||||
48
apps/rowboat/app/projects/[projectId]/workflow/pane.tsx
Normal file
48
apps/rowboat/app/projects/[projectId]/workflow/pane.tsx
Normal 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>;
|
||||
}
|
||||
144
apps/rowboat/app/projects/[projectId]/workflow/preview-modal.tsx
Normal file
144
apps/rowboat/app/projects/[projectId]/workflow/preview-modal.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
236
apps/rowboat/app/projects/[projectId]/workflow/tool_config.tsx
Normal file
236
apps/rowboat/app/projects/[projectId]/workflow/tool_config.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue