mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
Merge pull request #34 from rowboatlabs/refactor
Add dark mode support, app styling, and editable field improvements
This commit is contained in:
commit
98010d90a8
36 changed files with 893 additions and 1074 deletions
|
|
@ -75,6 +75,30 @@ html, body {
|
|||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* Add these new utility classes */
|
||||
.card-shadow {
|
||||
@apply shadow-sm dark:shadow-none dark:border-border;
|
||||
}
|
||||
|
||||
.hover-effect {
|
||||
@apply hover:bg-accent/10 dark:hover:bg-accent/20 transition-colors;
|
||||
}
|
||||
|
||||
.border-subtle {
|
||||
@apply border-border dark:border-border/50;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add this to disable color transitions */
|
||||
* {
|
||||
-webkit-transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, opacity 0.2s ease-in-out !important;
|
||||
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, opacity 0.2s ease-in-out !important;
|
||||
}
|
||||
|
||||
/* Ensure smooth transitions */
|
||||
* {
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
|
||||
/* Add these styles alongside your other global styles */
|
||||
|
|
@ -86,6 +110,40 @@ html, body {
|
|||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Base dark mode styles */
|
||||
.dark .ql-mention-list-container {
|
||||
background-color: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.dark .ql-mention-list-container * {
|
||||
background-color: #1f2937 !important;
|
||||
color: #f9fafb !important;
|
||||
}
|
||||
|
||||
/* Target individual items */
|
||||
.dark .ql-mention-list-item {
|
||||
color: #f9fafb !important;
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
/* Target hover and selected states for individual items */
|
||||
.dark .ql-mention-list-container .ql-mention-list-item.selected,
|
||||
.dark .ql-mention-list-container .ql-mention-list-item:hover {
|
||||
background-color: #6b7280 !important;
|
||||
}
|
||||
|
||||
/* Ensure the background color only applies to the item itself */
|
||||
.dark .ql-mention-list-item > * {
|
||||
background-color: inherit !important;
|
||||
}
|
||||
|
||||
/* Additional catch-all for any other possible class combinations */
|
||||
.dark [class*="mention"].selected,
|
||||
.dark [class*="mention"]:hover {
|
||||
background-color: #6b7280 !important;
|
||||
}
|
||||
|
||||
.ql-mention-list {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
|
@ -108,4 +166,29 @@ html, body {
|
|||
|
||||
.ql-editor .mention .invalid {
|
||||
color: red;
|
||||
}
|
||||
|
||||
/* Add custom scrollbar styling */
|
||||
.dark *::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-track {
|
||||
background: #1f2937; /* dark gray background */
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
background: #4b5563; /* medium gray thumb */
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280; /* lighter gray on hover */
|
||||
}
|
||||
|
||||
/* For Firefox */
|
||||
.dark * {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #4b5563 #1f2937;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import "./globals.css";
|
||||
import { ThemeProvider } from "./providers/theme-provider";
|
||||
import { UserProvider } from '@auth0/nextjs-auth0/client';
|
||||
import { Inter } from "next/font/google";
|
||||
import { Providers } from "./providers";
|
||||
|
|
@ -20,11 +21,13 @@ export default function RootLayout({
|
|||
}>) {
|
||||
return <html lang="en" className="h-dvh">
|
||||
<UserProvider>
|
||||
<body className={`${inter.className} h-full text-base [scrollbar-width:thin] bg-gray-100`}>
|
||||
<Providers className='h-full flex flex-col'>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
<ThemeProvider>
|
||||
<body className={`${inter.className} h-full text-base [scrollbar-width:thin] bg-background`}>
|
||||
<Providers className='h-full flex flex-col'>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</ThemeProvider>
|
||||
</UserProvider>
|
||||
</html>;
|
||||
}
|
||||
|
|
|
|||
37
apps/rowboat/app/lib/components/dropdown.tsx
Normal file
37
apps/rowboat/app/lib/components/dropdown.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Select, SelectItem } from "@nextui-org/react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface DropdownOption {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
options: DropdownOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Dropdown({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className = "w-60"
|
||||
}: DropdownProps) {
|
||||
return (
|
||||
<Select
|
||||
variant="bordered"
|
||||
selectedKeys={[value]}
|
||||
size="sm"
|
||||
className={className}
|
||||
onSelectionChange={(keys) => onChange(keys.currentKey as string)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.key} value={option.key}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import clsx from "clsx";
|
|||
import { Label } from "./label";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Match } from "./mentions_editor";
|
||||
import { SparklesIcon } from "lucide-react";
|
||||
const MentionsEditor = dynamic(() => import('./mentions_editor'), { ssr: false });
|
||||
|
||||
interface EditableFieldProps {
|
||||
|
|
@ -22,7 +23,13 @@ interface EditableFieldProps {
|
|||
mentions?: boolean;
|
||||
mentionsAtValues?: Match[];
|
||||
showSaveButton?: boolean;
|
||||
showDiscardButton?: boolean;
|
||||
error?: string | null;
|
||||
inline?: boolean;
|
||||
showGenerateButton?: {
|
||||
show: boolean;
|
||||
setShow: (show: boolean) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function EditableField({
|
||||
|
|
@ -33,13 +40,16 @@ export function EditableField({
|
|||
markdown = false,
|
||||
multiline = false,
|
||||
locked = false,
|
||||
className = "flex flex-col gap-1",
|
||||
className = "flex flex-col gap-1 w-full",
|
||||
validate,
|
||||
light = false,
|
||||
mentions = false,
|
||||
mentionsAtValues = [],
|
||||
showSaveButton = multiline,
|
||||
showSaveButton = false,
|
||||
showDiscardButton = false,
|
||||
error,
|
||||
inline = false,
|
||||
showGenerateButton,
|
||||
}: EditableFieldProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
|
@ -70,7 +80,11 @@ export function EditableField({
|
|||
variant: "bordered" as const,
|
||||
labelPlacement: "outside" as const,
|
||||
placeholder: markdown ? '' : placeholder,
|
||||
radius: "sm" as const,
|
||||
classNames: {
|
||||
input: "rounded-md",
|
||||
inputWrapper: "rounded-md border-medium"
|
||||
},
|
||||
radius: "md" as const,
|
||||
isInvalid: !isValid,
|
||||
errorMessage: validationResult?.errorMessage,
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
|
|
@ -97,80 +111,147 @@ export function EditableField({
|
|||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={clsx("flex flex-col gap-1", className)}>
|
||||
{(label || isEditing && showSaveButton) && <div className="flex items-center gap-2 justify-between">
|
||||
{label && <Label label={label} />}
|
||||
{isEditing && showSaveButton && <div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
setLocalValue(value);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>}
|
||||
</div>}
|
||||
{isEditing ? <>
|
||||
{mentions && <MentionsEditor
|
||||
atValues={mentionsAtValues}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onValueChange={setLocalValue}
|
||||
/>}
|
||||
if (isEditing) {
|
||||
const hasChanges = localValue !== value;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={clsx("flex flex-col gap-1 w-full", className)}>
|
||||
{label && (
|
||||
<div className="flex justify-between items-center">
|
||||
<Label label={label} />
|
||||
<div className="flex gap-2 items-center">
|
||||
{showGenerateButton && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
startContent={<SparklesIcon size={16} />}
|
||||
onPress={() => showGenerateButton.setShow(true)}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<>
|
||||
{showDiscardButton && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
setLocalValue(value);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
)}
|
||||
{showSaveButton && (
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mentions && (
|
||||
<div className="w-full rounded-md border-2 border-default-300">
|
||||
<MentionsEditor
|
||||
atValues={mentionsAtValues}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onValueChange={setLocalValue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{multiline && !mentions && <Textarea
|
||||
{...commonProps}
|
||||
minRows={3}
|
||||
maxRows={20}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
...commonProps.classNames,
|
||||
input: "rounded-md py-2",
|
||||
inputWrapper: "rounded-md border-medium py-1"
|
||||
}}
|
||||
/>}
|
||||
{!multiline && <Input {...commonProps} />}
|
||||
</> : (
|
||||
<div
|
||||
onClick={() => !locked && setIsEditing(true)}
|
||||
className={clsx("text-sm px-2 py-1 rounded-md", {
|
||||
"bg-gray-50": (markdown && !locked) || light,
|
||||
"hover:bg-blue-50 cursor-pointer": light && !locked,
|
||||
"hover:bg-gray-100 cursor-pointer": !light && !locked,
|
||||
"cursor-default": locked,
|
||||
})}
|
||||
>
|
||||
{value ? (<>
|
||||
{!multiline && <Input
|
||||
{...commonProps}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
...commonProps.classNames,
|
||||
input: "rounded-md py-2",
|
||||
inputWrapper: "rounded-md border-medium py-1"
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={clsx("cursor-text", className)}>
|
||||
{label && (
|
||||
<div className="flex justify-between items-center">
|
||||
<Label label={label} />
|
||||
{showGenerateButton && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
startContent={<SparklesIcon size={16} />}
|
||||
onPress={() => showGenerateButton.setShow(true)}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
{
|
||||
"border border-gray-300 dark:border-gray-600 rounded px-3 py-3": !inline,
|
||||
"bg-transparent focus:outline-none focus:ring-0 border-0 rounded-none text-gray-900 dark:text-gray-100": inline,
|
||||
}
|
||||
)}
|
||||
style={inline ? {
|
||||
border: 'none',
|
||||
borderRadius: '0',
|
||||
padding: '0'
|
||||
} : undefined}
|
||||
onClick={() => !locked && setIsEditing(true)}
|
||||
>
|
||||
{value ? (
|
||||
<>
|
||||
{markdown && <div className="max-h-[420px] overflow-y-auto">
|
||||
<MarkdownContent content={value} atValues={mentionsAtValues} />
|
||||
</div>}
|
||||
{!markdown && <div className={`${multiline ? 'whitespace-pre-wrap max-h-[420px] overflow-y-auto' : 'flex items-center'}`}>
|
||||
<MarkdownContent content={value} atValues={mentionsAtValues} />
|
||||
</div>}
|
||||
</>) : (
|
||||
<>
|
||||
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400">
|
||||
<MarkdownContent content={placeholder} atValues={mentionsAtValues} />
|
||||
</div>}
|
||||
{!markdown && <span className="text-gray-400">{placeholder}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-xs text-red-500 mt-1">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400">
|
||||
<MarkdownContent content={placeholder} atValues={mentionsAtValues} />
|
||||
</div>}
|
||||
{!markdown && <span className="text-gray-400">{placeholder}</span>}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-xs text-red-500 mt-1">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,22 @@
|
|||
import { Divider } from "@nextui-org/react";
|
||||
import { Label } from "./label";
|
||||
|
||||
export function FormSection({
|
||||
label,
|
||||
children,
|
||||
className = "",
|
||||
showDivider = false,
|
||||
}: {
|
||||
label?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
showDivider?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className={`flex flex-col gap-4 items-start ${className}`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{label && <Label label={label} />}
|
||||
{children}
|
||||
</div>
|
||||
<Divider />
|
||||
{showDivider && <Divider className="my-4" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
apps/rowboat/app/lib/components/mentions-editor.css
Normal file
36
apps/rowboat/app/lib/components/mentions-editor.css
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/* Target both edit mode and view mode mentions */
|
||||
.mention,
|
||||
.ql-editor p span[class*="bg-[#e"], /* Matches both #e8f2fe and #e0f2fe */
|
||||
span[class*="bg-[#e"] { /* For view mode */
|
||||
background-color: #e8f2fe !important;
|
||||
color: #1e40af !important;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .mention,
|
||||
.dark .ql-editor p span[class*="bg-[#e"],
|
||||
.dark span[class*="bg-[#e"] {
|
||||
background-color: rgb(31 41 55) !important; /* bg-gray-800 */
|
||||
color: rgb(243 244 246) !important; /* text-gray-100 */
|
||||
}
|
||||
|
||||
/* Handle Next.js dark mode class if needed */
|
||||
:global(.dark) .mention,
|
||||
:global(.dark) .ql-editor p span[class*="bg-[#e"],
|
||||
:global(.dark) span[class*="bg-[#e"] {
|
||||
background-color: rgb(31 41 55) !important;
|
||||
color: rgb(243 244 246) !important;
|
||||
}
|
||||
|
||||
/* Override the inline styles */
|
||||
.ql-editor p span[class*="bg-[#e0f2fe]"],
|
||||
.ql-editor p span[class*="bg-[#e8f2fe]"] {
|
||||
background-color: rgb(31 41 55) !important; /* bg-gray-800 */
|
||||
color: rgb(243 244 246) !important; /* text-gray-100 */
|
||||
}
|
||||
|
||||
/* Target our custom class */
|
||||
.dark .mention-tag {
|
||||
background-color: rgb(31 41 55) !important; /* bg-gray-800 */
|
||||
color: rgb(243 244 246) !important; /* text-gray-100 */
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react';
|
|||
import Quill, { Delta, Op } from 'quill';
|
||||
import { Mention, MentionBlot, MentionBlotData } from "quill-mention";
|
||||
import "quill/dist/quill.snow.css";
|
||||
import "./mentions-editor.css";
|
||||
import { CopyIcon } from 'lucide-react';
|
||||
import { CopyButton } from './copy-button';
|
||||
|
||||
|
|
|
|||
31
apps/rowboat/app/lib/components/menu-item.tsx
Normal file
31
apps/rowboat/app/lib/components/menu-item.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface MenuItemProps {
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const MenuItem: React.FC<MenuItemProps> = ({ icon, children, selected, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors",
|
||||
"hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
{
|
||||
"bg-gray-100 dark:bg-gray-800": selected,
|
||||
"text-gray-600 dark:text-gray-400": !selected,
|
||||
"text-gray-900 dark:text-gray-100": selected,
|
||||
}
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuItem;
|
||||
50
apps/rowboat/app/lib/components/structured-list.tsx
Normal file
50
apps/rowboat/app/lib/components/structured-list.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import clsx from "clsx";
|
||||
import { ActionButton } from "./structured-panel";
|
||||
|
||||
export function SectionHeader({ title, onAdd }: { title: string; onAdd: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2 py-1 mt-4 first:mt-0 border-b border-gray-200 dark:border-gray-600">
|
||||
<div className="text-xs font-semibold text-gray-400 dark:text-gray-300 uppercase">{title}</div>
|
||||
<ActionButton
|
||||
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={onAdd}
|
||||
>
|
||||
Add
|
||||
</ActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListItem({
|
||||
name,
|
||||
isSelected,
|
||||
onClick,
|
||||
disabled,
|
||||
rightElement,
|
||||
selectedRef
|
||||
}: {
|
||||
name: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
rightElement?: React.ReactNode;
|
||||
selectedRef?: React.RefObject<HTMLButtonElement>;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
ref={selectedRef as any}
|
||||
onClick={onClick}
|
||||
className={clsx("flex items-center justify-between rounded-md px-2 py-1", {
|
||||
"bg-gray-100 dark:bg-gray-700": isSelected,
|
||||
"hover:bg-gray-50 dark:hover:bg-gray-800": !isSelected,
|
||||
})}
|
||||
>
|
||||
<div className={clsx("truncate text-sm dark:text-gray-200", {
|
||||
"text-gray-400 dark:text-gray-500": disabled,
|
||||
})}>{name}</div>
|
||||
{rightElement}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,9 +18,9 @@ export function ActionButton({
|
|||
const onClickProp = onClick ? { onClick } : {};
|
||||
return <button
|
||||
disabled={disabled}
|
||||
className={clsx("rounded-md text-xs flex items-center gap-1 disabled:text-gray-300 hover:text-gray-600", {
|
||||
"text-blue-600": primary,
|
||||
"text-gray-400": !primary,
|
||||
className={clsx("rounded-md text-xs flex items-center gap-1 disabled:text-gray-300 dark:disabled:text-gray-600 hover:text-gray-600 dark:hover:text-gray-300", {
|
||||
"text-blue-600 dark:text-blue-400": primary,
|
||||
"text-gray-400 dark:text-gray-500": !primary,
|
||||
})}
|
||||
{...onClickProp}
|
||||
>
|
||||
|
|
@ -43,14 +43,14 @@ export function StructuredPanel({
|
|||
tooltip?: string | null;
|
||||
}) {
|
||||
return <div className={clsx("h-full flex flex-col overflow-auto rounded-md p-1", {
|
||||
"bg-gray-100": !fancy,
|
||||
"bg-blue-100": fancy,
|
||||
"bg-gray-100 dark:bg-gray-800": !fancy,
|
||||
"bg-blue-100 dark:bg-blue-900": fancy,
|
||||
})}>
|
||||
<div className="shrink-0 flex justify-between items-center gap-2 px-2 py-1 rounded-t-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={clsx("text-xs font-semibold uppercase", {
|
||||
"text-gray-400": !fancy,
|
||||
"text-blue-500": fancy,
|
||||
"text-gray-400 dark:text-gray-500": !fancy,
|
||||
"text-blue-500 dark:text-blue-400": fancy,
|
||||
})}>
|
||||
{title}
|
||||
</div>
|
||||
|
|
@ -61,21 +61,21 @@ export function StructuredPanel({
|
|||
className="cursor-help"
|
||||
>
|
||||
<InfoIcon size={12} className={clsx({
|
||||
"text-gray-400": !fancy,
|
||||
"text-blue-500": fancy,
|
||||
"text-gray-400 dark:text-gray-500": !fancy,
|
||||
"text-blue-500 dark:text-blue-400": fancy,
|
||||
})} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{!actions && <div className="w-4 h-4" />}
|
||||
{actions && <div className={clsx("rounded-md hover:text-gray-800 px-2 text-sm flex items-center gap-2", {
|
||||
"text-blue-600": fancy,
|
||||
"text-gray-400": !fancy,
|
||||
{actions && <div className={clsx("rounded-md hover:text-gray-800 dark:hover:text-gray-200 px-2 text-sm flex items-center gap-2", {
|
||||
"text-blue-600 dark:text-blue-400": fancy,
|
||||
"text-gray-400 dark:text-gray-500": !fancy,
|
||||
})}>
|
||||
{actions}
|
||||
</div>}
|
||||
</div>
|
||||
<div className="grow bg-white rounded-md overflow-auto flex flex-col justify-start p-2">
|
||||
<div className="grow bg-white dark:bg-gray-900 rounded-md overflow-auto flex flex-col justify-start p-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>;
|
||||
|
|
|
|||
21
apps/rowboat/app/lib/components/theme-toggle.tsx
Normal file
21
apps/rowboat/app/lib/components/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
'use client'
|
||||
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "@/app/providers/theme-provider"
|
||||
import { Button } from "@nextui-org/react"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="light"
|
||||
isIconOnly
|
||||
onClick={toggleTheme}
|
||||
aria-label="Toggle theme"
|
||||
className="text-foreground"
|
||||
>
|
||||
{theme === 'light' ? <Moon size={20} /> : <Sun size={20} />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,8 +11,8 @@ export default async function Layout({
|
|||
|
||||
return <div className="flex h-full">
|
||||
<Nav projectId={params.projectId} useDataSources={useDataSources} />
|
||||
<div className="grow p-2 overflow-auto bg-white rounded-tl-lg">
|
||||
<div className="grow p-2 overflow-auto bg-background dark:bg-background rounded-tl-lg">
|
||||
{children}
|
||||
</div>
|
||||
</div >;
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -2,29 +2,43 @@
|
|||
import { usePathname } from "next/navigation";
|
||||
import { Tooltip } from "@nextui-org/react";
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
import { DatabaseIcon, SettingsIcon, WorkflowIcon, PlayIcon } from "lucide-react";
|
||||
import MenuItem from "../../lib/components/menu-item";
|
||||
|
||||
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-2 gap-2 items-center rounded-lg text-sm hover:text-black", {
|
||||
"text-black": selected,
|
||||
"justify-center": collapsed,
|
||||
})}
|
||||
>
|
||||
{collapsed && <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>;
|
||||
function NavLink({ href, label, icon, collapsed, selected = false }: {
|
||||
href: string,
|
||||
label: string,
|
||||
icon: React.ReactNode,
|
||||
collapsed: boolean,
|
||||
selected?: boolean
|
||||
}) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip content={label} showArrow placement="right">
|
||||
<Link href={href} className="block">
|
||||
<MenuItem
|
||||
icon={icon}
|
||||
selected={selected}
|
||||
onClick={() => {}}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href}>
|
||||
<MenuItem
|
||||
icon={icon}
|
||||
selected={selected}
|
||||
onClick={() => {}}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Menu({
|
||||
|
|
@ -38,52 +52,38 @@ export default function Menu({
|
|||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return <div className="flex flex-col text-gray-500">
|
||||
{/* <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="Build"
|
||||
collapsed={collapsed}
|
||||
icon={<WorkflowIcon size={16} />}
|
||||
selected={pathname.startsWith(`/projects/${projectId}/workflow`)}
|
||||
/>
|
||||
<NavLink
|
||||
href={`/projects/${projectId}/simulation`}
|
||||
label="Test"
|
||||
collapsed={collapsed}
|
||||
icon={<PlayIcon size={16} />}
|
||||
selected={pathname.startsWith(`/projects/${projectId}/simulation`)}
|
||||
/>
|
||||
{useDataSources && <NavLink
|
||||
href={`/projects/${projectId}/sources`}
|
||||
label="Connect"
|
||||
collapsed={collapsed}
|
||||
icon={<DatabaseIcon size={16} />}
|
||||
selected={pathname.startsWith(`/projects/${projectId}/sources`)}
|
||||
/>}
|
||||
<NavLink
|
||||
href={`/projects/${projectId}/config`}
|
||||
label="Integrate"
|
||||
collapsed={collapsed}
|
||||
icon={<SettingsIcon size={16} />}
|
||||
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>;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<NavLink
|
||||
href={`/projects/${projectId}/workflow`}
|
||||
label="Build"
|
||||
collapsed={collapsed}
|
||||
icon={<WorkflowIcon size={16} />}
|
||||
selected={pathname.startsWith(`/projects/${projectId}/workflow`)}
|
||||
/>
|
||||
<NavLink
|
||||
href={`/projects/${projectId}/simulation`}
|
||||
label="Test"
|
||||
collapsed={collapsed}
|
||||
icon={<PlayIcon size={16} />}
|
||||
selected={pathname.startsWith(`/projects/${projectId}/simulation`)}
|
||||
/>
|
||||
{useDataSources && (
|
||||
<NavLink
|
||||
href={`/projects/${projectId}/sources`}
|
||||
label="Connect"
|
||||
collapsed={collapsed}
|
||||
icon={<DatabaseIcon size={16} />}
|
||||
selected={pathname.startsWith(`/projects/${projectId}/sources`)}
|
||||
/>
|
||||
)}
|
||||
<NavLink
|
||||
href={`/projects/${projectId}/config`}
|
||||
label="Integrate"
|
||||
collapsed={collapsed}
|
||||
icon={<SettingsIcon size={16} />}
|
||||
selected={pathname.startsWith(`/projects/${projectId}/config`)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,11 +40,8 @@ export function Nav({
|
|||
</button>
|
||||
</Tooltip>
|
||||
{!collapsed && <div className="flex flex-col gap-1">
|
||||
<Tooltip content="Change project" showArrow placement="bottom-end">
|
||||
<Link className="relative group flex flex-col px-2 py-2 border border-gray-200 rounded-md hover:border-gray-500" href="/projects">
|
||||
<div className="absolute top-[-7px] left-1 px-1 bg-gray-100 text-xs text-gray-400 group-hover:text-gray-600">
|
||||
Project
|
||||
</div>
|
||||
<Tooltip content="Change project" showArrow placement="bottom-end" delay={0} closeDelay={0}>
|
||||
<Link className="relative group flex flex-col px-2 py-2 border border-gray-200 rounded-md hover:border-gray-500 transition-colors duration-100" href="/projects">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<FolderOpenIcon size={16} />
|
||||
<div className="truncate text-sm">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { ActionButton, Pane } from "../workflow/pane";
|
|||
import { apiV1 } from "rowboat-shared";
|
||||
import { EllipsisVerticalIcon, MessageSquarePlusIcon, PlayIcon } from "lucide-react";
|
||||
import { getScenario } from "../../../actions/simulation_actions";
|
||||
import clsx from "clsx";
|
||||
|
||||
function SimulateLabel() {
|
||||
return <span>Simulate<sup className="pl-1">beta</sup></span>;
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function ComposeBox({
|
|||
isIconOnly
|
||||
disabled={disabled}
|
||||
onClick={handleInput}
|
||||
className="bg-gray-100"
|
||||
className="bg-default-100"
|
||||
>
|
||||
<CornerDownLeftIcon size={16} />
|
||||
</Button>}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ import { MessageSquareIcon, EllipsisIcon, CircleCheckIcon, ChevronsDownIcon, Che
|
|||
|
||||
function UserMessage({ content }: { content: string }) {
|
||||
return <div className="self-end ml-[30%] flex flex-col">
|
||||
<div className="text-right text-gray-500 text-xs mr-3">
|
||||
<div className="text-right text-gray-500 dark:text-gray-400 text-xs mr-3">
|
||||
User
|
||||
</div>
|
||||
<div className="bg-gray-100 px-3 py-1 rounded-lg rounded-br-none text-sm">
|
||||
<div className="bg-gray-100 dark:bg-gray-800 px-3 py-1 rounded-lg rounded-br-none text-sm text-gray-900 dark:text-gray-100">
|
||||
<MarkdownContent content={content} />
|
||||
</div>
|
||||
</div>;
|
||||
|
|
@ -27,23 +27,22 @@ function UserMessage({ content }: { content: string }) {
|
|||
function InternalAssistantMessage({ content, sender, latency }: { content: string, sender: string | null | 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)}>
|
||||
{!expanded && <button className="flex items-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 gap-1 group" onClick={() => setExpanded(true)}>
|
||||
<MessageSquareIcon size={16} />
|
||||
<EllipsisIcon size={16} />
|
||||
<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-xs pl-3">
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs pl-3">
|
||||
{sender ?? 'Assistant'}
|
||||
</div>
|
||||
<button className="flex items-center gap-1 text-gray-400 hover:text-gray-600" onClick={() => setExpanded(false)}>
|
||||
<button className="flex items-center gap-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300" onClick={() => setExpanded(false)}>
|
||||
<XIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-gray-300 border-dashed px-3 py-1 rounded-lg rounded-bl-none">
|
||||
<div className="border border-gray-300 dark:border-gray-700 border-dashed px-3 py-1 rounded-lg rounded-bl-none text-gray-900 dark:text-gray-100">
|
||||
<pre className="text-sm whitespace-pre-wrap">{content}</pre>
|
||||
</div>
|
||||
</div>}
|
||||
|
|
@ -53,22 +52,22 @@ function InternalAssistantMessage({ content, sender, latency }: { content: strin
|
|||
function AssistantMessage({ content, sender, latency }: { content: string, sender: string | null | 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-xs pl-3">
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs pl-3">
|
||||
{sender ?? 'Assistant'}
|
||||
</div>
|
||||
<div className="text-gray-400 text-xs pr-3">
|
||||
<div className="text-gray-400 dark:text-gray-500 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 text-sm">
|
||||
<div className="bg-gray-100 dark:bg-gray-800 px-3 py-1 rounded-lg rounded-bl-none text-sm text-gray-900 dark:text-gray-100">
|
||||
<MarkdownContent content={content} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AssistantMessageLoading() {
|
||||
return <div className="self-start mr-[30%] flex flex-col text-gray-500 items-start">
|
||||
<div className="text-gray-500 text-xs ml-3">
|
||||
return <div className="self-start mr-[30%] flex flex-col text-gray-500 dark:text-gray-400 items-start">
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs ml-3">
|
||||
Assistant
|
||||
</div>
|
||||
<Spinner size="sm" className="mt-2 ml-3" />
|
||||
|
|
@ -77,10 +76,10 @@ function AssistantMessageLoading() {
|
|||
|
||||
function UserMessageLoading() {
|
||||
return <div className="self-end ml-[30%] flex flex-col">
|
||||
<div className="text-right text-gray-500 text-sm mr-3">
|
||||
<div className="text-right text-gray-500 dark:text-gray-400 text-sm mr-3">
|
||||
User
|
||||
</div>
|
||||
<div className="bg-gray-100 p-3 rounded-lg rounded-br-none animate-pulse w-20 text-gray-800">
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-3 rounded-lg rounded-br-none animate-pulse w-20 text-gray-800 dark:text-gray-200">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
</div>;
|
||||
|
|
@ -219,7 +218,7 @@ function ToolCallHeader({
|
|||
{!result && <Spinner size="sm" />}
|
||||
{result && <CircleCheckIcon size={16} />}
|
||||
<div className='font-semibold text-sm'>
|
||||
Function Call: <span className='bg-gray-100 px-2 py-1 rounded-lg font-medium'>{toolCall.function.name}</span>
|
||||
Function Call: <code className='bg-gray-100 dark:bg-neutral-800 px-2 py-0.5 rounded font-mono'>{toolCall.function.name}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
|
|
@ -567,9 +566,14 @@ function MockToolCall({
|
|||
}, [autoSubmit, response, handleSubmit, result]);
|
||||
|
||||
return <div className="flex flex-col gap-1">
|
||||
{sender && <div className='text-gray-500 text-xs 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%]'>
|
||||
<ToolCallHeader toolCall={toolCall} result={result} />
|
||||
{sender && <div className='text-gray-500 dark:text-gray-400 text-xs ml-3'>{sender}</div>}
|
||||
<div className='border border-gray-300 dark:border-gray-700 p-2 pt-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%] bg-white dark:bg-gray-900'>
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleCheckIcon size={16} className="text-gray-500 dark:text-gray-400" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Function Call: <code className='bg-gray-100 dark:bg-neutral-800 px-2 py-0.5 rounded font-mono'>{toolCall.function.name}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<ExpandableContent label='Params' content={toolCall.function.arguments} expanded={false} />
|
||||
|
|
@ -633,12 +637,12 @@ function ExpandableContent({
|
|||
}
|
||||
|
||||
return <div className='flex flex-col gap-2'>
|
||||
<div className='flex gap-1 items-start cursor-pointer text-gray-500' onClick={toggleExpanded}>
|
||||
<div className='flex gap-1 items-start cursor-pointer text-gray-500 dark:text-gray-400' onClick={toggleExpanded}>
|
||||
{!isExpanded && <ChevronRightIcon size={16} />}
|
||||
{isExpanded && <ChevronDownIcon size={16} />}
|
||||
<div className='text-left break-all text-xs'>{label}</div>
|
||||
</div>
|
||||
{isExpanded && <pre className='text-sm font-mono bg-gray-100 p-2 rounded break-all whitespace-pre-wrap overflow-x-auto'>
|
||||
{isExpanded && <pre className='text-sm font-mono bg-gray-100 dark:bg-gray-800 p-2 rounded break-all whitespace-pre-wrap overflow-x-auto text-gray-900 dark:text-gray-100'>
|
||||
{formattedContent}
|
||||
</pre>}
|
||||
</div>;
|
||||
|
|
@ -654,10 +658,10 @@ function SystemMessage({
|
|||
locked: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-gray-300 p-2 rounded-lg flex flex-col gap-2">
|
||||
<div className="border border-gray-300 dark:border-gray-700 p-2 rounded-lg flex flex-col gap-2 bg-white dark:bg-gray-900">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 font-medium">CONTEXT</div>
|
||||
<EditableField
|
||||
light
|
||||
label="Context"
|
||||
value={content}
|
||||
onChange={onChange}
|
||||
multiline
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { WithStringId } from '../../../../lib/types/types';
|
|||
import { Scenario, SimulationRun, SimulationResult, SimulationAggregateResult } from "../../../../lib/types/testing_types";
|
||||
import { z } from 'zod';
|
||||
import { Workflow } from "../../../../lib/types/workflow_types";
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
|
||||
type SimulationRunType = WithStringId<z.infer<typeof SimulationRun>>;
|
||||
|
|
@ -31,23 +32,23 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
|
|||
const passedScenarios = run.aggregateResults?.pass ?? 0;
|
||||
const failedScenarios = run.aggregateResults?.fail ?? 0;
|
||||
|
||||
const statusLabelClass = "w-[110px] px-3 py-1 rounded text-xs text-center uppercase font-semibold inline-block";
|
||||
const getStatusClass = (status: string) => {
|
||||
const baseClass = "w-[110px] px-3 py-1 rounded text-xs text-center uppercase font-semibold inline-block";
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
case 'pass':
|
||||
return `${statusLabelClass} bg-green-50 text-green-800`;
|
||||
return `${baseClass} bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-400`;
|
||||
case 'failed':
|
||||
case 'fail':
|
||||
return `${statusLabelClass} bg-red-50 text-red-800`;
|
||||
return `${baseClass} bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400`;
|
||||
case 'error':
|
||||
return `${statusLabelClass} bg-orange-50 text-orange-800`;
|
||||
return `${baseClass} bg-orange-50 dark:bg-orange-900/20 text-orange-800 dark:text-orange-400`;
|
||||
case 'cancelled':
|
||||
return `${statusLabelClass} bg-gray-50 text-gray-800`;
|
||||
return `${baseClass} bg-gray-50 dark:bg-neutral-800 text-gray-800 dark:text-neutral-400`;
|
||||
case 'running':
|
||||
case 'pending':
|
||||
default:
|
||||
return `${statusLabelClass} bg-yellow-50 text-yellow-800`;
|
||||
return `${baseClass} bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-400`;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -105,18 +106,24 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
|
|||
}, [menuOpenId, setMenuOpenId]);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg mb-4 shadow-sm">
|
||||
<div className="border dark:border-neutral-800 rounded-lg mb-4 shadow-sm">
|
||||
<div
|
||||
className="p-4 flex items-center justify-between cursor-pointer hover:bg-gray-50"
|
||||
className={clsx(
|
||||
"p-4 flex items-center justify-between cursor-pointer",
|
||||
"transition-colors duration-200",
|
||||
"hover:bg-neutral-100 dark:hover:bg-neutral-800",
|
||||
"border-b border-transparent",
|
||||
isExpanded && "border-b-neutral-200 dark:border-b-neutral-800"
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="h-5 w-5 text-gray-400" />
|
||||
<ChevronDownIcon className="h-5 w-5 text-gray-400 dark:text-neutral-500" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-5 w-5 text-gray-400" />
|
||||
<ChevronRightIcon className="h-5 w-5 text-gray-400 dark:text-neutral-500" />
|
||||
)}
|
||||
<div className="text-sm truncate">
|
||||
<div className="text-sm truncate dark:text-neutral-200">
|
||||
{formatMainTitle(run.startedAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -130,13 +137,13 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
|
|||
e.stopPropagation();
|
||||
setMenuOpenId(menuOpenId === run._id ? null : run._id);
|
||||
}}
|
||||
className="p-1 rounded-full hover:bg-gray-100"
|
||||
className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-5 w-5 text-gray-600" />
|
||||
<EllipsisVerticalIcon className="h-5 w-5 text-gray-600 dark:text-neutral-400" />
|
||||
</button>
|
||||
|
||||
{menuOpenId === run._id && (
|
||||
<div className="absolute right-0 mt-1 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-10">
|
||||
<div className="absolute right-0 mt-1 w-48 rounded-md shadow-lg bg-white dark:bg-neutral-900 ring-1 ring-black ring-opacity-5 dark:ring-neutral-700 z-10">
|
||||
<div className="py-1">
|
||||
{(run.status === 'running' || run.status === 'pending') && onCancelRun && (
|
||||
<button
|
||||
|
|
@ -145,7 +152,7 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
|
|||
onCancelRun(run._id);
|
||||
setMenuOpenId(null);
|
||||
}}
|
||||
className="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 w-full"
|
||||
className="flex items-center px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-neutral-800 w-full"
|
||||
>
|
||||
<NoSymbolIcon className="h-4 w-4 mr-2" />
|
||||
Cancel Run
|
||||
|
|
@ -153,7 +160,7 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
|
|||
)}
|
||||
<button
|
||||
disabled
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-400 w-full cursor-not-allowed whitespace-nowrap"
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-400 dark:text-neutral-500 w-full cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-4 w-4 mr-2" />
|
||||
Download transcripts
|
||||
|
|
@ -164,7 +171,7 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
|
|||
setShowDeleteConfirm(true);
|
||||
setMenuOpenId(null);
|
||||
}}
|
||||
className="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 w-full"
|
||||
className="flex items-center px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-neutral-800 w-full"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4 mr-2" />
|
||||
Delete run
|
||||
|
|
@ -177,7 +184,7 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
|
|||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-4 border-t">
|
||||
<div className="p-4 border-t dark:border-neutral-800">
|
||||
{run.status === 'error' ? (
|
||||
<div className="text-orange-800 bg-orange-50 p-4 rounded-lg">
|
||||
Your simulation could not be completed. Please run a new simulation again.
|
||||
|
|
@ -187,36 +194,36 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
|
|||
{/* Workflow and timing information in a grid */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
{workflow && (
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-600 mb-1">Workflow Version</div>
|
||||
<div className="font-medium">{workflow.name}</div>
|
||||
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Workflow Version</div>
|
||||
<div className="font-medium dark:text-neutral-200">{workflow.name}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-600 mb-1">Completed</div>
|
||||
<div className="text-sm">
|
||||
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Completed</div>
|
||||
<div className="text-sm dark:text-neutral-300">
|
||||
{run.completedAt ? formatDateTime(run.completedAt) : 'Not completed'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-600 mb-1">Duration</div>
|
||||
<div className="text-sm">{getDuration()}</div>
|
||||
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Duration</div>
|
||||
<div className="text-sm dark:text-neutral-300">{getDuration()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results statistics */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="p-4 rounded-lg bg-gray-50">
|
||||
<div className="text-sm text-gray-600">Total Scenarios</div>
|
||||
<div className="text-2xl font-semibold">{totalScenarios}</div>
|
||||
<div className="p-4 rounded-lg bg-gray-50 dark:bg-neutral-800">
|
||||
<div className="text-sm text-gray-600 dark:text-neutral-400">Total Scenarios</div>
|
||||
<div className="text-2xl font-semibold dark:text-neutral-200">{totalScenarios}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-green-50">
|
||||
<div className="text-sm text-green-600">Passed</div>
|
||||
<div className="text-2xl font-semibold text-green-700">{passedScenarios}</div>
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20">
|
||||
<div className="text-sm text-green-600 dark:text-green-400">Passed</div>
|
||||
<div className="text-2xl font-semibold text-green-700 dark:text-green-400">{passedScenarios}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-red-50">
|
||||
<div className="text-sm text-red-600">Failed</div>
|
||||
<div className="text-2xl font-semibold text-red-700">{failedScenarios}</div>
|
||||
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20">
|
||||
<div className="text-sm text-red-600 dark:text-red-400">Failed</div>
|
||||
<div className="text-2xl font-semibold text-red-700 dark:text-red-400">{failedScenarios}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -229,23 +236,30 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
|
|||
return scenario && (
|
||||
<div
|
||||
key={scenarioId}
|
||||
className={`border rounded-lg overflow-hidden ${
|
||||
result?.result === 'pass' ? 'bg-green-50 border-green-200' :
|
||||
result?.result === 'fail' ? 'bg-red-50 border-red-200' :
|
||||
'bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
className={clsx(
|
||||
"border dark:border-neutral-800 rounded-lg overflow-hidden",
|
||||
"transition-colors duration-200",
|
||||
result?.result === 'pass'
|
||||
? 'bg-green-50/50 dark:bg-green-900/10 border-green-200 dark:border-green-900/50'
|
||||
: result?.result === 'fail'
|
||||
? 'bg-red-50/50 dark:bg-red-900/10 border-red-200 dark:border-red-900/50'
|
||||
: 'bg-gray-50/50 dark:bg-neutral-900/50 border-gray-200 dark:border-neutral-800'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="p-3 flex items-center justify-between cursor-pointer hover:bg-opacity-80"
|
||||
className={clsx(
|
||||
"p-3 flex items-center justify-between cursor-pointer",
|
||||
"hover:bg-white/50 dark:hover:bg-neutral-800/50"
|
||||
)}
|
||||
onClick={(e) => toggleScenario(scenarioId, e)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isScenarioExpanded ? (
|
||||
<ChevronDownIcon className="h-4 w-4 text-gray-600" />
|
||||
<ChevronDownIcon className="h-4 w-4 text-gray-600 dark:text-neutral-400" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4 text-gray-600" />
|
||||
<ChevronRightIcon className="h-4 w-4 text-gray-600 dark:text-neutral-400" />
|
||||
)}
|
||||
<span className="font-medium text-gray-900">{scenario.name}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-neutral-200">{scenario.name}</span>
|
||||
</div>
|
||||
{result && (
|
||||
<span className={getStatusClass(result.result)}>
|
||||
|
|
@ -255,29 +269,29 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
|
|||
</div>
|
||||
|
||||
{isScenarioExpanded && (
|
||||
<div className="p-3 border-t border-opacity-50 space-y-4">
|
||||
<div className="p-3 border-t border-opacity-50 dark:border-neutral-800 space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Description</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Description</div>
|
||||
<div className="text-sm text-gray-700 dark:text-neutral-300">
|
||||
{scenario.description}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Criteria</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Criteria</div>
|
||||
<div className="text-sm text-gray-700 dark:text-neutral-300">
|
||||
{scenario.criteria || 'No criteria specified'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Context</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Context</div>
|
||||
<div className="text-sm text-gray-700 dark:text-neutral-300">
|
||||
{scenario.context || 'No context provided'}
|
||||
</div>
|
||||
</div>
|
||||
{result && (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Result Details</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Result Details</div>
|
||||
<div className="text-sm text-gray-700 dark:text-neutral-300">
|
||||
{result.details}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { FormSection } from '../../../../lib/components/form-section';
|
|||
import { StructuredPanel, ActionButton } from "../../../../lib/components/structured-panel";
|
||||
import clsx from "clsx";
|
||||
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@nextui-org/react";
|
||||
import { SectionHeader, ListItem } from "../../../../lib/components/structured-list";
|
||||
|
||||
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
|
||||
|
||||
|
|
@ -72,10 +73,9 @@ export function ScenarioViewer({ scenario, onSave, onClose }: ScenarioViewerProp
|
|||
</ActionButton>
|
||||
].filter(Boolean)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-6 w-full">
|
||||
<FormSection>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormSection label="Name" showDivider>
|
||||
<EditableField
|
||||
label="NAME"
|
||||
value={editedScenario.name}
|
||||
onChange={(value) => handleChange('name', value)}
|
||||
multiline={false}
|
||||
|
|
@ -86,9 +86,8 @@ export function ScenarioViewer({ scenario, onSave, onClose }: ScenarioViewerProp
|
|||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection>
|
||||
<FormSection label="Description" showDivider>
|
||||
<EditableField
|
||||
label="DESCRIPTION"
|
||||
value={editedScenario.description}
|
||||
onChange={(value) => handleChange('description', value)}
|
||||
multiline={true}
|
||||
|
|
@ -98,9 +97,8 @@ export function ScenarioViewer({ scenario, onSave, onClose }: ScenarioViewerProp
|
|||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection>
|
||||
<FormSection label="Criteria" showDivider>
|
||||
<EditableField
|
||||
label="CRITERIA"
|
||||
value={editedScenario.criteria}
|
||||
onChange={(value) => handleChange('criteria', value)}
|
||||
multiline={true}
|
||||
|
|
@ -110,9 +108,8 @@ export function ScenarioViewer({ scenario, onSave, onClose }: ScenarioViewerProp
|
|||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection>
|
||||
<FormSection label="Context">
|
||||
<EditableField
|
||||
label="CONTEXT"
|
||||
value={editedScenario.context}
|
||||
onChange={(value) => handleChange('context', value)}
|
||||
multiline={true}
|
||||
|
|
@ -126,47 +123,6 @@ export function ScenarioViewer({ scenario, onSave, onClose }: ScenarioViewerProp
|
|||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ title, onAdd }: { title: string; onAdd: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2 py-1 mt-4 first:mt-0 border-b border-gray-200">
|
||||
<div className="text-xs font-semibold text-gray-400 uppercase">{title}</div>
|
||||
<ActionButton
|
||||
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={onAdd}
|
||||
>
|
||||
Add
|
||||
</ActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListItem({
|
||||
name,
|
||||
isSelected,
|
||||
onClick,
|
||||
rightElement
|
||||
}: {
|
||||
name: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
rightElement?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={clsx("flex items-center justify-between rounded-md px-2 py-1", {
|
||||
"bg-gray-100": isSelected,
|
||||
"hover:bg-gray-50": !isSelected,
|
||||
})}
|
||||
>
|
||||
<div className="truncate text-sm">{name}</div>
|
||||
{rightElement}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ScenarioDropdown({
|
||||
name,
|
||||
onRun,
|
||||
|
|
|
|||
|
|
@ -1,536 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { PlusIcon, PencilIcon, XMarkIcon, EllipsisVerticalIcon, TrashIcon, ChevronRightIcon, PlayIcon, ChevronDownIcon, ChevronLeftIcon } from '@heroicons/react/24/outline';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
getScenarios,
|
||||
createScenario,
|
||||
updateScenario,
|
||||
deleteScenario,
|
||||
getRuns,
|
||||
getRun,
|
||||
getRunResults,
|
||||
createRun,
|
||||
createRunResult,
|
||||
updateRunStatus,
|
||||
createAggregateResult,
|
||||
deleteRun,
|
||||
} from '../../../actions/simulation_actions';
|
||||
import { type WithStringId } from '../../../lib/types/types';
|
||||
import { Scenario, SimulationRun, SimulationResult } from "../../../lib/types/testing_types";
|
||||
import { Workflow } from "../../../lib/types/workflow_types";
|
||||
import { z } from 'zod';
|
||||
import { SimulationResultCard, ScenarioResultCard } from './components/RunComponents';
|
||||
import { ScenarioViewer } from './components/ScenarioComponents';
|
||||
import { fetchWorkflow } from '../../../actions/workflow_actions';
|
||||
|
||||
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
|
||||
type SimulationRunType = WithStringId<z.infer<typeof SimulationRun>>;
|
||||
type SimulationResultType = WithStringId<z.infer<typeof SimulationResult>>;
|
||||
|
||||
type SimulationReport = {
|
||||
totalScenarios: number;
|
||||
passedScenarios: number;
|
||||
failedScenarios: number;
|
||||
results: z.infer<typeof SimulationResult>[];
|
||||
timestamp: Date;
|
||||
};
|
||||
|
||||
const dummySimulator = async (scenario: ScenarioType, runId: string, projectId: string): Promise<z.infer<typeof SimulationResult>> => {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
const passed = Math.random() > 0.5;
|
||||
|
||||
const result: z.infer<typeof SimulationResult> = {
|
||||
projectId: projectId,
|
||||
runId: runId,
|
||||
scenarioId: scenario._id,
|
||||
result: passed ? 'pass' : 'fail' as const,
|
||||
details: passed
|
||||
? "The bot successfully completed the conversation"
|
||||
: "The bot could not handle the conversation",
|
||||
};
|
||||
|
||||
await createRunResult(
|
||||
projectId,
|
||||
runId,
|
||||
scenario._id,
|
||||
result.result,
|
||||
result.details
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export default function SimulationApp() {
|
||||
const { projectId } = useParams();
|
||||
const router = useRouter();
|
||||
const [scenarios, setScenarios] = useState<ScenarioType[]>([]);
|
||||
const [selectedScenario, setSelectedScenario] = useState<ScenarioType | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [menuOpenScenarioId, setMenuOpenScenarioId] = useState<string | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [simulationReport, setSimulationReport] = useState<SimulationReport | null>(null);
|
||||
const [expandedResults, setExpandedResults] = useState<Set<string>>(new Set());
|
||||
const [runs, setRuns] = useState<SimulationRunType[]>([]);
|
||||
const [activeRun, setActiveRun] = useState<SimulationRunType | null>(null);
|
||||
const [runResults, setRunResults] = useState<SimulationResultType[]>([]);
|
||||
const [isLoadingRuns, setIsLoadingRuns] = useState(true);
|
||||
const [allRunResults, setAllRunResults] = useState<Record<string, SimulationResultType[]>>({});
|
||||
const [workflowVersions, setWorkflowVersions] = useState<Record<string, WithStringId<z.infer<typeof Workflow>>>>({});
|
||||
const [menuOpenId, setMenuOpenIdState] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const runsPerPage = 10;
|
||||
|
||||
const setMenuOpenId = useCallback((id: string | null) => {
|
||||
setMenuOpenIdState(id);
|
||||
}, []);
|
||||
|
||||
// Load scenarios on mount
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
getScenarios(projectId as string).then(setScenarios);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuOpenScenarioId) {
|
||||
const closeMenu = () => setMenuOpenScenarioId(null);
|
||||
window.addEventListener('click', closeMenu);
|
||||
return () => window.removeEventListener('click', closeMenu);
|
||||
}
|
||||
}, [menuOpenScenarioId]);
|
||||
|
||||
// Modify the fetchRuns function to also fetch results
|
||||
const fetchRuns = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setIsLoadingRuns(true);
|
||||
try {
|
||||
const runsData = await getRuns(projectId as string);
|
||||
setRuns(runsData);
|
||||
|
||||
// Fetch results for all runs
|
||||
const resultsPromises = runsData.map(run =>
|
||||
getRunResults(projectId as string, run._id)
|
||||
);
|
||||
const allResults = await Promise.all(resultsPromises);
|
||||
|
||||
// Create a map of run ID to results
|
||||
const resultsMap = runsData.reduce((acc, run, index) => ({
|
||||
...acc,
|
||||
[run._id]: allResults[index]
|
||||
}), {});
|
||||
|
||||
setAllRunResults(resultsMap);
|
||||
} catch (error) {
|
||||
console.error('Error fetching runs:', error);
|
||||
} finally {
|
||||
setIsLoadingRuns(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Update the useEffect hooks to include fetchRuns
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
fetchRuns();
|
||||
}, [projectId, fetchRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !activeRun || activeRun.status === 'completed' || activeRun.status === 'cancelled') return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const updatedRun = await getRun(projectId as string, activeRun._id);
|
||||
setActiveRun(updatedRun);
|
||||
|
||||
if (updatedRun.status === 'completed') {
|
||||
const results = await getRunResults(projectId as string, activeRun._id);
|
||||
setRunResults(results);
|
||||
fetchRuns(); // Refresh the runs list
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling run status:', error);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [activeRun, projectId, fetchRuns]);
|
||||
|
||||
const createNewScenario = async () => {
|
||||
if (!projectId) return;
|
||||
const newScenarioId = await createScenario(
|
||||
projectId as string,
|
||||
'New Scenario',
|
||||
''
|
||||
);
|
||||
// Refresh scenarios list
|
||||
const updatedScenarios = await getScenarios(projectId as string);
|
||||
setScenarios(updatedScenarios);
|
||||
const newScenario = updatedScenarios.find(s => s._id === newScenarioId);
|
||||
if (newScenario) {
|
||||
setSelectedScenario(newScenario);
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateScenario = async (updatedScenario: ScenarioType) => {
|
||||
if (!projectId) return;
|
||||
|
||||
// First verify the scenario exists and get its current state
|
||||
const currentScenarios = await getScenarios(projectId as string);
|
||||
const existingScenario = currentScenarios.find(s => s._id === updatedScenario._id);
|
||||
|
||||
if (!existingScenario) {
|
||||
console.error('Scenario not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update the specific fields that have changed
|
||||
await updateScenario(
|
||||
projectId as string,
|
||||
updatedScenario._id,
|
||||
{
|
||||
name: updatedScenario.name,
|
||||
description: updatedScenario.description,
|
||||
criteria: updatedScenario.criteria,
|
||||
context: updatedScenario.context,
|
||||
}
|
||||
);
|
||||
|
||||
// Just refresh the scenarios list without setting selected scenario
|
||||
const updatedScenarios = await getScenarios(projectId as string);
|
||||
setScenarios(updatedScenarios);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCloseScenario = () => {
|
||||
setSelectedScenario(null);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleDeleteScenario = async (scenarioId: string) => {
|
||||
if (!projectId) return;
|
||||
await deleteScenario(projectId as string, scenarioId);
|
||||
const updatedScenarios = await getScenarios(projectId as string);
|
||||
setScenarios(updatedScenarios);
|
||||
if (selectedScenario?._id === scenarioId) {
|
||||
setSelectedScenario(null);
|
||||
setIsEditing(false);
|
||||
}
|
||||
setMenuOpenScenarioId(null);
|
||||
};
|
||||
|
||||
const runAllScenarios = async () => {
|
||||
if (!projectId) return;
|
||||
setIsRunning(true);
|
||||
setSimulationReport(null);
|
||||
|
||||
try {
|
||||
// Get workflowId from localStorage
|
||||
const workflowId = localStorage.getItem(`lastWorkflowId_${projectId}`);
|
||||
if (!workflowId) {
|
||||
throw new Error('No workflow selected. Please select a workflow first.');
|
||||
}
|
||||
|
||||
// First verify the workflow exists before creating the run
|
||||
let workflow;
|
||||
try {
|
||||
workflow = await fetchWorkflow(projectId as string, workflowId);
|
||||
} catch (error) {
|
||||
// If workflow doesn't exist, clear localStorage and throw error
|
||||
localStorage.removeItem(`lastWorkflowId_${projectId}`);
|
||||
throw new Error('Selected workflow no longer exists. Please select a new workflow.');
|
||||
}
|
||||
|
||||
const newRun = await createRun(
|
||||
projectId as string,
|
||||
scenarios.map(s => s._id),
|
||||
workflowId
|
||||
);
|
||||
setActiveRun(newRun);
|
||||
|
||||
// Store workflow version
|
||||
setWorkflowVersions(prev => ({
|
||||
...prev,
|
||||
[workflowId]: workflow
|
||||
}));
|
||||
|
||||
const shouldMock = process.env.NEXT_PUBLIC_MOCK_SIMULATION_RESULTS === 'true';
|
||||
|
||||
if (shouldMock) {
|
||||
console.log('Using mock simulation...');
|
||||
|
||||
await updateRunStatus(projectId as string, newRun._id, 'running');
|
||||
|
||||
// Run all scenarios and collect results
|
||||
const mockResults = await Promise.all(
|
||||
scenarios.map(scenario =>
|
||||
dummySimulator(scenario, newRun._id, projectId as string)
|
||||
)
|
||||
);
|
||||
|
||||
// Calculate and store aggregate results before marking as complete
|
||||
const total = scenarios.length;
|
||||
const pass = mockResults.filter(r => r.result === 'pass').length;
|
||||
const fail = mockResults.filter(r => r.result === 'fail').length;
|
||||
|
||||
await createAggregateResult(
|
||||
projectId as string,
|
||||
newRun._id,
|
||||
total,
|
||||
pass,
|
||||
fail
|
||||
);
|
||||
|
||||
await updateRunStatus(
|
||||
projectId as string,
|
||||
newRun._id,
|
||||
'completed',
|
||||
new Date().toISOString()
|
||||
);
|
||||
|
||||
const results = await getRunResults(projectId as string, newRun._id);
|
||||
setRunResults(results);
|
||||
|
||||
const updatedRun = await getRun(projectId as string, newRun._id);
|
||||
setActiveRun(updatedRun);
|
||||
}
|
||||
|
||||
await fetchRuns();
|
||||
} catch (error) {
|
||||
console.error('Error starting scenarios:', error);
|
||||
alert(error instanceof Error ? error.message : 'An error occurred while starting scenarios');
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runSingleScenario = (scenario: ScenarioType) => {
|
||||
// Store scenario ID in localStorage instead of URL parameter
|
||||
localStorage.setItem('pendingScenarioId', scenario._id);
|
||||
// Navigate to the playground without query parameter
|
||||
router.push(`/projects/${projectId}/workflow`);
|
||||
setMenuOpenScenarioId(null);
|
||||
};
|
||||
|
||||
// Update the workflow versions fetching effect
|
||||
useEffect(() => {
|
||||
if (!projectId || !runs.length) return;
|
||||
|
||||
const fetchWorkflowVersions = async () => {
|
||||
const workflowIds = Array.from(new Set(runs.map(run => run.workflowId)));
|
||||
const versions: Record<string, WithStringId<z.infer<typeof Workflow>>> = {};
|
||||
|
||||
for (const workflowId of workflowIds) {
|
||||
try {
|
||||
const workflow = await fetchWorkflow(projectId as string, workflowId);
|
||||
versions[workflowId] = workflow;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching workflow ${workflowId}:`, error);
|
||||
// Add a placeholder for deleted/invalid workflows
|
||||
versions[workflowId] = {
|
||||
_id: workflowId,
|
||||
name: "Deleted/Invalid Workflow",
|
||||
projectId: projectId as string,
|
||||
agents: [],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
startAgent: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setWorkflowVersions(versions);
|
||||
};
|
||||
|
||||
fetchWorkflowVersions();
|
||||
}, [projectId, runs]);
|
||||
|
||||
const handleCancelRun = async (runId: string) => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
await updateRunStatus(projectId as string, runId, 'cancelled');
|
||||
await fetchRuns(); // Refresh the runs list
|
||||
} catch (error) {
|
||||
console.error('Error cancelling run:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRun = async (runId: string) => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
await deleteRun(projectId as string, runId);
|
||||
await fetchRuns(); // Refresh the runs list
|
||||
} catch (error) {
|
||||
console.error('Error deleting run:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const indexOfLastRun = currentPage * runsPerPage;
|
||||
const indexOfFirstRun = indexOfLastRun - runsPerPage;
|
||||
const currentRuns = runs.slice(indexOfFirstRun, indexOfLastRun);
|
||||
const totalPages = Math.ceil(runs.length / runsPerPage);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{/* Left sidebar */}
|
||||
<div className="w-64 border-r border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Scenarios</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={createNewScenario}
|
||||
className="p-2 rounded-full hover:bg-gray-100"
|
||||
title="New Scenario"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{scenarios.map(scenario => (
|
||||
<div
|
||||
key={scenario._id}
|
||||
className={`p-2 rounded flex justify-between items-center ${
|
||||
selectedScenario?._id === scenario._id
|
||||
? 'bg-blue-100'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => setSelectedScenario(scenario)}
|
||||
className="cursor-pointer flex-grow"
|
||||
>
|
||||
{scenario.name}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpenScenarioId(menuOpenScenarioId === scenario._id ? null : scenario._id);
|
||||
}}
|
||||
className="p-1 rounded-full hover:bg-gray-200"
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
{menuOpenScenarioId === scenario._id && (
|
||||
<div className="absolute right-0 mt-1 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-10">
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
runSingleScenario(scenario);
|
||||
}}
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full"
|
||||
>
|
||||
<PlayIcon className="h-4 w-4 mr-2" />
|
||||
Run Scenario
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteScenario(scenario._id);
|
||||
}}
|
||||
className="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 w-full"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4 mr-2" />
|
||||
Delete Scenario
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
{selectedScenario ? (
|
||||
<ScenarioViewer
|
||||
scenario={selectedScenario}
|
||||
onSave={handleUpdateScenario}
|
||||
onClose={handleCloseScenario}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Simulation Runs</h1>
|
||||
<button
|
||||
onClick={runAllScenarios}
|
||||
disabled={isRunning || scenarios.length === 0}
|
||||
className={`px-4 py-2 rounded-md text-white ${
|
||||
isRunning || scenarios.length === 0
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isRunning ? 'Running...' : 'Run All Scenarios'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoadingRuns ? (
|
||||
<div className="text-center py-4">Loading runs...</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">No simulation runs yet</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{currentRuns.map((run) => (
|
||||
<SimulationResultCard
|
||||
key={run._id}
|
||||
run={run}
|
||||
results={allRunResults[run._id] || []}
|
||||
scenarios={scenarios}
|
||||
workflow={workflowVersions[run.workflowId]}
|
||||
onCancelRun={handleCancelRun}
|
||||
onDeleteRun={handleDeleteRun}
|
||||
menuOpenId={menuOpenId}
|
||||
setMenuOpenId={setMenuOpenId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center space-x-4 mt-6">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`p-2 rounded-full ${
|
||||
currentPage === 1
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<span className="text-sm text-gray-600">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`p-2 rounded-full ${
|
||||
currentPage === totalPages
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { deleteDataSource } from "../../../../actions/datasource_actions";
|
||||
import { FormStatusButton } from "../../../../lib/components/FormStatusButton";
|
||||
import { FormStatusButton } from "../../../../lib/components/form-status-button";
|
||||
|
||||
export function DeleteSource({
|
||||
projectId,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import { PageSection } from "../../../../lib/components/PageSection";
|
||||
import { PageSection } from "../../../../lib/components/page-section";
|
||||
import { WithStringId } from "../../../../lib/types/types";
|
||||
import { DataSourceDoc } from "../../../../lib/types/datasource_types";
|
||||
import { DataSource } from "../../../../lib/types/datasource_types";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import { PageSection } from "../../../../lib/components/PageSection";
|
||||
import { PageSection } from "../../../../lib/components/page-section";
|
||||
import { WithStringId } from "../../../../lib/types/types";
|
||||
import { DataSourceDoc } from "../../../../lib/types/datasource_types";
|
||||
import { DataSource } from "../../../../lib/types/datasource_types";
|
||||
|
|
@ -11,7 +11,7 @@ import { Spinner } from "@nextui-org/react";
|
|||
import { Pagination } from "@nextui-org/react";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { Textarea } from "@nextui-org/react";
|
||||
import { FormStatusButton } from "../../../../lib/components/FormStatusButton";
|
||||
import { FormStatusButton } from "../../../../lib/components/form-status-button";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
function UrlListItem({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
import { WithStringId } from "../../../../lib/types/types";
|
||||
import { DataSource } from "../../../../lib/types/datasource_types";
|
||||
import { PageSection } from "../../../../lib/components/PageSection";
|
||||
import { PageSection } from "../../../../lib/components/page-section";
|
||||
import { ToggleSource } from "../toggle-source";
|
||||
import { Spinner } from "@nextui-org/react";
|
||||
import { SourceStatus } from "../source-status";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
'use client';
|
||||
import { FormStatusButton } from "../../../../lib/components/FormStatusButton";
|
||||
import { FormStatusButton } from "../../../../lib/components/form-status-button";
|
||||
import { RefreshCwIcon } from "lucide-react";
|
||||
|
||||
export function Recrawl({
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { Input, Select, SelectItem, Textarea } from "@nextui-org/react"
|
||||
import { useState } from "react";
|
||||
import { createDataSource, addDocsToDataSource } from "../../../../actions/datasource_actions";
|
||||
import { FormStatusButton } from "../../../../lib/components/FormStatusButton";
|
||||
import { FormStatusButton } from "../../../../lib/components/form-status-button";
|
||||
import { DataSourceIcon } from "../../../../lib/components/datasource-icon";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export function SourcesList({
|
|||
<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} />
|
||||
<ToggleSource projectId={projectId} sourceId={source._id} active={source.active} compact={true} className="bg-default-100" />
|
||||
</td>
|
||||
</tr>;
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ export function ToggleSource({
|
|||
sourceId,
|
||||
active,
|
||||
compact=false,
|
||||
className
|
||||
}: {
|
||||
projectId: string;
|
||||
sourceId: string;
|
||||
active: boolean;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isActive, setIsActive] = useState(active);
|
||||
|
|
@ -30,12 +32,16 @@ export function ToggleSource({
|
|||
return <div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex items-center gap-1">
|
||||
<Switch
|
||||
size={compact ? 'sm' : 'md'}
|
||||
disabled={loading}
|
||||
size="sm"
|
||||
isSelected={isActive}
|
||||
onValueChange={handleActiveSwitchChange}
|
||||
disabled={loading}
|
||||
aria-label="Toggle source active state"
|
||||
classNames={{
|
||||
wrapper: `light:bg-default-200 dark:bg-default-100 group-data-[selected=true]:bg-primary-500 ${className || ''}`
|
||||
}}
|
||||
>
|
||||
{isActive ? 'Active' : 'Inactive'}
|
||||
{isActive ? "Active" : "Inactive"}
|
||||
</Switch>
|
||||
{loading && <Spinner size="sm" />}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@ import { WithStringId } from "../../../lib/types/types";
|
|||
import { AgenticAPITool } from "../../../lib/types/agents_api_types";
|
||||
import { WorkflowPrompt, WorkflowAgent, Workflow } from "../../../lib/types/workflow_types";
|
||||
import { DataSource } from "../../../lib/types/datasource_types";
|
||||
import { Button, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Radio, RadioGroup, Select, SelectItem } from "@nextui-org/react";
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Radio, RadioGroup, Divider } from "@nextui-org/react";
|
||||
import { z } from "zod";
|
||||
import { DataSourceIcon } from "../../../lib/components/datasource-icon";
|
||||
import { ActionButton, StructuredPanel } from "../../../lib/components/structured-panel";
|
||||
import { FormSection } from "../../../lib/components/form-section";
|
||||
import { EditableField } from "../../../lib/components/editable-field";
|
||||
import { Label } from "../../../lib/components/label";
|
||||
import { PlusIcon, SparklesIcon } from "lucide-react";
|
||||
import { PlusIcon, SparklesIcon, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { List } from "./config_list";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { usePreviewModal } from "./preview-modal";
|
||||
|
|
@ -18,6 +19,7 @@ import { Textarea } from "@nextui-org/react";
|
|||
import { PreviewModalProvider } from "./preview-modal";
|
||||
import { CopilotMessage } from "@/app/lib/types/copilot_types";
|
||||
import { getCopilotAgentInstructions } from "@/app/actions/copilot_actions";
|
||||
import { Dropdown as CustomDropdown } from "../../../lib/components/dropdown";
|
||||
|
||||
export function AgentConfig({
|
||||
projectId,
|
||||
|
|
@ -42,6 +44,8 @@ export function AgentConfig({
|
|||
handleUpdate: (agent: z.infer<typeof WorkflowAgent>) => void,
|
||||
handleClose: () => void,
|
||||
}) {
|
||||
const [isAdvancedConfigOpen, setIsAdvancedConfigOpen] = useState(false);
|
||||
|
||||
const atMentions = [];
|
||||
for (const a of agents) {
|
||||
if (a.disabled || a.name === agent.name) {
|
||||
|
|
@ -83,85 +87,79 @@ export function AgentConfig({
|
|||
</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" };
|
||||
}
|
||||
// validate against this regex: ^[a-zA-Z0-9_-]+$
|
||||
if (!/^[a-zA-Z0-9_-\s]+$/.test(value)) {
|
||||
return { valid: false, errorMessage: "Name must contain only letters, numbers, underscores, hyphens, and spaces" };
|
||||
}
|
||||
return { valid: true };
|
||||
}}
|
||||
/>
|
||||
<Divider />
|
||||
</>}
|
||||
|
||||
<EditableField
|
||||
key="description"
|
||||
label="Description"
|
||||
value={agent.description || ""}
|
||||
onChange={(value) => {
|
||||
handleUpdate({
|
||||
...agent,
|
||||
description: value
|
||||
});
|
||||
}}
|
||||
placeholder="Enter a description for this agent"
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label label="Instructions" />
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
startContent={<SparklesIcon size={16} />}
|
||||
onPress={() => setShowGenerateModal(true)}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full flex flex-col">
|
||||
{!agent.locked && (
|
||||
<FormSection showDivider>
|
||||
<EditableField
|
||||
key="instructions"
|
||||
value={agent.instructions}
|
||||
key="name"
|
||||
label="Name"
|
||||
value={agent.name}
|
||||
onChange={(value) => {
|
||||
handleUpdate({
|
||||
...agent,
|
||||
instructions: value
|
||||
name: value
|
||||
});
|
||||
}}
|
||||
markdown
|
||||
multiline
|
||||
mentions
|
||||
mentionsAtValues={atMentions}
|
||||
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" };
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-\s]+$/.test(value)) {
|
||||
return { valid: false, errorMessage: "Name must contain only letters, numbers, underscores, hyphens, and spaces" };
|
||||
}
|
||||
return { valid: true };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<FormSection showDivider>
|
||||
<EditableField
|
||||
key="description"
|
||||
label="Description"
|
||||
value={agent.description || ""}
|
||||
onChange={(value) => {
|
||||
handleUpdate({
|
||||
...agent,
|
||||
description: value
|
||||
});
|
||||
}}
|
||||
placeholder="Enter a description for this agent"
|
||||
multiline
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<div className="w-full flex flex-col">
|
||||
<FormSection showDivider>
|
||||
<EditableField
|
||||
key="instructions"
|
||||
label="Instructions"
|
||||
value={agent.instructions}
|
||||
onChange={(value) => {
|
||||
handleUpdate({
|
||||
...agent,
|
||||
instructions: value
|
||||
});
|
||||
}}
|
||||
markdown
|
||||
multiline
|
||||
mentions
|
||||
mentionsAtValues={atMentions}
|
||||
showSaveButton={true}
|
||||
showDiscardButton={true}
|
||||
showGenerateButton={{
|
||||
show: showGenerateModal,
|
||||
setShow: setShowGenerateModal
|
||||
}}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection showDivider>
|
||||
<EditableField
|
||||
key="examples"
|
||||
label="Examples"
|
||||
value={agent.examples || ""}
|
||||
onChange={(value) => {
|
||||
handleUpdate({
|
||||
|
|
@ -171,131 +169,157 @@ export function AgentConfig({
|
|||
}}
|
||||
placeholder="Enter examples for this agent"
|
||||
markdown
|
||||
label="Examples"
|
||||
multiline
|
||||
mentions
|
||||
mentionsAtValues={atMentions}
|
||||
showSaveButton={true}
|
||||
showDiscardButton={true}
|
||||
/>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col gap-4 items-start">
|
||||
<Label label="RAG (beta)" />
|
||||
<List
|
||||
items={agent.ragDataSources?.map((source) => ({
|
||||
id: source,
|
||||
node: <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>
|
||||
})) || []}
|
||||
onRemove={(id) => {
|
||||
const newSources = agent.ragDataSources?.filter((s) => s !== id);
|
||||
handleUpdate({
|
||||
...agent,
|
||||
ragDataSources: newSources
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
startContent={<PlusIcon size={16} />}
|
||||
>
|
||||
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} />}
|
||||
<FormSection label="RAG (beta)" showDivider>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
startContent={<PlusIcon size={16} />}
|
||||
className="w-fit text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
{ds.name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{agent.ragDataSources !== undefined && agent.ragDataSources.length > 0 && <>
|
||||
<Label label="Advanced RAG configuration" />
|
||||
<div className="ml-4 flex flex-col gap-4">
|
||||
<Label label="Return type" />
|
||||
<RadioGroup
|
||||
size="sm"
|
||||
orientation="horizontal"
|
||||
value={agent.ragReturnType}
|
||||
onValueChange={(value) => handleUpdate({
|
||||
Add data source
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu onAction={(key) => handleUpdate({
|
||||
...agent,
|
||||
ragReturnType: value as z.infer<typeof WorkflowAgent>['ragReturnType']
|
||||
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} />}
|
||||
className="text-foreground dark:text-gray-300"
|
||||
>
|
||||
{ds.name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{(agent.ragDataSources || []).map((source) => {
|
||||
const ds = dataSources.find((ds) => ds._id === source);
|
||||
return (
|
||||
<div
|
||||
key={source}
|
||||
className="group flex items-center justify-between p-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1 rounded-md bg-white dark:bg-gray-700">
|
||||
<DataSourceIcon type={ds?.data.type} />
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{ds?.name || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-gray-500 hover:text-red-500"
|
||||
onClick={() => {
|
||||
const newSources = agent.ragDataSources?.filter((s) => s !== source);
|
||||
handleUpdate({
|
||||
...agent,
|
||||
ragDataSources: newSources
|
||||
});
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
>
|
||||
<Radio value="chunks">Chunks</Radio>
|
||||
<Radio value="content">Content</Radio>
|
||||
</RadioGroup>
|
||||
<Label label="No. of matches" />
|
||||
<Input
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
className="w-20"
|
||||
value={agent.ragK.toString()}
|
||||
onValueChange={(value) => handleUpdate({
|
||||
...agent,
|
||||
ragK: parseInt(value)
|
||||
})}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{agent.ragDataSources !== undefined && agent.ragDataSources.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsAdvancedConfigOpen(!isAdvancedConfigOpen)}
|
||||
className="flex items-center gap-2 text-xs font-medium text-gray-400 dark:text-gray-500 uppercase hover:text-gray-500 dark:hover:text-gray-400"
|
||||
>
|
||||
{isAdvancedConfigOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
Advanced RAG configuration
|
||||
</button>
|
||||
|
||||
{isAdvancedConfigOpen && (
|
||||
<div className="ml-4 flex flex-col gap-4">
|
||||
<Label label="Return type" />
|
||||
<RadioGroup
|
||||
size="sm"
|
||||
orientation="horizontal"
|
||||
value={agent.ragReturnType}
|
||||
onValueChange={(value) => handleUpdate({
|
||||
...agent,
|
||||
ragReturnType: value as z.infer<typeof WorkflowAgent>['ragReturnType']
|
||||
})}
|
||||
classNames={{
|
||||
label: "text-foreground dark:text-gray-300"
|
||||
}}
|
||||
>
|
||||
<Radio value="chunks">Chunks</Radio>
|
||||
<Radio value="content">Content</Radio>
|
||||
</RadioGroup>
|
||||
<Label label="No. of matches" />
|
||||
<Input
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
className="w-20 text-foreground dark:text-gray-300"
|
||||
value={agent.ragK.toString()}
|
||||
onValueChange={(value) => handleUpdate({
|
||||
...agent,
|
||||
ragK: parseInt(value)
|
||||
})}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
</>}
|
||||
</FormSection>
|
||||
|
||||
<Divider />
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<Label label="Model" />
|
||||
<Select
|
||||
variant="bordered"
|
||||
selectedKeys={[agent.model]}
|
||||
size="sm"
|
||||
onSelectionChange={(keys) => handleUpdate({
|
||||
<FormSection label="Model" showDivider>
|
||||
<CustomDropdown
|
||||
value={agent.model}
|
||||
options={WorkflowAgent.shape.model.options.map((model) => ({
|
||||
key: model.value,
|
||||
label: model.value
|
||||
}))}
|
||||
onChange={(value) => handleUpdate({
|
||||
...agent,
|
||||
model: keys.currentKey! as z.infer<typeof WorkflowAgent>['model']
|
||||
model: value as z.infer<typeof WorkflowAgent>['model']
|
||||
})}
|
||||
className="w-40"
|
||||
>
|
||||
{WorkflowAgent.shape.model.options.map((model) => (
|
||||
<SelectItem key={model.value} value={model.value}>{model.value}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<Label label="Conversation control after turn" />
|
||||
<Select
|
||||
variant="bordered"
|
||||
selectedKeys={[agent.controlType]}
|
||||
size="sm"
|
||||
onSelectionChange={(keys) => handleUpdate({
|
||||
<FormSection label="Conversation control after turn">
|
||||
<CustomDropdown
|
||||
value={agent.controlType}
|
||||
options={[
|
||||
{ key: "retain", label: "Retain control" },
|
||||
{ key: "relinquish_to_parent", label: "Relinquish to parent" },
|
||||
{ key: "relinquish_to_start", label: "Relinquish to 'start' agent" }
|
||||
]}
|
||||
onChange={(value) => handleUpdate({
|
||||
...agent,
|
||||
controlType: keys.currentKey! as z.infer<typeof WorkflowAgent>['controlType']
|
||||
controlType: value 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>
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<Divider />
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useRef, useEffect } from "react";
|
|||
import { ActionButton, StructuredPanel } from "../../../lib/components/structured-panel";
|
||||
import clsx from "clsx";
|
||||
import { EllipsisVerticalIcon } from "lucide-react";
|
||||
import { SectionHeader, ListItem } from "../../../lib/components/structured-list";
|
||||
|
||||
interface EntityListProps {
|
||||
agents: z.infer<typeof WorkflowAgent>[];
|
||||
|
|
@ -30,54 +31,6 @@ interface EntityListProps {
|
|||
onDeletePrompt: (name: string) => void;
|
||||
}
|
||||
|
||||
function SectionHeader({ title, onAdd }: { title: string; onAdd: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2 py-1 mt-4 first:mt-0 border-b border-gray-200">
|
||||
<div className="text-xs font-semibold text-gray-400 uppercase">{title}</div>
|
||||
<ActionButton
|
||||
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={onAdd}
|
||||
>
|
||||
Add
|
||||
</ActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListItem({
|
||||
name,
|
||||
isSelected,
|
||||
onClick,
|
||||
disabled,
|
||||
rightElement,
|
||||
selectedRef
|
||||
}: {
|
||||
name: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
rightElement?: React.ReactNode;
|
||||
selectedRef?: React.RefObject<HTMLButtonElement>;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
ref={selectedRef as any}
|
||||
onClick={onClick}
|
||||
className={clsx("flex items-center justify-between rounded-md px-2 py-1", {
|
||||
"bg-gray-100": isSelected,
|
||||
"hover:bg-gray-50": !isSelected,
|
||||
})}
|
||||
>
|
||||
<div className={clsx("truncate text-sm", {
|
||||
"text-gray-400": disabled,
|
||||
})}>{name}</div>
|
||||
{rightElement}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function EntityList({
|
||||
agents,
|
||||
tools,
|
||||
|
|
|
|||
|
|
@ -708,7 +708,7 @@ export function WorkflowEditor({
|
|||
|
||||
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-1 border-1 border-gray-200 rounded-md px-2 text-gray-800">
|
||||
<div className="workflow-version-selector flex items-center gap-1 px-2 text-gray-800 dark:text-gray-100">
|
||||
<WorkflowIcon size={16} />
|
||||
<EditableField
|
||||
key={state.present.workflow._id}
|
||||
|
|
@ -716,6 +716,7 @@ export function WorkflowEditor({
|
|||
onChange={handleRenameWorkflow}
|
||||
placeholder="Name this version"
|
||||
className="text-sm font-semibold"
|
||||
inline={true}
|
||||
/>
|
||||
{state.present.publishing && <Spinner size="sm" />}
|
||||
{isLive && <PublishedBadge />}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import logo from "@/public/rowboat-logo.png";
|
|||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { UserButton } from "../lib/components/user_button";
|
||||
import { ThemeToggle } from "../lib/components/theme-toggle";
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
|
|
@ -9,17 +10,20 @@ export default function Layout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return <>
|
||||
<header className="shrink-0 flex justify-between items-center px-4 py-2 border-b border-b-gray-100">
|
||||
<header className="shrink-0 flex justify-between items-center px-4 py-2 border-b border-border bg-background">
|
||||
<div className="flex items-center gap-12">
|
||||
<Link href="/">
|
||||
<Image
|
||||
src={logo}
|
||||
height={24}
|
||||
alt="RowBoat Labs Logo"
|
||||
/>
|
||||
<Image
|
||||
src={logo}
|
||||
height={24}
|
||||
alt="RowBoat Labs Logo"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<UserButton />
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<UserButton />
|
||||
</div>
|
||||
</header>
|
||||
<main className="grow overflow-auto">
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { cn, Input } from "@nextui-org/react";
|
|||
import { createProject } from "../../actions/project_actions";
|
||||
import { templates } from "../../lib/project_templates";
|
||||
import { WorkflowTemplate } from "../../lib/types/workflow_types";
|
||||
import { FormStatusButton } from "../../lib/components/FormStatusButton";
|
||||
import { FormStatusButton } from "../../lib/components/form-status-button";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { z } from "zod";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
48
apps/rowboat/app/providers/theme-provider.tsx
Normal file
48
apps/rowboat/app/providers/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undefined)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'light',
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(defaultTheme)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue