Mega UI revamp

This commit is contained in:
akhisud3195 2025-03-27 18:52:17 +05:30 committed by Ramnique Singh
parent 650f481a96
commit bcb686a20d
94 changed files with 6984 additions and 3889 deletions

0
.gitattributes vendored Normal file
View file

View file

@ -7,6 +7,32 @@
.text-balance {
text-wrap: balance;
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #E4E4E7 transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #E4E4E7;
border-radius: 3px;
}
/* Dark mode */
.dark .custom-scrollbar {
scrollbar-color: #3F3F46 transparent;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #3F3F46;
}
}
html, body {
@ -67,6 +93,27 @@ html, body {
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
/* Define a card class that will be used for all card-like components */
.card {
@apply rounded-xl border p-4
border-[#E5E7EB] dark:border-[#2E2E30]
bg-white dark:bg-[#1C1C1E]
shadow-[0_2px_8px_rgba(0,0,0,0.04)]
transition-all duration-200 ease-in-out;
}
.card:hover {
@apply shadow-[0_4px_12px_rgba(0,0,0,0.06)]
transform translate-y-[-1px];
}
/* Update input styles */
input, textarea, select {
@apply rounded-lg border-[#E5E7EB] dark:border-[#2E2E30]
bg-[#F3F4F6] dark:bg-[#2A2A2D]
focus:ring-2 focus:ring-indigo-500 focus:ring-opacity-50
transition-all duration-200;
}
}
@layer base {
@ -88,6 +135,20 @@ html, body {
.border-subtle {
@apply border-border dark:border-border/50;
}
/* Apply rounded corners to common interactive elements by default */
button,
input,
textarea,
select,
[role="button"],
.card,
.input,
.select,
.textarea,
.button {
@apply !rounded-lg;
}
}
* {
@ -97,4 +158,27 @@ html, body {
* {
@apply transition-colors duration-200;
}
}
/* Add Inter font */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
/* Set base font */
html {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
@keyframes slideUpAndFade {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-slideUpAndFade {
animation: slideUpAndFade 0.2s ease-out forwards;
}

View file

@ -1,32 +0,0 @@
'use client';
import { CopyIcon, CheckIcon } from "lucide-react";
import { useState } from "react";
export function CopyButton({
onCopy,
label,
successLabel,
}: {
onCopy: () => void;
label: string;
successLabel: string;
}) {
const [showCopySuccess, setShowCopySuccess] = useState(false);
const handleCopy = () => {
onCopy();
setShowCopySuccess(true);
setTimeout(() => {
setShowCopySuccess(false);
}, 500);
}
return <button onClick={handleCopy} className="0 text-gray-300 hover:text-gray-700 flex items-center gap-1 group">
{showCopySuccess ? (
<CheckIcon size={16} />
) : (
<CopyIcon size={16} />
)}
<div className="text-xs hidden group-hover:block">
{showCopySuccess ? successLabel : label}
</div>
</button>
}

View file

@ -0,0 +1,14 @@
'use client';
import { useFormStatus } from "react-dom";
import { Button, ButtonProps } from "@heroui/react";
export function FormStatusButton({
props
}: {
props: ButtonProps;
}) {
const { pending } = useFormStatus();
return <Button {...props} isLoading={pending} />;
}

View file

@ -1,12 +1,19 @@
'use client';
import { useFormStatus } from "react-dom";
import { Button, ButtonProps } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { ButtonHTMLAttributes } from "react";
export function FormStatusButton({
props
}: {
props: ButtonProps;
props: ButtonHTMLAttributes<HTMLButtonElement> & {
startContent?: React.ReactNode;
endContent?: React.ReactNode;
variant?: 'primary' | 'secondary' | 'tertiary';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
};
}) {
const { pending } = useFormStatus();

View file

@ -5,7 +5,7 @@ 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';
import { CopyButton } from '../../../components/common/copy-button';
export type Match = {
id: string;

View file

@ -1,21 +0,0 @@
'use client'
import { Moon, Sun } from "lucide-react"
import { useTheme } from "@/app/providers/theme-provider"
import { Button } from "@heroui/react"
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
return (
<Button
variant="light"
isIconOnly
onPress={toggleTheme}
aria-label="Toggle theme"
className="text-foreground"
>
{theme === 'light' ? <Moon size={20} /> : <Sun size={20} />}
</Button>
)
}

View file

@ -5,7 +5,7 @@ export const templates: { [key: string]: z.infer<typeof WorkflowTemplate> } = {
// Default template
'default': {
name: 'Blank Template',
description: 'A blank canvas to build your support agents.',
description: 'A blank canvas to build your agents.',
startAgent: "Example Agent",
agents: [
{
@ -82,270 +82,6 @@ You are an helpful customer support assistant
}
],
tools: [],
},
// single agent
"single_agent": {
"name": "Example Single Agent",
"description": "With tool calls and escalation.",
"startAgent": "Account Balance Checker",
"agents": [
{
"name": "Post process",
"type": "post_process",
"description": "Minimal post processing",
"instructions": "- Avoid adding any additional phrases such as 'Let me know if you need anything else!' or similar.",
"model": "gpt-4o",
"toggleAble": true,
"locked": true,
"global": true,
"ragReturnType": "chunks",
"ragK": 3,
"controlType": "relinquish_to_parent"
},
{
"name": "Escalation",
"type": "escalation",
"description": "Escalation agent",
"instructions": "## 🧑‍💼 Role:\nHandle scenarios where the system needs to escalate a request to a human representative.\n\n---\n## ⚙️ Steps to Follow:\n1. Inform the user that their details are being escalated to a human agent.\n2. Call [@tool:close_chat](#mention) to close the chat session.\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Escalating issues to human agents\n- Closing chat sessions\n\n❌ Out of Scope:\n- Handling queries that do not require escalation\n- Providing solutions without escalation\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Clearly inform the user about the escalation.\n- Ensure the chat is closed after escalation.\n\n🚫 Don'ts:\n- Attempt to resolve issues without escalation.\n- Leave the chat open after informing the user about escalation.\n",
"model": "gpt-4o",
"locked": true,
"toggleAble": false,
"ragReturnType": "chunks",
"ragK": 3,
"controlType": "retain",
"examples": "- **User** : I need help with something urgent.\n - **Agent response**: Your request is being escalated to a human agent.\n - **Agent actions**: Call [@tool:close_chat](#mention)\n\n- **User** : Can you escalate this issue?\n - **Agent response**: Your details are being escalated to a human agent.\n - **Agent actions**: Call [@tool:close_chat](#mention)"
},
{
"name": "Account Balance Checker",
"type": "conversation",
"description": "Agent to check the user's account balance.",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nAssist users in checking their account balance.\n\n---\n## ⚙️ Steps to Follow:\n1. Greet them with 'Hello, welcome to RowBoat Bank.'\n2. If the user hasn't provided their request yet, ask 'How may I help you today?'\n3. If the request is related to checking account balance, proceed with the following steps:\n - Ask the user to confirm the last 4 digits of their debit card.\n - Use [@tool:get_account_balance](#mention) to fetch the account balance.\n - Inform the user of their account balance based on the output of [@tool:get_account_balance](#mention) \n4. If the user requests to talk to a human, call [@agent:Escalation](#mention) .\n5. If the request is not related to checking account balance, inform the user: 'Sorry, I can only help you with account balance.'\n\n---\n## 🎯 Scope:\n✅ In Scope:\n- Fetching and providing account balance\n- Escalating to human agents upon request\n\n❌ Out of Scope:\n- Handling queries unrelated to account balance\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Always call get_account_balance to fetch the user's account balance\n- Be clear and concise in communication.\n- Call the Escalation agent if the user requests to speak with a human.\n\n🚫 Don'ts:\n- Extend the conversation beyond account balance checking.\n",
"model": "gpt-4o",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"controlType": "retain",
"examples": "- **User** : Can you tell me my account balance?\n - **Agent response**: Sure, can you confirm the last 4 digits of your debit card?\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n\n- **User** : The last 4 digits are 1234.\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n - **Agent response**: Your account balance is $500.\n\n- **User** : What's my balance?\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n - **Agent response**: Your account balance is $500.\n\n- **User** : I need help with my credit card.\n - **Agent response**: Sorry, I can only help you with account balance.\n\n- **User** : I need to know my balance, please.\n - **Agent response**: Of course, please provide the last 4 digits of your debit card.\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n\n- **User** : How much money do I have in my account?\n - **Agent response**: Please confirm the last 4 digits of your debit card to proceed.\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n\n- **User** : My card ends in 5678.\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n - **Agent response**: Your account balance is $500.\n\n- **User** : Can you check my balance?\n - **Agent response**: Certainly, could you provide the last 4 digits of your debit card?\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n\n- **User** : I want to know my account balance.\n - **Agent response**: Sure, please confirm the last 4 digits of your debit card.\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n\n- **User** : The last 4 digits are 9876.\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n - **Agent response**: Your account balance is $500.\n\n- **User** : Hi\n - **Agent response**: Hello, welcome to RowBoat Bank. How may I help you today?\n\n- **User** : Hi, I want to check my balance\n - **Agent response**: Hello, welcome to RowBoat Bank. Sure, can you confirm the last 4 digits of your debit card?\n - **Agent actions**: Call [@tool:get_account_balance](#mention)\n\n- **User** : Who are you?\n - **Agent response**: I'm a friendly RowBoat Bank assistant, how can I help you today?\n\n- **User** : Who built you?\n - **Agent response**: I was created by the RowBoat Bank team to assist you with your banking needs. How may I help you today?\n\n- **User** : How are you?\n - **Agent response**: I'm here to help you with your banking queries. How can I assist you today?\n\n- **User** : I want to talk to a human.\n - **Agent actions**: Call [@agent:Escalation](#mention)\n - **Agent response**: Your request is being escalated to a human agent."
}
],
"prompts": [
{
"name": "Style prompt",
"type": "style_prompt",
"prompt": "You should be empathetic and helpful."
},
{
"name": "Greeting",
"type": "greeting",
"prompt": "Hello! How can I help you?"
}
],
"tools": [
{
"name": "get_account_balance",
"description": "Return account balance typically around $15000 for the user.",
"parameters": {
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "The unique identifier for the user whose account balance is being queried."
}
},
"required": [
"user_id"
]
},
"mockTool": true,
"autoSubmitMockedResponse": true
},
{
"name": "close_chat",
"description": "return 'The chat is now closed'",
"parameters": {
"type": "object",
"properties": {
"param1": {
"type": "string",
"description": ""
}
},
"required": [
"param1"
]
},
"mockTool": true,
"autoSubmitMockedResponse": true
}
],
},
// Scooter Subscription
'multi_agent': {
"name": "Example Multi-Agent",
"description": "With tool calls, escalation, structured output, post processing, and prompt organization.",
"startAgent": "Main agent",
"agents": [
{
"name": "Main agent",
"type": "conversation",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\nYou are a customer support agent for ScootUp scooters. Your main responsibility is to orchestrate conversations and delegate them to specialized worker agents for efficient query handling.\n\n---\n## ⚙️ Steps to Follow:\n1. Engage in basic small talk to build rapport. Stick to the specified examples for such interactions.\n2. When a specific query arises, pass control to the relevant worker agent immediately, such as [@agent:Product info agent](#mention) or [@agent:Delivery info agent](#mention) .\n3. For follow-up questions on the same topic, direct them back to the same worker agent who handled the initial query.\n4. If the query is out-of-scope, call [@agent:Escalation agent](#mention)\n\n---\n## 🎯 Scope:\n\n✅ In Scope:\n- Initial query handling and passing control to specific agents\n\n❌ Out of Scope:\n- Detailed product or service resolutions\n- Technical troubleshooting or detailed assistance beyond initial query reading\n- General knowledge related questions\n\n---\n## 📋 Guidelines:\n✔ Dos:\n- Ensure smooth conversational flow while transferring queries to respective agents.\n- Engage only in light rapport-building or disambiguating discussions.\n\n🚫 Don'ts:\n- Avoid engaging in detailed support discussions or troubleshooting at length.\n- Do not address queries beyond initial understanding beyond relaying them to appropriate agents.\n- Do not answer out-of-scope questions; instead, direct them to the [@agent:Escalation agent](#mention) .\n- Do not talk about other agents or about transferring to them.\n\nFollow [@prompt:Style prompt](#mention) . Also keep in mind [@prompt:self_support_prompt](#mention) .\n",
"model": "gpt-4o",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"ragDataSources": [],
"description": "The Main agent orchestrates interactions between various specialized worker agents to ensure efficient handling of user queries and support needs.",
"controlType": "retain",
"examples": "- **User**: \"hi\"\n - **Agent response**: \"Hello! How can I help you with your ScootUp scooter today? I can assist with product information or check on your delivery. Let me know how I can help!\"\n\n- **User**: \"How are you?\"\n - **Agent response**: \"I'm doing well, thank you. How can I help you with ScootUp today?\"\n\n- **User**: \"What can you do?\"\n - **Agent response**: \"I can help with customer support-related issues for ScootUp scooters. Let me know if you have any questions.\"\n\n- **User**: \"What's your role here?\"\n - **Agent response**: \"I'm here to assist you with any ScootUp scooter inquiries. Feel free to ask me anything!\"\n\n- **User**: \"Do you have a favorite scooter?\"\n - **Agent response**: \"I think all ScootUp scooters are great! What interests you about them?\"\n\n- **User**: \"What's your favorite season for scooter rides?\"\n - **Agent response**: \"Each season has its charm for a ride! How about you?\"\n\n- **User**: \"Tell me a fun fact about scooters.\"\n - **Agent response**: \"Did you know that ScootUp scooters can now be found in almost every major city as a part of shared transportation services?\"\n\n- **User**: \"Do you enjoy scooter rides too?\"\n - **Agent response**: \"I think riding scooters can be a lot of fun and a great way to explore the city!\"\n\n- **User**: \"What's a good way to spend a weekend?\"\n - **Agent response**: \"Exploring new places on a scooter could be very exciting! What do you think?\"\n\n- **User**: \"Do you follow any sports?\"\n - **Agent response**: \"I'm focused on supporting ScootUp-related inquiries, but I'd love to hear if you have a favorite sport!\"\n\n- **User**: \"Tell me something interesting.\"\n - **Agent response**: \"ScootUp scooters are known for their dual motors, which provide extra power for smooth rides.\"\n\n- **User**: \"Tell me about the product features.\"\n - **Agent actions**: \"Call [@agent:Product info agent](#mention)\"\n\n- **User**: \"Where is my scooter?\"\n - **Agent actions**: \"Call [@agent:Delivery info agent](#mention)\"\n\n- **User**: \"Can I get details about shipping and delivery times?\"\n - **Agent actions**: \"Call [@agent:Delivery info agent](#mention)\"\n\n- **User**: \"What is the battery life of the scooter?\"\n - **Agent actions**: \"Call [@agent:Product info agent](#mention)\"\n\n- **User**: \"I have a broken scooter part, can I get a replacement?\"\n - **Agent actions**: \"Call [@agent:Product info agent](#mention)\"\n\n- **User**: \"What are ScootUp scooters?\"\n - **Agent actions**: \"Call [@agent:Product info agent](#mention)\""
},
{
"name": "Post process",
"type": "post_process",
"disabled": false,
"instructions": "- Extract the response_to_user field from the provided structured JSON and ensure that this is the only content you use for the final output.\n- Ensure that the agent response covers all the details the user asked for.\n- Use bullet points only when providing lengthy or detailed information that benefits from such formatting.\n- Generally, aim to keep responses concise and focused on key details. You can summarize the info to around 5 sentences.\n- Focus specifically on the response_to_user field in its input.",
"model": "gpt-4o",
"locked": true,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"ragDataSources": [],
"description": "",
"controlType": "retain"
},
{
"name": "Product info agent",
"type": "conversation",
"disabled": false,
"instructions": "🧑‍💼 Role:\nYou are a product information agent for ScootUp scooters. Your job is to search for the right article and answer questions strictly based on the article about ScootUp products. Feel free to ask the user clarification questions if needed.\n\n---\n\n📜 Instructions:\n- Call [@tool:retrieve_snippet](#mention) to get the relevant information and answer questions strictly based on that\n\n✅ In Scope:\n- Answer questions strictly about ScootUp product information.\n\n❌ Out of Scope:\n- Questions about delivery, returns, and subscriptions.\n- Any topic unrelated to ScootUp products.\n- If a question is out of scope, call give_up_control and do not attempt to answer it.\n\n---\n## 📋 Guidelines:\n\n✔ Dos:\n- Stick to the facts provided in the articles.\n- Provide complete and direct answers to the user's questions.\n\n---\n\n🚫 Don'ts:\n- Do not partially answer questions or direct users to a URL for more information.\n- Do not provide information outside of the given context.\n\n",
"model": "gpt-4o",
"locked": false,
"toggleAble": true,
"ragReturnType": "content",
"ragK": 3,
"ragDataSources": [],
"description": "You assist with product-related questions by retrieving relevant articles and information.",
"controlType": "relinquish_to_parent",
"examples": "- **User**: \"What is the maximum speed of the ScootUp E500?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"The maximum speed of the E500 is <snippet_based_info>.\"\n\n- **User**: \"How long does it take to charge a ScootUp scooter fully?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"A full charge requires <snippet_based_info> hours.\"\n\n- **User**: \"Can you tell me about the weight-carrying capacity of ScootUp scooters?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"It supports up to <snippet_based_info>.\"\n\n- **User**: \"What are the differences between the E250 and E500 models?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"Here are the differences: <snippet_based_info>.\"\n\n- **User**: \"How far can I travel on a single charge with the E500?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"You can typically travel up to <snippet_based_info> miles.\"\n\n- **User**: \"Is the scooter waterproof?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"Its waterproof capabilities are: <snippet_based_info>.\"\n\n- **User**: \"Does the scooter have any safety features?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"These safety features are: <snippet_based_info>.\"\n\n- **User**: \"What materials are used to make ScootUp scooters?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"The materials used are: <snippet_based_info>.\"\n\n- **User**: \"Can the scooter be used off-road?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"Regarding off-road use, <snippet_based_info>.\"\n\n- **User**: \"Are spare parts available for purchase?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"Spare parts availability is <snippet_based_info>.\"\n\n- **User**: \"What is the status of my order delivery?\"\n - **Agent actions**: Call give_up_control\n\n- **User**: \"How do I process a return?\"\n - **Agent actions**: Call give_up_control\n\n- **User**: \"Can you tell me more about the subscription plans?\"\n - **Agent actions**: Call give_up_control\n\n- **User**: \"Are there any promotions or discounts?\"\n - **Agent actions**: Call give_up_control\n\n- **User**: \"Who won the last election?\"\n - **Agent actions**: Call give_up_control"
},
{
"name": "Delivery info agent",
"type": "conversation",
"disabled": false,
"instructions": "## 🧑‍💼 Role:\n\nYou are responsible for providing delivery information to the user.\n\n---\n\n## ⚙️ Steps to Follow:\n\n1. Check if the orderId is available:\n - If not available, politely ask the user for their orderId.\n - Once the user provides the orderId, call the [@tool:validate_entity](#mention) tool to check if it's valid.\n - If 'validated', proceed to Step 2.\n - If 'not validated', ask the user to re-check or provide a corrected orderId. Provide a reason on why it is invalid only if the validations tool returns that information.\n2. Fetch the delivery details using the function: [@tool:get_delivery_details](#mention) once the valid orderId is available.\n3. Answer the user's question based on the fetched delivery details.\n4. If the user asks a general delivery question, call [@tool:retrieve_snippet](#mention) and provide an answer only based on it.\n5. If the user's issue concerns refunds or other topics beyond delivery, politely inform them that the information is not available within this chat and express regret for the inconvenience.\n\n---\n## 🎯 Scope\n\n✅ In Scope:\n- Questions about delivery status, shipping timelines, and delivery processes.\n- Generic delivery/shipping-related questions where answers can be sourced from articles.\n\n❌ Out of Scope:\n- Questions unrelated to delivery or shipping.\n- Questions about product features, returns, subscriptions, or promotions.\n- If a question is out of scope, politely inform the user and avoid providing an answer.\n\n---\n\n## 📋 Guidelines\n\n✔ Dos:\n- Use validations to verify orderId.\n- Use get_shipping_details to fetch accurate delivery information.\n- Promptly ask for orderId if not available.\n- Provide complete and clear answers based on the delivery details.\n- For generic delivery questions, strictly refer to retrieved snippets. Stick to factual information when answering.\n\n🚫 Don'ts:\n- Do not mention or describe how you are fetching the information behind the scenes.\n- Do not provide answers without fetching delivery details when required.\n- Do not leave the user with partial information.\n- Refrain from phrases like 'please contact support'; instead, relay information limitations gracefully.\n",
"model": "gpt-4o",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"ragDataSources": [],
"description": "You are responsible for providing accurate delivery status and shipping details for orders.",
"controlType": "retain",
"examples": "- **User**: \"What is the status of my delivery?\"\n - **Agent actions**: Call [@tool:get_delivery_details](#mention)\n - **Agent response**: \"Could you please provide your order ID so I can check your delivery status?\"\n\n- **User**: \"Can you explain the delivery process?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"Here's some information on the delivery process: <snippet_based_info>.\"\n\n- **User**: \"I have a question about product features such as range, durability etc.\"\n - **Agent actions**: give_up_control\n\n- **User**: \"I want to know when my scooter shipped.\"\n - **Agent actions**: Call [@tool:get_delivery_details](#mention)\n - **Agent response**: \"May I have your order ID, please?\"\n\n- **User**: \"Which shipping carrier do you use?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"We typically use <snippet_based_info> as our shipping carrier.\"\n\n- **User**: \"Where can I find my orderId?\"\n - **Agent actions**: Call [@tool:retrieve_snippet](#mention)\n - **Agent response**: \"<snippet_based_info>\"\n\n- **User**: \"My orderId is 123456.\"\n - **Agent actions**: Call [@tool:validate_entity](#mention)\n - **Agent actions**: Call [@tool:get_delivery_details](#mention)\n - **Agent response**: \"Your scooter is expected to arrive by <delivery_date>.\"\n\n- **User**: \"My orderId is abcxyz.\"\n - **Agent actions**: Call [@tool:validate_entity](#mention)\n - **Agent response**: \"It seems your order ID is invalid <reason if provided by validations>. Could you please double-check and provide a correct orderId?\""
},
{
"name": "Escalation agent",
"type": "escalation",
"description": "Handles situations where user queries cannot be addressed by existing agents and require escalation.",
"disabled": false,
"instructions": "\n## 🧑‍💼 Role:\nYou handle situations where escalation is necessary because the current agents cannot fulfill the user's request.\n\n---\n\n## ⚙️ Steps to Follow:\n1. Tell the user you will setup a callback with the team. \n\n---\n\n## 🎯 Scope:\n✅ In Scope:\n- Escalating unresolvable queries, notifying users of escalation, and logging escalation activities.\n\n❌ Out of Scope:\n- Providing responses to general or specialized topics already handled by other agents.\n\n---\n\n## 📋 Guidelines:\n✔ Dos: \n- Respond empathetically to the user, inform them about the escalation, and ensure necessary actions are taken.\n\n🚫 Don'ts: \n- Do not attempt to resolve issues not within your scope.\n",
"model": "gpt-4o",
"locked": false,
"toggleAble": true,
"ragReturnType": "chunks",
"ragK": 3,
"controlType": "retain",
"examples": "- **User**: \"I've tried everything, but no one can resolve my issue. I demand further assistance!\"\n - **Agent actions**: N/A\n - **Agent response**: \"I'm sorry about your experience. I'll set up a callback with our support team so we can thoroughly resolve your issue. We appreciate your patience, and we'll be in touch soon.\"\n\n- **User**: \"Could you escalate this? I've been waiting for days without a resolution.\"\n - **Agent actions**: N/A\n - **Agent response**: \"I'm sorry about your experience. I'll set up a callback with our support team so we can thoroughly resolve your issue. We appreciate your patience, and we'll be in touch soon.\"\n\n- **User**: \"I want a manager to handle my case personally. This is unacceptable.\"\n - **Agent actions**: N/A\n - **Agent response**: \"I'm sorry about your experience. I'll set up a callback with our support team so we can thoroughly resolve your issue. We appreciate your patience, and we'll be in touch soon.\"\n\n- **User**: \"None of the agents so far have fixed my problem. How do I escalate this?\"\n - **Agent actions**: N/A\n - **Agent response**: \"I'm sorry about your experience. I'll set up a callback with our support team so we can thoroughly resolve your issue. We appreciate your patience, and we'll be in touch soon.\"\n\n- **User**: \"I'm tired of repeating myself. I need upper management involved now.\"\n - **Agent actions**: N/A\n - **Agent response**: \"I'm sorry about your experience. I'll set up a callback with our support team so we can thoroughly resolve your issue. We appreciate your patience, and we'll be in touch soon.\""
}
],
"prompts": [
{
"name": "Style prompt",
"type": "style_prompt",
"prompt": "---\n\nmake this more friendly. Keep it to 5-7 sentences. Use these as example references:\n\n---"
},
{
"name": "Greeting",
"type": "greeting",
"prompt": "Hello! How can I help you?"
},
{
"name": "structured_output",
"type": "base_prompt",
"prompt": "Provide your output in the following structured JSON format:\n```\n{\n \"steps_completed\": <number of steps completed, e.g., 1, 2, etc.>,\n \"current_step\": <current step number, e.g., 1>,\n \"reasoning\": \"<reasoning behind the response>\",\n \"error_count\": <number of errors encountered>,\n \"response_to_user\": \"<response to the user, ensure any detailed information such as tables or lists is included within this field>\"\n}\n```\nAlways ensure that all pertinent details, including tables or structured lists, are contained within the response_to_user field to maintain clarity and a comprehensive response for the user."
},
{
"name": "rag_article_prompt",
"type": "base_prompt",
"prompt": "Retrieval instructions:\n\nIn every turn, retrieve a relevant article and use the information from that article to answer the user's question."
},
{
"name": "self_support_prompt",
"type": "base_prompt",
"prompt": "Self Support Guidance:\n\nThe bot should not suggest phrases like 'let me connect you to support' or 'you can reach out to support'. Instead, the agent is the customer support. It can say 'I apologize, but I don't have the right information'."
}
],
"tools": [
{
"name": "get_delivery_details",
"description": "Return a estimated delivery date for the unagi scooter.",
"parameters": {
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "the user's ID"
}
},
"required": [
"orderId"
]
},
"mockTool": true,
"autoSubmitMockedResponse": true
},
{
"name": "retrieve_snippet",
"description": "This is a mock RAG service. Always return 2 paragraphs about a fictional scooter rental product, based on the query. Be verbose.",
"mockTool": true,
"autoSubmitMockedResponse": true,
"parameters": {
"type": "object",
"properties": {
"param1": {
"type": "string",
"description": ""
}
},
"required": [
"param1"
]
}
},
{
"name": "validate_entity",
"description": "orderId should contain only numbers. If the provided orderId is correct, return 'validated' else return 'not validated; <what a correct orderId should contain>'",
"parameters": {
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": ""
}
},
"required": [
"orderId"
]
},
"mockTool": true,
"autoSubmitMockedResponse": true
}
],
}
}

View file

@ -5,7 +5,7 @@ import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, Dropdo
import { ReactNode, useEffect, useState, useCallback, useMemo } from "react";
import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions";
import { updateMcpServers } from "../../../actions/mcp_actions";
import { CopyButton } from "../../../lib/components/copy-button";
import { CopyButton } from "../../../../components/common/copy-button";
import { EditableField } from "../../../lib/components/editable-field";
import { EyeIcon, EyeOffIcon, CopyIcon, MoreVerticalIcon, PlusIcon, EllipsisVerticalIcon, CheckCircleIcon, XCircleIcon } from "lucide-react";
import { WithStringId } from "../../../lib/types/types";
@ -21,7 +21,12 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from "../../../../components/ui/resizable"
import { VoiceSection } from './voice';
import { VoiceSection } from './components/voice';
import { ProjectSection } from './components/project';
import { ToolsSection } from './components/tools';
import { Panel } from "@/components/common/panel-common";
import { Settings, Wrench, Phone } from "lucide-react";
import { clsx } from "clsx";
export const metadata: Metadata = {
title: "Project config",
@ -791,19 +796,46 @@ function NavigationMenu({
selected: string;
onSelect: (page: string) => void;
}) {
const items = ['Project', 'Tools', 'Voice'];
const items = [
{ id: 'Project', icon: <Settings className="w-4 h-4" /> },
{ id: 'Tools', icon: <Wrench className="w-4 h-4" /> },
{ id: 'Voice', icon: <Phone className="w-4 h-4" /> }
];
return (
<StructuredPanel title="SETTINGS">
{items.map((item) => (
<ListItem
key={item}
name={item}
isSelected={selected === item}
onClick={() => onSelect(item)}
/>
))}
</StructuredPanel>
<Panel
variant="projects"
title={
<div className="font-semibold text-zinc-700 dark:text-zinc-300 flex items-center gap-2">
<Settings className="w-4 h-4" />
<span>Settings</span>
</div>
}
>
<div className="flex flex-col">
<div className="space-y-1 pb-2">
{items.map((item) => (
<div
key={item.id}
className="group flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<button
className={clsx(
"flex-1 flex items-center gap-2 text-sm text-left",
selected === item.id
? "text-zinc-900 dark:text-zinc-100"
: "text-zinc-600 dark:text-zinc-400"
)}
onClick={() => onSelect(item.id)}
>
{item.icon}
{item.id}
</button>
</div>
))}
</div>
</div>
</Panel>
);
}
@ -822,26 +854,60 @@ export function ConfigApp({
switch (selectedPage) {
case 'Project':
return (
<div className="h-full overflow-auto p-6 space-y-6">
<BasicSettingsSection projectId={projectId} />
<SecretSection projectId={projectId} />
<McpServersSection projectId={projectId} />
<WebhookUrlSection projectId={projectId} />
<ApiKeysSection projectId={projectId} />
{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />}
<DeleteProjectSection projectId={projectId} />
<div className="h-full overflow-auto p-6">
<Panel
variant="projects"
title={
<div className="font-semibold text-zinc-700 dark:text-zinc-300 flex items-center gap-2">
<Settings className="w-4 h-4" />
<span>Project Settings</span>
</div>
}
>
<div className="space-y-6">
<ProjectSection
projectId={projectId}
useChatWidget={useChatWidget}
chatWidgetHost={chatWidgetHost}
/>
</div>
</Panel>
</div>
);
case 'Tools':
return (
<div className="h-full overflow-auto p-6">
<WebhookUrlSection projectId={projectId} />
<Panel
variant="projects"
title={
<div className="font-semibold text-zinc-700 dark:text-zinc-300 flex items-center gap-2">
<Wrench className="w-4 h-4" />
<span>Tools Configuration</span>
</div>
}
>
<div className="space-y-6">
<ToolsSection projectId={projectId} />
</div>
</Panel>
</div>
);
case 'Voice':
return (
<div className="h-full overflow-auto p-6">
<VoiceSection projectId={projectId} />
<Panel
variant="projects"
title={
<div className="font-semibold text-zinc-700 dark:text-zinc-300 flex items-center gap-2">
<Phone className="w-4 h-4" />
<span>Voice Configuration</span>
</div>
}
>
<div className="space-y-6">
<VoiceSection projectId={projectId} />
</div>
</Panel>
</div>
);
default:
@ -851,13 +917,13 @@ export function ConfigApp({
return (
<ResizablePanelGroup direction="horizontal" className="h-screen gap-1">
<ResizablePanel minSize={10} defaultSize={15}>
<ResizablePanel minSize={10} defaultSize={15} className="p-6">
<NavigationMenu
selected={selectedPage}
onSelect={setSelectedPage}
/>
</ResizablePanel>
<ResizableHandle />
<ResizableHandle className="w-[3px] bg-transparent" />
<ResizablePanel minSize={20} defaultSize={85}>
{renderContent()}
</ResizablePanel>

View file

@ -0,0 +1,527 @@
'use client';
import { ReactNode, useEffect, useState, useCallback } from "react";
import { Spinner, Dropdown, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { getProjectConfig, updateProjectName, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../../actions/project_actions";
import { CopyButton } from "../../../../../components/common/copy-button";
import { EyeIcon, EyeOffIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { WithStringId } from "../../../../lib/types/types";
import { ApiKey } from "../../../../lib/types/project_types";
import { z } from "zod";
import { RelativeTime } from "@primer/react";
import { Label } from "../../../../lib/components/label";
import { sectionHeaderStyles, sectionDescriptionStyles } from './shared-styles';
import { clsx } from "clsx";
export function Section({
title,
children,
description,
}: {
title: string;
children: React.ReactNode;
description?: string;
}) {
return (
<div className="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden">
<div className="px-6 pt-4">
<h2 className={sectionHeaderStyles}>{title}</h2>
{description && (
<p className={sectionDescriptionStyles}>{description}</p>
)}
</div>
<div className="px-6 pb-6">{children}</div>
</div>
);
}
export function SectionRow({
children,
}: {
children: ReactNode;
}) {
return <div className="flex flex-col gap-2">{children}</div>;
}
export function LeftLabel({
label,
}: {
label: string;
}) {
return <Label label={label} />;
}
export function RightContent({
children,
}: {
children: React.ReactNode;
}) {
return <div>{children}</div>;
}
function ProjectNameSection({ projectId }: { projectId: string }) {
const [loading, setLoading] = useState(false);
const [projectName, setProjectName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
getProjectConfig(projectId).then((project) => {
setProjectName(project?.name);
setLoading(false);
});
}, [projectId]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setProjectName(value);
if (!value.trim()) {
setError("Project name cannot be empty");
return;
}
setError(null);
updateProjectName(projectId, value);
};
return <Section
title="Project Name"
description="The name of your project."
>
{loading ? (
<Spinner size="sm" />
) : (
<div className="space-y-2">
<div className={clsx(
"border rounded-lg focus-within:ring-2",
error
? "border-red-500 focus-within:ring-red-500/20"
: "border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
)}>
<Textarea
value={projectName || ''}
onChange={handleChange}
placeholder="Enter project name..."
className="w-full text-sm bg-transparent border-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
autoResize
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
)}
</Section>;
}
function ProjectIdSection({ projectId }: { projectId: string }) {
return <Section
title="Project ID"
description="Your project's unique identifier."
>
<div className="flex flex-row gap-2 items-center">
<div className="text-sm font-mono text-gray-600 dark:text-gray-400">{projectId}</div>
<CopyButton
onCopy={() => navigator.clipboard.writeText(projectId)}
label="Copy"
successLabel="Copied"
/>
</div>
</Section>;
}
function SecretSection({ projectId }: { projectId: string }) {
const [loading, setLoading] = useState(false);
const [hidden, setHidden] = useState(true);
const [secret, setSecret] = useState<string | null>(null);
const formattedSecret = hidden ? `${secret?.slice(0, 2)}${'•'.repeat(5)}${secret?.slice(-2)}` : secret;
useEffect(() => {
setLoading(true);
getProjectConfig(projectId).then((project) => {
setSecret(project.secret);
setLoading(false);
});
}, [projectId]);
const handleRotateSecret = async () => {
if (!confirm("Are you sure you want to rotate the secret? All existing signatures will become invalid.")) {
return;
}
setLoading(true);
try {
const newSecret = await rotateSecret(projectId);
setSecret(newSecret);
} catch (error) {
console.error('Failed to rotate secret:', error);
} finally {
setLoading(false);
}
};
return <Section
title="Project Secret"
description="The project secret is used for signing tool-call requests sent to your webhook."
>
<div className="space-y-4">
{loading ? (
<Spinner size="sm" />
) : (
<div className="flex flex-row gap-4 items-center">
<div className="text-sm font-mono break-all text-gray-600 dark:text-gray-400">
{formattedSecret}
</div>
<div className="flex flex-row gap-4 items-center">
<button
onClick={() => setHidden(!hidden)}
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
{hidden ? <EyeIcon size={16} /> : <EyeOffIcon size={16} />}
</button>
<CopyButton
onCopy={() => navigator.clipboard.writeText(secret || '')}
label="Copy"
successLabel="Copied"
/>
<Button
size="sm"
variant="primary"
onClick={handleRotateSecret}
disabled={loading}
>
Rotate
</Button>
</div>
</div>
)}
</div>
</Section>;
}
function ApiKeyDisplay({ apiKey, onDelete }: { apiKey: string; onDelete: () => void }) {
const [isVisible, setIsVisible] = useState(false);
const formattedKey = isVisible ? apiKey : `${apiKey.slice(0, 2)}${'•'.repeat(5)}${apiKey.slice(-2)}`;
return (
<div className="flex items-center gap-2">
<div className="text-sm font-mono break-all">
{formattedKey}
</div>
<button
onClick={() => setIsVisible(!isVisible)}
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
{isVisible ? <EyeOffIcon className="w-4 h-4" /> : <EyeIcon className="w-4 h-4" />}
</button>
<CopyButton
onCopy={() => navigator.clipboard.writeText(apiKey)}
label="Copy"
successLabel="Copied"
/>
<button
onClick={onDelete}
className="text-gray-600 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
>
<Trash2Icon className="w-4 h-4" />
</button>
</div>
);
}
function ApiKeysSection({ projectId }: { projectId: string }) {
const [keys, setKeys] = useState<WithStringId<z.infer<typeof ApiKey>>[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<{
type: 'success' | 'error' | 'info';
text: string;
} | null>(null);
const loadKeys = useCallback(async () => {
const keys = await listApiKeys(projectId);
setKeys(keys);
setLoading(false);
}, [projectId]);
useEffect(() => {
loadKeys();
}, [loadKeys]);
const handleCreateKey = async () => {
setLoading(true);
setMessage(null);
try {
const key = await createApiKey(projectId);
setKeys([...keys, key]);
setMessage({
type: 'success',
text: 'API key created successfully',
});
setTimeout(() => setMessage(null), 2000);
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : "Failed to create API key",
});
} finally {
setLoading(false);
}
};
const handleDeleteKey = async (id: string) => {
if (!confirm("Are you sure you want to delete this API key? This action cannot be undone.")) {
return;
}
try {
setLoading(true);
await deleteApiKey(projectId, id);
setKeys(keys.filter((k) => k._id !== id));
setMessage({
type: 'info',
text: 'API key deleted successfully',
});
setTimeout(() => setMessage(null), 2000);
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : "Failed to delete API key",
});
} finally {
setLoading(false);
}
};
return <Section
title="API Keys"
description="API keys are used to authenticate requests to the Rowboat API."
>
<div className="space-y-4">
<div className="flex justify-between items-center">
<Button
size="sm"
variant="primary"
startContent={<PlusIcon className="w-4 h-4" />}
onClick={handleCreateKey}
disabled={loading}
>
Create API Key
</Button>
</div>
{loading ? (
<Spinner size="sm" />
) : (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="grid grid-cols-12 items-center border-b border-gray-200 dark:border-gray-700 p-4">
<div className="col-span-7 font-medium text-gray-900 dark:text-gray-100">API Key</div>
<div className="col-span-3 font-medium text-gray-900 dark:text-gray-100">Created</div>
<div className="col-span-2 font-medium text-gray-900 dark:text-gray-100">Last Used</div>
</div>
{message && (
<div className={clsx(
"p-4 text-sm",
message.type === 'success' && "bg-green-50 text-green-700",
message.type === 'error' && "bg-red-50 text-red-700",
message.type === 'info' && "bg-yellow-50 text-yellow-700"
)}>
{message.text}
</div>
)}
{keys.map((key) => (
<div key={key._id} className="grid grid-cols-12 items-center border-b border-gray-200 dark:border-gray-700 last:border-0 p-4">
<div className="col-span-7">
<ApiKeyDisplay
apiKey={key.key}
onDelete={() => handleDeleteKey(key._id)}
/>
</div>
<div className="col-span-3 text-sm text-gray-500">
<RelativeTime date={new Date(key.createdAt)} />
</div>
<div className="col-span-2 text-sm text-gray-500">
{key.lastUsedAt ? (
<RelativeTime date={new Date(key.lastUsedAt)} />
) : 'Never'}
</div>
</div>
))}
{keys.length === 0 && (
<div className="p-6 text-center text-gray-500 dark:text-gray-400">
No API keys created yet
</div>
)}
</div>
)}
</div>
</Section>;
}
function ChatWidgetSection({ projectId, chatWidgetHost }: { projectId: string, chatWidgetHost: string }) {
const [loading, setLoading] = useState(false);
const [chatClientId, setChatClientId] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
getProjectConfig(projectId).then((project) => {
setChatClientId(project.chatClientId);
setLoading(false);
});
}, [projectId]);
const code = `<!-- RowBoat Chat Widget -->
<script>
window.ROWBOAT_CONFIG = {
clientId: '${chatClientId}'
};
(function(d) {
var s = d.createElement('script');
s.src = '${chatWidgetHost}/api/bootstrap.js';
s.async = true;
d.getElementsByTagName('head')[0].appendChild(s);
})(document);
</script>`;
return (
<Section
title="Chat Widget"
description="Add the chat widget to your website by copying and pasting this code snippet just before the closing </body> tag."
>
<div className="space-y-4">
{loading ? (
<Spinner size="sm" />
) : (
<div className="relative">
<div className="absolute top-3 right-3">
<CopyButton
onCopy={() => navigator.clipboard.writeText(code)}
label="Copy"
successLabel="Copied"
/>
</div>
<div className="font-mono text-sm bg-gray-50 dark:bg-gray-800 rounded-lg p-4 pr-12 overflow-x-auto">
<pre className="whitespace-pre-wrap break-all">
{code}
</pre>
</div>
</div>
)}
</div>
</Section>
);
}
function DeleteProjectSection({ projectId }: { projectId: string }) {
const [loading, setLoading] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const [projectName, setProjectName] = useState("");
const [projectNameInput, setProjectNameInput] = useState("");
const [confirmationInput, setConfirmationInput] = useState("");
const isValid = projectNameInput === projectName && confirmationInput === "delete project";
useEffect(() => {
setLoading(true);
getProjectConfig(projectId).then((project) => {
setProjectName(project.name);
setLoading(false);
});
}, [projectId]);
const handleDelete = async () => {
if (!isValid) return;
setLoading(true);
await deleteProject(projectId);
setLoading(false);
};
return (
<Section
title="Delete Project"
description="Permanently delete this project and all its data."
>
<div className="space-y-4">
<div className="p-4 bg-red-50/10 dark:bg-red-900/10 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-300">
Deleting a project will permanently remove all associated data, including workflows, sources, and API keys.
This action cannot be undone.
</p>
</div>
<Button
variant="primary"
size="sm"
onClick={onOpen}
disabled={loading}
isLoading={loading}
color="red"
>
Delete project
</Button>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader>Delete Project</ModalHeader>
<ModalBody>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
This action cannot be undone. Please type in the following to confirm:
</p>
<Input
label="Project name"
placeholder={projectName}
value={projectNameInput}
onChange={(e) => setProjectNameInput(e.target.value)}
/>
<Input
label='Type "delete project" to confirm'
placeholder="delete project"
value={confirmationInput}
onChange={(e) => setConfirmationInput(e.target.value)}
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
color="danger"
onClick={handleDelete}
disabled={!isValid}
>
Delete Project
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
</Section>
);
}
export function ProjectSection({
projectId,
useChatWidget,
chatWidgetHost,
}: {
projectId: string;
useChatWidget: boolean;
chatWidgetHost: string;
}) {
return (
<div className="p-6 space-y-6">
<ProjectNameSection projectId={projectId} />
<ProjectIdSection projectId={projectId} />
<SecretSection projectId={projectId} />
<ApiKeysSection projectId={projectId} />
{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />}
<DeleteProjectSection projectId={projectId} />
</div>
);
}

View file

@ -0,0 +1,4 @@
export const sectionHeaderStyles = "text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2";
export const sectionDescriptionStyles = "text-sm text-gray-500 dark:text-gray-400 mb-4";
export const textareaStyles = "rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500";
export const inputStyles = "rounded-lg px-3 py-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20";

View file

@ -0,0 +1,316 @@
'use client';
import { useState, useEffect, useMemo } from "react";
import { Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react";
import { getProjectConfig, updateWebhookUrl } from "../../../../actions/project_actions";
import { updateMcpServers } from "../../../../actions/mcp_actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { PlusIcon } from "lucide-react";
import { sectionHeaderStyles, sectionDescriptionStyles, inputStyles } from './shared-styles';
import { clsx } from "clsx";
function Section({ title, children, description }: {
title: string;
children: React.ReactNode;
description?: string;
}) {
return (
<div className="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden">
<div className="px-6 pt-4">
<h2 className={sectionHeaderStyles}>{title}</h2>
{description && (
<p className={sectionDescriptionStyles}>{description}</p>
)}
</div>
<div className="px-6 pb-6">{children}</div>
</div>
);
}
function McpServersSection({ projectId }: { projectId: string }) {
const [servers, setServers] = useState<Array<{ name: string; url: string }>>([]);
const [originalServers, setOriginalServers] = useState<Array<{ name: string; url: string }>>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const [newServer, setNewServer] = useState({ name: '', url: '' });
const [validationErrors, setValidationErrors] = useState<{
name?: string;
url?: string;
}>({});
useEffect(() => {
setLoading(true);
getProjectConfig(projectId).then((project) => {
const initialServers = project.mcpServers || [];
setServers(JSON.parse(JSON.stringify(initialServers)));
setOriginalServers(JSON.parse(JSON.stringify(initialServers)));
setLoading(false);
});
}, [projectId]);
const hasChanges = useMemo(() => {
if (servers.length !== originalServers.length) return true;
return servers.some((server, index) => {
return server.name !== originalServers[index]?.name ||
server.url !== originalServers[index]?.url;
});
}, [servers, originalServers]);
const handleAddServer = () => {
setNewServer({ name: '', url: '' });
setValidationErrors({});
onOpen();
};
const handleRemoveServer = (index: number) => {
setServers(servers.filter((_, i) => i !== index));
};
const handleCreateServer = () => {
setValidationErrors({});
const errors: typeof validationErrors = {};
if (!newServer.name.trim()) {
errors.name = 'Server name is required';
} else if (servers.some(s => s.name === newServer.name)) {
errors.name = 'Server name must be unique';
}
if (!newServer.url.trim()) {
errors.url = 'Server URL is required';
} else {
try {
new URL(newServer.url);
} catch {
errors.url = 'Invalid URL format';
}
}
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
return;
}
setServers([...servers, newServer]);
onClose();
};
const handleSave = async () => {
setSaving(true);
try {
await updateMcpServers(projectId, servers);
setOriginalServers(JSON.parse(JSON.stringify(servers)));
setMessage({ type: 'success', text: 'Servers updated successfully' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Failed to update servers' });
}
setSaving(false);
};
return <Section
title="MCP Servers"
description="MCP servers are used to execute MCP tools."
>
<div className="space-y-4">
<div className="flex justify-start">
<Button
size="sm"
variant="primary"
onClick={handleAddServer}
>
+ Add Server
</Button>
</div>
{loading ? (
<Spinner size="sm" />
) : (
<>
<div className="space-y-3">
{servers.map((server, index) => (
<div key={index} className="flex gap-3 items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800">
<div className="flex-1">
<div className="font-medium">{server.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{server.url}</div>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => handleRemoveServer(index)}
>
Remove
</Button>
</div>
))}
{servers.length === 0 && (
<div className="text-center text-gray-500 dark:text-gray-400 p-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-dashed border-gray-200 dark:border-gray-700">
No servers configured
</div>
)}
</div>
{hasChanges && (
<div className="flex justify-end pt-4">
<Button
size="sm"
variant="primary"
onClick={handleSave}
isLoading={saving}
>
Save Changes
</Button>
</div>
)}
{message && (
<div className={clsx(
"mt-4 text-sm p-4 rounded-lg",
message.type === 'success'
? "bg-green-50 text-green-700 border border-green-200"
: "bg-red-50 text-red-700 border border-red-200"
)}>
{message.text}
</div>
)}
</>
)}
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader>Add MCP Server</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Server Name</label>
<Input
placeholder="Enter server name"
value={newServer.name}
onChange={(e) => {
setNewServer({ ...newServer, name: e.target.value });
if (validationErrors.name) {
setValidationErrors(prev => ({
...prev,
name: undefined
}));
}
}}
className={inputStyles}
required
/>
{validationErrors.name && (
<p className="text-sm text-red-500">{validationErrors.name}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">SSE URL</label>
<Input
placeholder="https://localhost:8000/sse"
value={newServer.url}
onChange={(e) => {
setNewServer({ ...newServer, url: e.target.value });
if (validationErrors.url) {
setValidationErrors(prev => ({
...prev,
url: undefined
}));
}
}}
className={inputStyles}
required
/>
{validationErrors.url && (
<p className="text-sm text-red-500">{validationErrors.url}</p>
)}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleCreateServer}
disabled={!newServer.name || !newServer.url}
>
Add Server
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
</Section>;
}
export function WebhookUrlSection({ projectId }: { projectId: string }) {
const [loading, setLoading] = useState(false);
const [webhookUrl, setWebhookUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
getProjectConfig(projectId).then((project) => {
setWebhookUrl(project.webhookUrl || null);
setLoading(false);
});
}, [projectId]);
function validate(url: string) {
try {
new URL(url);
setError(null);
return { valid: true };
} catch {
setError('Please enter a valid URL');
return { valid: false, errorMessage: 'Please enter a valid URL' };
}
}
return <Section
title="Webhook URL"
description="In workflow editor, tool calls will be posted to this URL, unless they are mocked."
>
<div className="space-y-2">
<div className={clsx(
"border rounded-lg focus-within:ring-2",
error
? "border-red-500 focus-within:ring-red-500/20"
: "border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
)}>
<Textarea
value={webhookUrl || ''}
useValidation={true}
updateOnBlur={true}
validate={validate}
onValidatedChange={(value) => {
setWebhookUrl(value);
updateWebhookUrl(projectId, value);
}}
placeholder="Enter webhook URL..."
className="w-full text-sm bg-transparent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
autoResize
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
</div>
</Section>;
}
export function ToolsSection({ projectId }: { projectId: string }) {
return (
<div className="p-6 space-y-6">
<McpServersSection projectId={projectId} />
<WebhookUrlSection projectId={projectId} />
</div>
);
}
export default ToolsSection;

View file

@ -1,19 +1,148 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from "@heroui/react";
import { configureTwilioNumber, mockConfigureTwilioNumber, getTwilioConfigs, deleteTwilioConfig } from "../../../actions/voice_actions";
import { FormSection } from "../../../lib/components/form-section";
import { EditableField } from "../../../lib/components/editable-field-with-immediate-save";
import { StructuredPanel } from "../../../lib/components/structured-panel";
import { TwilioConfig } from "../../../lib/types/voice_types";
import { CheckCircleIcon, XCircleIcon, InfoIcon } from "lucide-react";
import { Spinner } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { configureTwilioNumber, getTwilioConfigs, deleteTwilioConfig } from "../../../../actions/voice_actions";
import { TwilioConfig } from "../../../../lib/types/voice_types";
import { CheckCircleIcon, XCircleIcon, InfoIcon, EyeOffIcon, EyeIcon } from "lucide-react";
import { Section } from './project';
import { clsx } from 'clsx';
export function VoiceSection({
projectId,
}: {
projectId: string;
function PhoneNumberSection({
value,
onChange,
disabled
}: {
value: string;
onChange: (value: string) => void;
disabled: boolean;
}) {
return (
<Section
title="Twilio Phone Number"
description="The phone number to use for voice calls."
>
<div className="space-y-2">
<div className={clsx(
"border rounded-lg focus-within:ring-2",
"border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
)}>
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="+14156021922"
className="w-full text-sm bg-transparent border-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
disabled={disabled}
autoResize
/>
</div>
</div>
</Section>
);
}
function AccountSidSection({
value,
onChange,
disabled
}: {
value: string;
onChange: (value: string) => void;
disabled: boolean;
}) {
return (
<Section
title="Twilio Account SID"
description="Your Twilio account identifier."
>
<div className="space-y-2">
<div className={clsx(
"border rounded-lg focus-within:ring-2",
"border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
)}>
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="AC5588686d3ec65df89615274..."
className="w-full text-sm bg-transparent border-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
disabled={disabled}
autoResize
/>
</div>
</div>
</Section>
);
}
function AuthTokenSection({
value,
onChange,
disabled
}: {
value: string;
onChange: (value: string) => void;
disabled: boolean;
}) {
return (
<Section
title="Twilio Auth Token"
description="Your Twilio authentication token."
>
<div className="space-y-2">
<div className={clsx(
"border rounded-lg focus-within:ring-2",
"border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
)}>
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="b74e48f9098764ef834cf6bd..."
className="w-full text-sm bg-transparent border-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
disabled={disabled}
autoResize
/>
</div>
</div>
</Section>
);
}
function LabelSection({
value,
onChange,
disabled
}: {
value: string;
onChange: (value: string) => void;
disabled: boolean;
}) {
return (
<Section
title="Label"
description="A descriptive label for this phone number configuration."
>
<div className="space-y-2">
<div className={clsx(
"border rounded-lg focus-within:ring-2",
"border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
)}>
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Enter a label for this number..."
className="w-full text-sm bg-transparent border-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
disabled={disabled}
autoResize
/>
</div>
</div>
</Section>
);
}
export function VoiceSection({ projectId }: { projectId: string }) {
const [formState, setFormState] = useState({
phone: '',
accountSid: '',
@ -120,9 +249,7 @@ export function VoiceSection({
};
return (
<div className="flex flex-col gap-4">
<StructuredPanel title="CONFIGURE TWILIO PHONE NUMBER">
<div className="flex flex-col gap-4 p-6">
<div className="p-6 space-y-6">
{success && (
<div className="bg-green-50 text-green-700 p-4 rounded-md flex items-center gap-2">
<CheckCircleIcon className="w-5 h-5" />
@ -148,56 +275,44 @@ export function VoiceSection({
</div>
)}
<FormSection label="TWILIO PHONE NUMBER">
<EditableField
<PhoneNumberSection
value={formState.phone}
onChange={(value) => handleFieldChange('phone', value)}
placeholder="+14156021922"
disabled={loading}
/>
</FormSection>
<FormSection label="TWILIO ACCOUNT SID">
<EditableField
<AccountSidSection
value={formState.accountSid}
onChange={(value) => handleFieldChange('accountSid', value)}
placeholder="AC5588686d3ec65df89615274..."
disabled={loading}
/>
</FormSection>
<FormSection label="TWILIO AUTH TOKEN">
<EditableField
<AuthTokenSection
value={formState.authToken}
onChange={(value) => handleFieldChange('authToken', value)}
placeholder="b74e48f9098764ef834cf6bd..."
type="password"
disabled={loading}
/>
</FormSection>
<FormSection label="LABEL">
<EditableField
<LabelSection
value={formState.label}
onChange={(value) => handleFieldChange('label', value)}
placeholder="Enter a label for this number..."
disabled={loading}
/>
</FormSection>
<div className="flex gap-2 mt-4">
<div className="flex gap-2">
<Button
color="primary"
variant="primary"
size="sm"
onClick={handleConfigureTwilio}
isLoading={loading}
disabled={loading || !isDirty}
>
{existingConfig ? 'Update Twilio Config' : 'Import from Twilio'}
</Button>
{existingConfig ? (
<Button
color="danger"
variant="flat"
variant="primary"
color="red"
size="sm"
onClick={handleDeleteConfig}
disabled={loading}
>
@ -205,7 +320,8 @@ export function VoiceSection({
</Button>
) : (
<Button
variant="flat"
variant="tertiary"
size="sm"
onClick={() => {
setFormState({
phone: '',
@ -222,8 +338,6 @@ export function VoiceSection({
</Button>
)}
</div>
</div>
</StructuredPanel>
</div>
);
}

View file

@ -0,0 +1,385 @@
'use client';
import { Button } from "@/components/ui/button";
import { useRef, useState, createContext, useContext, useCallback, forwardRef, useImperativeHandle, useEffect, Ref } from "react";
import { CopilotChatContext } from "../../../lib/types/copilot_types";
import { CopilotMessage } from "../../../lib/types/copilot_types";
import { CopilotAssistantMessageActionPart } from "../../../lib/types/copilot_types";
import { Workflow } from "../../../lib/types/workflow_types";
import { z } from "zod";
import { getCopilotResponse } from "@/app/actions/copilot_actions";
import { Action as WorkflowDispatch } from "../workflow/workflow_editor";
import { Panel } from "@/components/common/panel-common";
import { ComposeBox } from "@/components/common/compose-box";
import { Messages } from "./components/messages";
import { CopyIcon, CheckIcon, PlusIcon, XIcon } from "lucide-react";
const CopilotContext = createContext<{
workflow: z.infer<typeof Workflow> | null;
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void;
appliedChanges: Record<string, boolean>;
}>({ workflow: null, handleApplyChange: () => { }, appliedChanges: {} });
export function getAppliedChangeKey(messageIndex: number, actionIndex: number, field: string) {
return `${messageIndex}-${actionIndex}-${field}`;
}
const App = forwardRef(function App({
projectId,
workflow,
dispatch,
chatContext = undefined,
onCopyJson,
}: {
projectId: string;
workflow: z.infer<typeof Workflow>;
dispatch: (action: WorkflowDispatch) => void;
chatContext?: z.infer<typeof CopilotChatContext>;
onCopyJson: (data: { messages: any[], lastRequest: any, lastResponse: any }) => void;
}, ref: Ref<{ handleCopyChat: () => void }>) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const [messages, setMessages] = useState<z.infer<typeof CopilotMessage>[]>([]);
const [loadingResponse, setLoadingResponse] = useState(false);
const [responseError, setResponseError] = useState<string | null>(null);
const [appliedChanges, setAppliedChanges] = useState<Record<string, boolean>>({});
const [discardContext, setDiscardContext] = useState(false);
const [lastRequest, setLastRequest] = useState<unknown | null>(null);
const [lastResponse, setLastResponse] = useState<unknown | null>(null);
// Check for initial prompt in local storage and send it
useEffect(() => {
const prompt = localStorage.getItem(`project_prompt_${projectId}`);
if (prompt && messages.length === 0) {
localStorage.removeItem(`project_prompt_${projectId}`);
setMessages([{
role: 'user',
content: prompt
}]);
}
}, [projectId, messages.length, setMessages]);
// Reset discardContext when chatContext changes
useEffect(() => {
setDiscardContext(false);
}, [chatContext]);
// Get the effective context based on user preference
const effectiveContext = discardContext ? null : chatContext;
function handleUserMessage(prompt: string) {
setMessages([...messages, {
role: 'user',
content: prompt,
}]);
setResponseError(null);
}
const handleApplyChange = useCallback((
messageIndex: number,
actionIndex: number,
field?: string
) => {
// validate
console.log('apply change', messageIndex, actionIndex, field);
const msg = messages[messageIndex];
if (!msg) {
console.log('no message');
return;
}
if (msg.role !== 'assistant') {
console.log('not assistant');
return;
}
const action = msg.content.response[actionIndex].content as z.infer<typeof CopilotAssistantMessageActionPart>['content'];
if (!action) {
console.log('no action');
return;
}
console.log('reached here');
if (action.action === 'create_new') {
switch (action.config_type) {
case 'agent':
dispatch({
type: 'add_agent',
agent: {
name: action.name,
...action.config_changes
}
});
break;
case 'tool':
dispatch({
type: 'add_tool',
tool: {
name: action.name,
...action.config_changes
}
});
break;
case 'prompt':
dispatch({
type: 'add_prompt',
prompt: {
name: action.name,
...action.config_changes
}
});
break;
}
const appliedKeys = Object.keys(action.config_changes).reduce((acc, key) => {
acc[getAppliedChangeKey(messageIndex, actionIndex, key)] = true;
return acc;
}, {} as Record<string, boolean>);
setAppliedChanges({
...appliedChanges,
...appliedKeys,
});
} else if (action.action === 'edit') {
const changes = field
? { [field]: action.config_changes[field] }
: action.config_changes;
switch (action.config_type) {
case 'agent':
dispatch({
type: 'update_agent',
name: action.name,
agent: changes
});
break;
case 'tool':
dispatch({
type: 'update_tool',
name: action.name,
tool: changes
});
break;
case 'prompt':
dispatch({
type: 'update_prompt',
name: action.name,
prompt: changes
});
break;
}
const appliedKeys = Object.keys(changes).reduce((acc, key) => {
acc[getAppliedChangeKey(messageIndex, actionIndex, key)] = true;
return acc;
}, {} as Record<string, boolean>);
setAppliedChanges({
...appliedChanges,
...appliedKeys,
});
}
}, [dispatch, appliedChanges, messages]);
// Second useEffect for copilot response
useEffect(() => {
let ignore = false;
async function process() {
setLoadingResponse(true);
setResponseError(null);
try {
setLastRequest(null);
setLastResponse(null);
const response = await getCopilotResponse(
projectId,
messages,
workflow,
effectiveContext || null,
);
if (ignore) {
return;
}
setLastRequest(response.rawRequest);
setLastResponse(response.rawResponse);
setMessages([...messages, response.message]);
} catch (err) {
if (!ignore) {
setResponseError(`Failed to get copilot response: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
} finally {
if (!ignore) {
setLoadingResponse(false);
}
}
}
// if no messages, return
if (messages.length === 0) {
return;
}
// if last message is not from role user
// or tool, return
const last = messages[messages.length - 1];
if (responseError) {
return;
}
if (last.role !== 'user') {
return;
}
process();
return () => {
ignore = true;
};
}, [
messages,
projectId,
responseError,
workflow,
effectiveContext,
setLoadingResponse,
setMessages,
setResponseError
]);
const handleCopyChat = useCallback(() => {
onCopyJson({
messages,
lastRequest,
lastResponse,
});
}, [messages, lastRequest, lastResponse, onCopyJson]);
useImperativeHandle(ref, () => ({
handleCopyChat
}), [handleCopyChat]);
return (
<CopilotContext.Provider value={{ workflow, handleApplyChange, appliedChanges }}>
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
<Messages
messages={messages}
loadingResponse={loadingResponse}
workflow={workflow}
handleApplyChange={handleApplyChange}
appliedChanges={appliedChanges}
/>
</div>
<div className="shrink-0 px-1 pb-6">
{responseError && (
<div className="mb-4 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex gap-2 justify-between items-center text-sm">
<p className="text-red-600 dark:text-red-400">{responseError}</p>
<Button
size="sm"
color="danger"
onClick={() => setResponseError(null)}
>
Retry
</Button>
</div>
)}
{effectiveContext && <div className="flex items-start mb-2">
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 text-sm px-2 py-1 rounded-sm shadow-sm">
<div>
{effectiveContext.type === 'chat' && "Chat"}
{effectiveContext.type === 'agent' && `Agent: ${effectiveContext.name}`}
{effectiveContext.type === 'tool' && `Tool: ${effectiveContext.name}`}
{effectiveContext.type === 'prompt' && `Prompt: ${effectiveContext.name}`}
</div>
<button
className="text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
onClick={() => setDiscardContext(true)}
>
<XIcon size={16} />
</button>
</div>
</div>}
<ComposeBox
handleUserMessage={handleUserMessage}
messages={messages as any[]}
loading={loadingResponse}
disabled={loadingResponse}
/>
</div>
</div>
</CopilotContext.Provider>
);
});
export function Copilot({
projectId,
workflow,
chatContext = undefined,
dispatch,
}: {
projectId: string;
workflow: z.infer<typeof Workflow>;
chatContext?: z.infer<typeof CopilotChatContext>;
dispatch: (action: WorkflowDispatch) => void;
}) {
const [copilotKey, setCopilotKey] = useState(0);
const [showCopySuccess, setShowCopySuccess] = useState(false);
const appRef = useRef<{ handleCopyChat: () => void }>(null);
function handleNewChat() {
setCopilotKey(prev => prev + 1);
}
function handleCopyJson(data: { messages: any[], lastRequest: any, lastResponse: any }) {
const jsonString = JSON.stringify(data, null, 2);
navigator.clipboard.writeText(jsonString);
setShowCopySuccess(true);
setTimeout(() => {
setShowCopySuccess(false);
}, 2000);
}
return (
<Panel variant="copilot"
title={
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
COPILOT
</div>
<Button
variant="primary"
size="sm"
onClick={handleNewChat}
className="bg-blue-50 text-blue-700 hover:bg-blue-100"
showHoverContent={true}
hoverContent="New chat"
>
<PlusIcon className="w-4 h-4" />
</Button>
</div>
}
rightActions={
<div className="flex items-center gap-3">
<Button
variant="secondary"
size="sm"
onClick={() => appRef.current?.handleCopyChat()}
showHoverContent={true}
hoverContent={showCopySuccess ? "Copied" : "Copy JSON"}
>
{showCopySuccess ? (
<CheckIcon className="w-4 h-4" />
) : (
<CopyIcon className="w-4 h-4" />
)}
</Button>
</div>
}
>
<div className="h-full overflow-auto px-3 pt-4">
<App
key={copilotKey}
ref={appRef}
projectId={projectId}
workflow={workflow}
dispatch={dispatch}
chatContext={chatContext}
onCopyJson={handleCopyJson}
/>
</div>
</Panel>
);
}

View file

@ -2,10 +2,10 @@
import { createContext, useContext, useState } from "react";
import clsx from "clsx";
import { z } from "zod";
import { CopilotAssistantMessageActionPart } from "../../../lib/types/copilot_types";
import { Workflow } from "../../../lib/types/workflow_types";
import { PreviewModalProvider, usePreviewModal } from './preview-modal';
import { getAppliedChangeKey } from "./copilot";
import { CopilotAssistantMessageActionPart } from "../../../../lib/types/copilot_types";
import { Workflow } from "../../../../lib/types/workflow_types";
import { PreviewModalProvider, usePreviewModal } from '../../workflow/preview-modal';
import { getAppliedChangeKey } from "../app";
import { AlertTriangleIcon, CheckCheckIcon, CheckIcon, ChevronsDownIcon, ChevronsUpIcon, EyeIcon, PencilIcon, PlusIcon } from "lucide-react";
const ActionContext = createContext<{
@ -37,12 +37,17 @@ export function Action({
}) {
const [expanded, setExpanded] = useState(false);
// determine whether all changes contained in this action are applied
const appliedFields = Object.keys(action.config_changes).filter(key => appliedChanges[getAppliedChangeKey(msgIndex, actionIndex, key)]);
console.log('appliedFields', appliedFields);
if (!action || typeof action !== 'object') {
console.warn('Invalid action object:', action);
return null;
}
// determine whether all changes contained in this action are applied
const allApplied = Object.keys(action.config_changes).every(key => appliedFields.includes(key));
const appliedFields = Object.keys(action.config_changes).filter(key =>
appliedChanges[getAppliedChangeKey(msgIndex, actionIndex, key)]
);
const allApplied = Object.keys(action.config_changes).every(key =>
appliedFields.includes(key)
);
// generate apply change function
const applyChangeHandler = () => {
@ -221,83 +226,4 @@ export function ActionField({
</div>
</div>
</div>;
}
// function ActionToolParamsView({
// params,
// }: {
// params: z.infer<typeof Workflow>['tools'][number]['parameters'];
// }) {
// const required = params?.required || [];
// return <ActionField label="parameters">
// <div className="flex flex-col gap-2 text-sm">
// {Object.entries(params?.properties || {}).map(([paramName, paramConfig]) => {
// return <div className="flex flex-col gap-1">
// <div className="flex gap-1 items-center">
// <svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
// <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14" />
// </svg>
// <div>{paramName}{required.includes(paramName) && <sup>*</sup>}</div>
// <div className="text-gray-500">{paramConfig.type}</div>
// </div>
// <div className="flex gap-1 ml-4">
// <div className="text-gray-500 italic">{paramConfig.description}</div>
// </div>
// </div>;
// })}
// </div>
// </ActionField>;
// }
// function ActionAgentToolsView({
// action,
// tools,
// }: {
// action: z.infer<typeof CopilotAssistantMessage>['content']['Actions'][number];
// tools: z.infer<typeof Workflow>['agents'][number]['tools'];
// }) {
// const { workflow } = useContext(CopilotContext);
// if (!workflow) {
// return <></>;
// }
// // find the agent in the workflow
// const agent = workflow.agents.find((agent) => agent.name === action.name);
// if (!agent) {
// return <></>;
// }
// // find the tools that were removed
// const removedTools = agent.tools.filter((tool) => !tools.includes(tool));
// return <ActionField label="tools">
// {removedTools.length > 0 && <div className="flex flex-col gap-1 text-sm">
// <div className="text-gray-500 italic">The following tools were removed:</div>
// <div className="flex flex-col gap-1">
// {removedTools.map((tool) => {
// return <div className="flex gap-1 items-center">
// <svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
// <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14" />
// </svg>
// <div>{tool}</div>
// </div>;
// })}
// </div>
// </div>}
// <div className="flex flex-col gap-1 text-sm">
// <div className="text-gray-500 italic">The following tools were added:</div>
// <div className="flex flex-col gap-1">
// {tools.map((tool) => {
// return <div className="flex gap-1 items-center">
// <svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
// <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14" />
// </svg>
// <div>{tool}</div>
// </div>;
// })}
// </div>
// </div>
// </ActionField>;
// }
}

View file

@ -0,0 +1,168 @@
'use client';
import { Spinner } from "@heroui/react";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { Workflow } from "@/app/lib/types/workflow_types";
import MarkdownContent from "@/app/lib/components/markdown-content";
import { MessageSquareIcon, EllipsisIcon, XIcon } from "lucide-react";
import { CopilotMessage, CopilotAssistantMessage } from "@/app/lib/types/copilot_types";
import { Action } from './actions';
function UserMessage({ content }: { content: string }) {
return (
<div className="w-full">
<div className="bg-blue-50 dark:bg-[#1e2023] px-4 py-2.5
rounded-lg text-sm leading-relaxed
text-gray-700 dark:text-gray-200
border border-blue-100 dark:border-[#2a2d31]
shadow-sm animate-slideUpAndFade">
<div className="text-left">
<MarkdownContent content={content} />
</div>
</div>
</div>
);
}
function InternalAssistantMessage({ content }: { content: string }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="w-full">
{!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="text-xs">Show debug message</span>
</button>
) : (
<div className="w-full">
<div className="border border-gray-200 dark:border-gray-700 border-dashed
px-4 py-2.5 rounded-lg text-sm
text-gray-700 dark:text-gray-200 shadow-sm">
<div className="flex justify-end mb-2">
<button className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
onClick={() => setExpanded(false)}>
<XIcon size={16} />
</button>
</div>
<pre className="whitespace-pre-wrap">{content}</pre>
</div>
</div>
)}
</div>
);
}
function AssistantMessage({
content,
workflow,
handleApplyChange,
appliedChanges,
messageIndex
}: {
content: z.infer<typeof CopilotAssistantMessage>['content'],
workflow: z.infer<typeof Workflow>,
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void,
appliedChanges: Record<string, boolean>,
messageIndex: number
}) {
return (
<div className="w-full">
<div className="px-4 py-2.5 text-sm leading-relaxed text-gray-700 dark:text-gray-200">
<div className="flex flex-col gap-2">
<div className="text-left">
{content.response.map((part, actionIndex) => {
if (part.type === "text") {
return <MarkdownContent key={actionIndex} content={part.content} />;
} else if (part.type === "action") {
return <Action
key={actionIndex}
msgIndex={messageIndex}
actionIndex={actionIndex}
action={part.content}
workflow={workflow}
handleApplyChange={handleApplyChange}
appliedChanges={appliedChanges}
stale={false}
/>;
}
return null;
})}
</div>
</div>
</div>
</div>
);
}
function AssistantMessageLoading() {
return (
<div className="w-full">
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-2.5
rounded-lg
border border-gray-200 dark:border-gray-700
shadow-sm dark:shadow-gray-950/20 animate-pulse min-h-[2.5rem] flex items-center">
<Spinner size="sm" className="ml-2" />
</div>
</div>
);
}
export function Messages({
messages,
loadingResponse,
workflow,
handleApplyChange,
appliedChanges
}: {
messages: z.infer<typeof CopilotMessage>[];
loadingResponse: boolean;
workflow: z.infer<typeof Workflow>;
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void;
appliedChanges: Record<string, boolean>;
}) {
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, loadingResponse]);
const renderMessage = (message: z.infer<typeof CopilotMessage>, messageIndex: number) => {
if (message.role === 'assistant') {
return (
<AssistantMessage
key={messageIndex}
content={message.content}
workflow={workflow}
handleApplyChange={handleApplyChange}
appliedChanges={appliedChanges}
messageIndex={messageIndex}
/>
);
}
if (message.role === 'user' && typeof message.content === 'string') {
return <UserMessage key={messageIndex} content={message.content} />;
}
return null;
};
return (
<div className="h-full">
<div className="flex flex-col space-y-4 px-4 pt-4">
<div className="flex flex-col space-y-4">
{messages.map((message, index) => (
<div key={index}>
{renderMessage(message, index)}
</div>
))}
{loadingResponse && <AssistantMessageLoading />}
</div>
<div ref={messagesEndRef} />
</div>
</div>
);
}

View file

@ -0,0 +1,450 @@
"use client";
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 { z } from "zod";
import { PlusIcon, Sparkles, X as XIcon } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { usePreviewModal } from "../workflow/preview-modal";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { PreviewModalProvider } from "../workflow/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";
import { createAtMentions } from "../../../lib/components/atmentions";
import { Textarea } from "@/components/ui/textarea";
import { Panel } from "@/components/common/panel-common";
import { Button as CustomButton } from "@/components/ui/button";
import clsx from "clsx";
import { EditableField } from "@/app/lib/components/editable-field";
// Common section header styles
const sectionHeaderStyles = "text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400";
// Common textarea styles
const textareaStyles = "rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500";
export function AgentConfig({
projectId,
workflow,
agent,
usedAgentNames,
agents,
tools,
prompts,
dataSources,
handleUpdate,
handleClose,
useRag,
}: {
projectId: string,
workflow: z.infer<typeof Workflow>,
agent: z.infer<typeof WorkflowAgent>,
usedAgentNames: Set<string>,
agents: z.infer<typeof WorkflowAgent>[],
tools: z.infer<typeof AgenticAPITool>[],
prompts: z.infer<typeof WorkflowPrompt>[],
dataSources: WithStringId<z.infer<typeof DataSource>>[],
handleUpdate: (agent: z.infer<typeof WorkflowAgent>) => void,
handleClose: () => void,
useRag: boolean,
}) {
const [isAdvancedConfigOpen, setIsAdvancedConfigOpen] = useState(false);
const [showGenerateModal, setShowGenerateModal] = useState(false);
const { showPreview } = usePreviewModal();
const [localName, setLocalName] = useState(agent.name);
const [nameError, setNameError] = useState<string | null>(null);
useEffect(() => {
setLocalName(agent.name);
}, [agent.name]);
const validateName = (value: string) => {
if (value.length === 0) {
setNameError("Name cannot be empty");
return false;
}
if (value !== agent.name && usedAgentNames.has(value)) {
setNameError("This name is already taken");
return false;
}
if (!/^[a-zA-Z0-9_-\s]+$/.test(value)) {
setNameError("Name must contain only letters, numbers, underscores, hyphens, and spaces");
return false;
}
setNameError(null);
return true;
};
const handleNameChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newName = e.target.value;
setLocalName(newName);
if (validateName(newName)) {
handleUpdate({
...agent,
name: newName
});
}
};
const atMentions = createAtMentions({
agents,
prompts,
tools,
currentAgentName: agent.name
});
return (
<Panel
title={
<div className="flex items-center justify-between w-full">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{agent.name}
</div>
<CustomButton
variant="secondary"
size="sm"
onClick={handleClose}
startContent={<XIcon className="w-4 h-4" />}
aria-label="Close agent config"
>
Close
</CustomButton>
</div>
}
>
<div className="flex flex-col gap-6 p-4">
{!agent.locked && (
<div className="space-y-4">
<div className="space-y-2">
<label className={sectionHeaderStyles}>
Name
</label>
<div className={clsx(
"border rounded-lg focus-within:ring-2",
nameError
? "border-red-500 focus-within:ring-red-500/20"
: "border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
)}>
<Textarea
value={agent.name}
useValidation={true}
updateOnBlur={true}
validate={(value) => {
const error = validateAgentName(value, agent.name, usedAgentNames);
setNameError(error);
return { valid: !error, errorMessage: error || undefined };
}}
onValidatedChange={(value) => {
handleUpdate({
...agent,
name: value
});
}}
placeholder="Enter agent name..."
className="w-full text-sm bg-transparent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
autoResize
/>
</div>
{nameError && (
<p className="text-sm text-red-500">{nameError}</p>
)}
</div>
</div>
)}
<div className="space-y-4">
<div className="space-y-2">
<label className={sectionHeaderStyles}>
Description
</label>
<Textarea
value={agent.description || ""}
onChange={(e) => {
handleUpdate({
...agent,
description: e.target.value
});
}}
placeholder="Enter a description for this agent"
className={textareaStyles}
autoResize
/>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className={sectionHeaderStyles}>
Instructions
</label>
<CustomButton
variant="primary"
size="sm"
onClick={() => setShowGenerateModal(true)}
startContent={<Sparkles className="w-4 h-4" />}
>
Generate
</CustomButton>
</div>
<EditableField
key="instructions"
value={agent.instructions}
onChange={(value) => {
handleUpdate({
...agent,
instructions: value
});
}}
markdown
multiline
mentions
mentionsAtValues={atMentions}
showSaveButton={true}
showDiscardButton={true}
className="border border-gray-200 dark:border-gray-700 rounded-lg focus-within:ring-2 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
/>
</div>
<div className="space-y-4">
<label className={sectionHeaderStyles}>
Examples
</label>
<EditableField
key="examples"
value={agent.examples || ""}
onChange={(value) => {
handleUpdate({
...agent,
examples: value
});
}}
placeholder="Enter examples for this agent"
markdown
multiline
mentions
mentionsAtValues={atMentions}
showSaveButton={true}
showDiscardButton={true}
className="border border-gray-200 dark:border-gray-700 rounded-lg focus-within:ring-2 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
/>
</div>
{useRag && (
<div className="space-y-4">
<label className={sectionHeaderStyles}>
RAG
</label>
<div className="flex flex-col gap-3">
<CustomButton
variant="secondary"
size="sm"
startContent={<PlusIcon className="w-4 h-4" />}
onClick={() => {/* existing dropdown logic */}}
>
Add data source
</CustomButton>
{/* ... rest of RAG section ... */}
</div>
</div>
)}
<div className="space-y-4">
<label className={sectionHeaderStyles}>
Model
</label>
<CustomDropdown
value={agent.model}
options={WorkflowAgent.shape.model.options.map((model) => ({
key: model.value,
label: model.value
}))}
onChange={(value) => handleUpdate({
...agent,
model: value as z.infer<typeof WorkflowAgent>['model']
})}
className="w-40"
/>
</div>
<div className="space-y-4">
<label className={sectionHeaderStyles}>
Conversation control after turn
</label>
<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: value as z.infer<typeof WorkflowAgent>['controlType']
})}
/>
</div>
<PreviewModalProvider>
<GenerateInstructionsModal
projectId={projectId}
workflow={workflow}
agent={agent}
isOpen={showGenerateModal}
onClose={() => setShowGenerateModal(false)}
currentInstructions={agent.instructions}
onApply={(newInstructions) => {
handleUpdate({
...agent,
instructions: newInstructions
});
}}
/>
</PreviewModalProvider>
</div>
</Panel>
);
}
function GenerateInstructionsModal({
projectId,
workflow,
agent,
isOpen,
onClose,
currentInstructions,
onApply
}: {
projectId: string,
workflow: z.infer<typeof Workflow>,
agent: z.infer<typeof WorkflowAgent>,
isOpen: boolean,
onClose: () => void,
currentInstructions: string,
onApply: (newInstructions: string) => void
}) {
const [prompt, setPrompt] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { showPreview } = usePreviewModal();
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (isOpen) {
setPrompt("");
setIsLoading(false);
setError(null);
textareaRef.current?.focus();
}
}, [isOpen]);
const handleGenerate = async () => {
setIsLoading(true);
setError(null);
try {
const msgs: z.infer<typeof CopilotMessage>[] = [
{
role: 'user',
content: prompt,
},
];
const newInstructions = await getCopilotAgentInstructions(projectId, msgs, workflow, agent.name);
onClose();
showPreview(
currentInstructions,
newInstructions,
true,
"Generated Instructions",
"Review the changes below:",
() => onApply(newInstructions)
);
} catch (err) {
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (prompt.trim() && !isLoading) {
handleGenerate();
}
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalContent>
<ModalHeader>Generate Instructions</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
{error && (
<div className="p-2 bg-red-50 border border-red-200 rounded-lg flex gap-2 justify-between items-center text-sm">
<p className="text-red-600">{error}</p>
<CustomButton
variant="primary"
size="sm"
onClick={() => {
setError(null);
handleGenerate();
}}
>
Retry
</CustomButton>
</div>
)}
<Textarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isLoading}
placeholder="e.g., This agent should help users analyze their data and provide insights..."
className={textareaStyles}
autoResize
/>
</div>
</ModalBody>
<ModalFooter>
<CustomButton
variant="secondary"
size="sm"
onClick={onClose}
disabled={isLoading}
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
size="sm"
onClick={handleGenerate}
disabled={!prompt.trim() || isLoading}
isLoading={isLoading}
>
Generate
</CustomButton>
</ModalFooter>
</ModalContent>
</Modal>
);
}
function validateAgentName(value: string, currentName?: string, usedNames?: Set<string>) {
if (value.length === 0) {
return "Name cannot be empty";
}
if (currentName && value !== currentName && usedNames?.has(value)) {
return "This name is already taken";
}
if (!/^[a-zA-Z0-9_-\s]+$/.test(value)) {
return "Name must contain only letters, numbers, underscores, hyphens, and spaces";
}
return null;
}

View file

@ -0,0 +1,133 @@
"use client";
import { WorkflowAgent, WorkflowPrompt, WorkflowTool } from "../../../lib/types/workflow_types";
import { z } from "zod";
import { XIcon } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { Panel } from "@/components/common/panel-common";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import clsx from "clsx";
// Common section header styles (matching tool_config)
const sectionHeaderStyles = "text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400";
// Enhanced textarea styles with improved states
const textareaStyles = "rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500";
export function PromptConfig({
prompt,
agents,
tools,
prompts,
usedPromptNames,
handleUpdate,
handleClose,
}: {
prompt: z.infer<typeof WorkflowPrompt>,
agents: z.infer<typeof WorkflowAgent>[],
tools: z.infer<typeof WorkflowTool>[],
prompts: z.infer<typeof WorkflowPrompt>[],
usedPromptNames: Set<string>,
handleUpdate: (prompt: z.infer<typeof WorkflowPrompt>) => void,
handleClose: () => void,
}) {
const [nameError, setNameError] = useState<string | null>(null);
const atMentions = [
...agents.map(a => ({ id: `agent:${a.name}`, value: `agent:${a.name}` })),
...prompts.filter(p => p.name !== prompt.name).map(p => ({ id: `prompt:${p.name}`, value: `prompt:${p.name}` })),
...tools.map(tool => ({ id: `tool:${tool.name}`, value: `tool:${tool.name}` }))
];
// Move validation function inside component to access props
const validatePromptName = (value: string) => {
if (value.length === 0) {
return "Name cannot be empty";
}
if (value !== prompt.name && usedPromptNames.has(value)) {
return "This name is already taken";
}
return null;
};
return (
<Panel
title={
<div className="flex items-center justify-between w-full">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{prompt.name}
</div>
<Button
variant="secondary"
size="sm"
onClick={handleClose}
startContent={<XIcon className="w-4 h-4" />}
aria-label="Close prompt config"
className="transition-colors"
>
Close
</Button>
</div>
}
>
<div className="flex flex-col gap-6 p-4">
{prompt.type === "base_prompt" && (
<div className="space-y-4">
<div className="space-y-2">
<label className={sectionHeaderStyles}>
Name
</label>
<div className={clsx(
"border rounded-lg focus-within:ring-2",
nameError
? "border-red-500 focus-within:ring-red-500/20"
: "border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
)}>
<Textarea
value={prompt.name}
useValidation={true}
updateOnBlur={true}
validate={(value) => {
const error = validatePromptName(value);
setNameError(error);
return { valid: !error, errorMessage: error || undefined };
}}
onValidatedChange={(value) => {
handleUpdate({
...prompt,
name: value
});
}}
placeholder="Enter prompt name..."
className="w-full text-sm bg-transparent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
autoResize
/>
</div>
{nameError && (
<p className="text-sm text-red-500">{nameError}</p>
)}
</div>
</div>
)}
<div className="space-y-4">
<label className={sectionHeaderStyles}>
Prompt
</label>
<Textarea
value={prompt.prompt}
onChange={(e) => {
handleUpdate({
...prompt,
prompt: e.target.value
});
}}
placeholder="Edit prompt here..."
className={`${textareaStyles} min-h-[200px]`}
autoResize
/>
</div>
</div>
</Panel>
);
}

View file

@ -0,0 +1,448 @@
"use client";
import { WorkflowTool } from "../../../lib/types/workflow_types";
import { Checkbox, Select, SelectItem, RadioGroup, Radio } from "@heroui/react";
import { z } from "zod";
import { ImportIcon, XIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Panel } from "@/components/common/panel-common";
import { Button } from "@/components/ui/button";
import clsx from "clsx";
// Update textarea styles with improved states
const textareaStyles = "rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500";
// Add divider styles
const dividerStyles = "border-t border-gray-200 dark:border-gray-800";
// Common section header styles
const sectionHeaderStyles = "text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400";
export function ParameterConfig({
param,
handleUpdate,
handleDelete,
handleRename,
readOnly
}: {
param: {
name: string,
description: string,
type: string,
required: boolean
},
handleUpdate: (name: string, data: {
description: string,
type: string,
required: boolean
}) => void,
handleDelete: (name: string) => void,
handleRename: (oldName: string, newName: string) => void,
readOnly?: boolean
}) {
return (
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
{param.name}
</div>
{!readOnly && (
<Button
variant="tertiary"
size="sm"
onClick={() => handleDelete(param.name)}
startContent={<XIcon className="w-4 h-4" />}
aria-label={`Remove parameter ${param.name}`}
>
Remove
</Button>
)}
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-medium text-gray-500 dark:text-gray-400">
Name
</label>
<Textarea
value={param.name}
onChange={(e) => {
const newName = e.target.value;
if (newName && newName !== param.name) {
handleRename(param.name, newName);
}
}}
placeholder="Enter parameter name..."
disabled={readOnly}
className={textareaStyles}
autoResize
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-500 dark:text-gray-400">
Description
</label>
<Textarea
value={param.description}
onChange={(e) => {
handleUpdate(param.name, {
...param,
description: e.target.value
});
}}
placeholder="Describe this parameter..."
disabled={readOnly}
className={textareaStyles}
autoResize
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-500 dark:text-gray-400">
Type
</label>
<Select
variant="bordered"
className="w-52"
size="sm"
selectedKeys={new Set([param.type])}
onSelectionChange={(keys) => {
handleUpdate(param.name, {
...param,
type: Array.from(keys)[0] as string
});
}}
isDisabled={readOnly}
>
{['string', 'number', 'boolean', 'array', 'object'].map(type => (
<SelectItem key={type}>
{type}
</SelectItem>
))}
</Select>
</div>
<Checkbox
size="sm"
isSelected={param.required}
onValueChange={() => {
handleUpdate(param.name, {
...param,
required: !param.required
});
}}
isDisabled={readOnly}
>
<span className="text-sm text-gray-500 dark:text-gray-400">
Required parameter
</span>
</Checkbox>
</div>
</div>
);
}
export function ToolConfig({
tool,
usedToolNames,
handleUpdate,
handleClose
}: {
tool: z.infer<typeof WorkflowTool>,
usedToolNames: Set<string>,
handleUpdate: (tool: z.infer<typeof WorkflowTool>) => void,
handleClose: () => void
}) {
const [selectedParams, setSelectedParams] = useState(new Set([]));
const isReadOnly = tool.isMcp;
const [nameError, setNameError] = useState<string | null>(null);
function handleParamRename(oldName: string, newName: string) {
const newProperties = { ...tool.parameters!.properties };
newProperties[newName] = newProperties[oldName];
delete newProperties[oldName];
const newRequired = [...(tool.parameters?.required || [])];
newRequired.splice(newRequired.indexOf(oldName), 1);
newRequired.push(newName);
handleUpdate({
...tool,
parameters: { ...tool.parameters!, properties: newProperties, required: newRequired }
});
}
function handleParamUpdate(name: string, data: {
description: string,
type: string,
required: boolean
}) {
const newProperties = { ...tool.parameters!.properties };
newProperties[name] = {
type: data.type,
description: data.description
};
const newRequired = [...(tool.parameters?.required || [])];
if (data.required) {
newRequired.push(name);
} else {
newRequired.splice(newRequired.indexOf(name), 1);
}
handleUpdate({
...tool,
parameters: {
...tool.parameters!,
properties: newProperties,
required: newRequired,
}
});
}
function handleParamDelete(paramName: string) {
const newProperties = { ...tool.parameters!.properties };
delete newProperties[paramName];
const newRequired = [...(tool.parameters?.required || [])];
newRequired.splice(newRequired.indexOf(paramName), 1);
handleUpdate({
...tool,
parameters: {
...tool.parameters!,
properties: newProperties,
required: newRequired,
}
});
}
function validateToolName(value: string) {
if (value.length === 0) {
return "Name cannot be empty";
}
return null;
}
return (
<Panel
title={
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{tool.name}
</div>
{tool.isMcp && (
<div className="flex items-center gap-2 text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded-full text-gray-700 dark:text-gray-300">
<ImportIcon className="w-4 h-4 text-blue-700 dark:text-blue-400" />
<span className="text-xs">MCP: {tool.mcpServerName}</span>
</div>
)}
</div>
<Button
variant="secondary"
size="sm"
onClick={handleClose}
startContent={<XIcon className="w-4 h-4" />}
aria-label="Close tool config"
>
Close
</Button>
</div>
}
>
<div className="flex flex-col gap-6 p-4">
{!isReadOnly && (
<div className="space-y-4">
<div className="space-y-2">
<label className={sectionHeaderStyles}>
Name
</label>
<div className={clsx(
"border rounded-lg focus-within:ring-2",
nameError
? "border-red-500 focus-within:ring-red-500/20"
: "border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
)}>
<Textarea
value={tool.name}
useValidation={true}
updateOnBlur={true}
validate={(value) => {
const error = validateToolName(value);
setNameError(error);
return { valid: !error, errorMessage: error || undefined };
}}
onValidatedChange={(value) => {
handleUpdate({
...tool,
name: value
});
}}
placeholder="Enter tool name..."
className="w-full text-sm bg-transparent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
autoResize
/>
</div>
{nameError && (
<p className="text-sm text-red-500">{nameError}</p>
)}
</div>
</div>
)}
<div className="space-y-4">
<div className="space-y-2">
<label className={sectionHeaderStyles}>
Description
</label>
<Textarea
value={tool.description}
onChange={(e) => handleUpdate({
...tool,
description: e.target.value
})}
placeholder="Describe what this tool does..."
disabled={isReadOnly}
className={textareaStyles}
autoResize
/>
</div>
</div>
{!isReadOnly && (
<div className="space-y-4">
<label className={sectionHeaderStyles}>
Tool Mode
</label>
<RadioGroup
defaultValue="mock"
value={tool.mockTool ? "mock" : "api"}
onValueChange={(value) => handleUpdate({
...tool,
mockTool: value === "mock",
autoSubmitMockedResponse: value === "mock" ? true : undefined
})}
orientation="horizontal"
classNames={{
wrapper: "flex gap-12 pl-3",
label: "text-sm"
}}
>
<Radio
value="mock"
classNames={{
base: "p-0 data-[selected=true]:bg-indigo-50 dark:data-[selected=true]:bg-indigo-950/50 rounded-lg transition-colors",
label: "text-base font-normal text-gray-900 dark:text-gray-100 px-3 py-1"
}}
>
Mock tool responses
</Radio>
<Radio
value="api"
classNames={{
base: "p-0 data-[selected=true]:bg-indigo-50 dark:data-[selected=true]:bg-indigo-900/50 rounded-lg transition-colors",
label: "text-base font-normal text-gray-900 dark:text-gray-100 px-3 py-1"
}}
>
Connect tool to your API
</Radio>
</RadioGroup>
</div>
)}
{tool.mockTool && (
<div className={`space-y-4 ${dividerStyles} pt-6`}>
<div className="space-y-4">
<label className={sectionHeaderStyles}>
Mock Settings
</label>
<div className="pl-3 space-y-4">
<Checkbox
size="sm"
isSelected={tool.autoSubmitMockedResponse ?? true}
onValueChange={(value) => handleUpdate({
...tool,
autoSubmitMockedResponse: value
})}
>
<span className="text-sm text-gray-600 dark:text-gray-300">
Automatically send mock response in chat
</span>
</Checkbox>
<Textarea
value={tool.mockInstructions || ''}
onChange={(e) => handleUpdate({
...tool,
mockInstructions: e.target.value
})}
placeholder="Describe the response the mock tool should return..."
className={textareaStyles}
autoResize
/>
</div>
</div>
</div>
)}
<div className={`space-y-4 ${dividerStyles} pt-6`}>
<label className={sectionHeaderStyles}>
Parameters
</label>
<div className="pl-3 space-y-3">
{Object.entries(tool.parameters?.properties || {}).map(([paramName, param], index) => (
<ParameterConfig
key={paramName}
param={{
name: paramName,
description: param.description,
type: param.type,
required: tool.parameters?.required?.includes(paramName) ?? false
}}
handleUpdate={handleParamUpdate}
handleDelete={handleParamDelete}
handleRename={handleParamRename}
readOnly={isReadOnly}
/>
))}
</div>
{!isReadOnly && (
<div className="pl-3">
<Button
variant="primary"
size="sm"
startContent={<PlusIcon className="w-4 h-4" />}
onClick={() => {
const newParamName = `param${Object.keys(tool.parameters?.properties || {}).length + 1}`;
const newProperties = {
...(tool.parameters?.properties || {}),
[newParamName]: {
type: 'string',
description: ''
}
};
handleUpdate({
...tool,
parameters: {
type: 'object',
properties: newProperties,
required: [...(tool.parameters?.required || []), newParamName]
}
});
}}
className="hover:bg-indigo-100 dark:hover:bg-indigo-900 hover:shadow-indigo-500/20 dark:hover:shadow-indigo-400/20 hover:shadow-lg transition-all"
>
Add Parameter
</Button>
</div>
)}
</div>
</div>
</Panel>
);
}

View file

@ -1,6 +1,3 @@
import { Nav } from "./nav";
import { USE_RAG } from "@/app/lib/feature_flags";
export default async function Layout({
params,
children
@ -8,12 +5,5 @@ export default async function Layout({
params: { projectId: string }
children: React.ReactNode
}) {
const useRag = USE_RAG;
return <div className="flex h-full">
<Nav projectId={params.projectId} useRag={useRag} />
<div className="grow p-2 overflow-auto bg-background dark:bg-background rounded-tl-lg">
{children}
</div>
</div>;
return children;
}

View file

@ -1,14 +1,16 @@
'use client';
import { useState } from "react";
import { z } from "zod";
import { MCPServer, PlaygroundChat } from "../../../lib/types/types";
import { Workflow } from "../../../lib/types/workflow_types";
import { Chat } from "./chat";
import { ActionButton, Pane } from "../workflow/pane";
import { MCPServer, PlaygroundChat } from "@/app/lib/types/types";
import { Workflow } from "@/app/lib/types/workflow_types";
import { Chat } from "./components/chat";
import { Panel } from "@/components/common/panel-common";
import { Button } from "@/components/ui/button";
import { apiV1 } from "rowboat-shared";
import { MessageSquarePlusIcon } from "lucide-react";
import { TestProfile } from "@/app/lib/types/testing_types";
import { WithStringId } from "@/app/lib/types/types";
import { ProfileSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/profile-selector";
import { CheckIcon, CopyIcon, PlusIcon, UserIcon } from "lucide-react";
const defaultSystemMessage = '';
@ -28,7 +30,7 @@ export function App({
toolWebhookUrl: string;
}) {
const [counter, setCounter] = useState<number>(0);
const [testProfile, setTestProfile] = useState<z.infer<typeof TestProfile> | null>(null);
const [testProfile, setTestProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
const [systemMessage, setSystemMessage] = useState<string>(defaultSystemMessage);
const [chat, setChat] = useState<z.infer<typeof PlaygroundChat>>({
projectId,
@ -37,6 +39,8 @@ export function App({
simulated: false,
systemMessage: defaultSystemMessage,
});
const [isProfileSelectorOpen, setIsProfileSelectorOpen] = useState(false);
const [showCopySuccess, setShowCopySuccess] = useState(false);
function handleSystemMessageChange(message: string) {
setSystemMessage(message);
@ -59,39 +63,94 @@ export function App({
});
}
const handleCopyJson = () => {
const jsonString = JSON.stringify({
messages: [{
role: 'system',
content: systemMessage,
}, ...chat.messages],
}, null, 2);
navigator.clipboard.writeText(jsonString);
setShowCopySuccess(true);
setTimeout(() => {
setShowCopySuccess(false);
}, 2000);
};
if (hidden) {
return <></>;
}
return (
<Pane
title="PLAYGROUND"
tooltip="Test your agents and see their responses in this interactive chat interface"
actions={[
<ActionButton
key="new-chat"
icon={<MessageSquarePlusIcon size={16} />}
onClick={handleNewChatButtonClick}
>
New chat
</ActionButton>,
]}
>
<div className="h-full overflow-auto">
<Chat
key={`chat-${counter}`}
chat={chat}
<>
<Panel
title={
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
PLAYGROUND
</div>
<Button
variant="primary"
size="sm"
onClick={handleNewChatButtonClick}
className="bg-blue-50 text-blue-700 hover:bg-blue-100"
showHoverContent={true}
hoverContent="New chat"
>
<PlusIcon className="w-4 h-4" />
</Button>
</div>
}
rightActions={
<div className="flex items-center gap-3">
<Button
variant="secondary"
size="sm"
onClick={() => setIsProfileSelectorOpen(true)}
showHoverContent={true}
hoverContent={testProfile?.name || 'Select test profile'}
>
<UserIcon className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleCopyJson}
showHoverContent={true}
hoverContent={showCopySuccess ? "Copied" : "Copy JSON"}
>
{showCopySuccess ? (
<CheckIcon className="w-4 h-4" />
) : (
<CopyIcon className="w-4 h-4" />
)}
</Button>
</div>
}
>
<ProfileSelector
projectId={projectId}
workflow={workflow}
testProfile={testProfile}
messageSubscriber={messageSubscriber}
onTestProfileChange={handleTestProfileChange}
systemMessage={systemMessage}
onSystemMessageChange={handleSystemMessageChange}
mcpServerUrls={mcpServerUrls}
toolWebhookUrl={toolWebhookUrl}
isOpen={isProfileSelectorOpen}
onOpenChange={setIsProfileSelectorOpen}
onSelect={handleTestProfileChange}
selectedProfileId={testProfile?._id}
/>
</div>
</Pane>
<div className="h-full overflow-auto px-4 py-4">
<Chat
key={`chat-${counter}`}
chat={chat}
projectId={projectId}
workflow={workflow}
testProfile={testProfile}
messageSubscriber={messageSubscriber}
onTestProfileChange={handleTestProfileChange}
systemMessage={systemMessage}
onSystemMessageChange={handleSystemMessageChange}
mcpServerUrls={mcpServerUrls}
toolWebhookUrl={toolWebhookUrl}
/>
</div>
</Panel>
</>
);
}

View file

@ -1,21 +1,19 @@
'use client';
import { getAssistantResponseStreamId } from "../../../actions/actions";
import { useEffect, useOptimistic, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { getAssistantResponseStreamId } from "@/app/actions/actions";
import { Messages } from "./messages";
import z from "zod";
import { MCPServer, PlaygroundChat } from "../../../lib/types/types";
import { AgenticAPIChatMessage, convertFromAgenticAPIChatMessages, convertToAgenticAPIChatMessages } from "../../../lib/types/agents_api_types";
import { convertWorkflowToAgenticAPI } from "../../../lib/types/agents_api_types";
import { AgenticAPIChatRequest } from "../../../lib/types/agents_api_types";
import { Workflow } from "../../../lib/types/workflow_types";
import { ComposeBox } from "./compose-box";
import { Button, Spinner, Tooltip } from "@heroui/react";
import { MCPServer, PlaygroundChat } from "@/app/lib/types/types";
import { AgenticAPIChatMessage, convertFromAgenticAPIChatMessages, convertToAgenticAPIChatMessages } from "@/app/lib/types/agents_api_types";
import { convertWorkflowToAgenticAPI } from "@/app/lib/types/agents_api_types";
import { AgenticAPIChatRequest } from "@/app/lib/types/agents_api_types";
import { Workflow } from "@/app/lib/types/workflow_types";
import { ComposeBox } from "@/components/common/compose-box";
import { Button } from "@heroui/react";
import { apiV1 } from "rowboat-shared";
import { CopyAsJsonButton } from "./copy-as-json-button";
import { TestProfile } from "@/app/lib/types/testing_types";
import { ProfileSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/profile-selector";
import { WithStringId } from "@/app/lib/types/types";
import { XIcon } from "lucide-react";
import { ProfileContextBox } from "./profile-context-box";
export function Chat({
chat,
@ -50,6 +48,7 @@ export function Chat({
const [lastAgenticResponse, setLastAgenticResponse] = useState<unknown | null>(null);
const [isProfileSelectorOpen, setIsProfileSelectorOpen] = useState(false);
const [optimisticMessages, setOptimisticMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages);
const messagesEndRef = useRef<HTMLDivElement>(null);
// reset optimistic messages when messages change
useEffect(() => {
@ -224,72 +223,55 @@ export function Chat({
fetchResponseError,
]);
const handleCopyChat = () => {
const jsonString = JSON.stringify({
messages: [{
role: 'system',
content: systemMessage,
}, ...messages],
lastRequest: lastAgenticRequest,
lastResponse: lastAgenticResponse,
}, null, 2);
navigator.clipboard.writeText(jsonString);
}
return <div className="relative h-full flex flex-col gap-8 pt-8 overflow-auto">
<CopyAsJsonButton onCopy={handleCopyChat} />
<div className="absolute top-0 left-0 flex items-center gap-1">
<Tooltip content={"Change profile"} placement="right">
<button
className="border border-gray-200 dark:border-gray-800 p-2 rounded-lg text-xs hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={() => setIsProfileSelectorOpen(true)}
>
{`${testProfile?.name || 'Select test profile'}`}
</button>
</Tooltip>
{testProfile && <Tooltip content={"Remove profile"} placement="right">
<button className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300" onClick={() => onTestProfileChange(null)}>
<XIcon className="w-4 h-4" />
</button>
</Tooltip>}
return <div className="relative max-w-3xl mx-auto h-full flex flex-col">
<div className="sticky top-0 z-10 bg-white dark:bg-zinc-900 pt-4 pb-4">
<ProfileContextBox
content={testProfile?.context || systemMessage || ''}
onChange={onSystemMessageChange}
locked={testProfile !== null}
/>
</div>
<ProfileSelector
projectId={projectId}
isOpen={isProfileSelectorOpen}
onOpenChange={setIsProfileSelectorOpen}
onSelect={onTestProfileChange}
/>
<Messages
projectId={projectId}
messages={optimisticMessages}
toolCallResults={toolCallResults}
loadingAssistantResponse={loadingAssistantResponse}
workflow={workflow}
testProfile={testProfile}
systemMessage={systemMessage}
onSystemMessageChange={onSystemMessageChange}
/>
<div className="shrink-0">
<div className="flex-1 overflow-auto pr-1
[&::-webkit-scrollbar]{width:4px}
[&::-webkit-scrollbar-track]{background:transparent}
[&::-webkit-scrollbar-thumb]{background-color:rgb(156 163 175)}
dark:[&::-webkit-scrollbar-thumb]{background-color:#2a2d31}">
<div className="pr-4">
<Messages
projectId={projectId}
messages={optimisticMessages}
toolCallResults={toolCallResults}
loadingAssistantResponse={loadingAssistantResponse}
workflow={workflow}
testProfile={testProfile}
systemMessage={systemMessage}
onSystemMessageChange={onSystemMessageChange}
showSystemMessage={false}
/>
</div>
</div>
<div className="sticky bottom-0 bg-white dark:bg-zinc-900 pt-4 pb-2">
{fetchResponseError && (
<div className="max-w-[768px] mx-auto mb-4 p-2 bg-red-50 border border-red-200 rounded-lg flex gap-2 justify-between items-center">
<p className="text-red-600">{fetchResponseError}</p>
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800
rounded-lg flex gap-2 justify-between items-center">
<p className="text-red-600 dark:text-red-400 text-sm">{fetchResponseError}</p>
<Button
size="sm"
color="danger"
onPress={() => {
setFetchResponseError(null);
}}
onPress={() => setFetchResponseError(null)}
>
Retry
</Button>
</div>
)}
<div className="max-w-[768px] mx-auto">
<ComposeBox
handleUserMessage={handleUserMessage}
messages={messages}
/>
</div>
<ComposeBox
handleUserMessage={handleUserMessage}
messages={messages.filter(msg => msg.content !== undefined) as any}
loading={loadingAssistantResponse}
/>
</div>
</div>;
}

View file

@ -0,0 +1,395 @@
'use client';
import { Spinner } from "@heroui/react";
import { useEffect, useMemo, useRef, useState } from "react";
import z from "zod";
import { Workflow } from "@/app/lib/types/workflow_types";
import { WorkflowTool } from "@/app/lib/types/workflow_types";
import MarkdownContent from "@/app/lib/components/markdown-content";
import { apiV1 } from "rowboat-shared";
import { MessageSquareIcon, EllipsisIcon, CircleCheckIcon, ChevronRightIcon, ChevronDownIcon, XIcon } from "lucide-react";
import { TestProfile } from "@/app/lib/types/testing_types";
import { ProfileContextBox } from "./profile-context-box";
function UserMessage({ content }: { content: string }) {
return (
<div className="self-end flex flex-col items-end gap-1">
<div className="text-gray-500 dark:text-gray-400 text-xs">
User
</div>
<div className="max-w-[85%] inline-block">
<div className="bg-blue-50 dark:bg-[#1e2023] px-4 py-2.5
rounded-2xl rounded-br-lg text-sm leading-relaxed
text-gray-700 dark:text-gray-200
border border-blue-100 dark:border-[#2a2d31]
shadow-sm animate-slideUpAndFade">
<div className="text-left">
<MarkdownContent content={content} />
</div>
</div>
</div>
</div>
);
}
function InternalAssistantMessage({ content, sender, latency }: { content: string, sender: string | null | undefined, latency: number }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="self-start flex flex-col gap-1">
{!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="text-xs">Show debug message</span>
</button>
) : (
<>
<div className="text-gray-500 dark:text-gray-400 text-xs pl-1 flex items-center justify-between">
<span>{sender ?? 'Assistant'}</span>
<button className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
onClick={() => setExpanded(false)}>
<XIcon size={16} />
</button>
</div>
<div className="max-w-[85%] inline-block">
<div className="border border-gray-200 dark:border-gray-700 border-dashed
px-4 py-2.5 rounded-2xl rounded-bl-lg text-sm
text-gray-700 dark:text-gray-200 shadow-sm">
<pre className="whitespace-pre-wrap">{content}</pre>
</div>
</div>
</>
)}
</div>
);
}
function AssistantMessage({ content, sender, latency }: { content: string, sender: string | null | undefined, latency: number }) {
return (
<div className="self-start flex flex-col gap-1">
<div className="text-gray-500 dark:text-gray-400 text-xs pl-1">
{sender ?? 'Assistant'}
</div>
<div className="max-w-[85%] inline-block">
<div className="bg-gray-50 dark:bg-[#1e2023] px-4 py-2.5
rounded-2xl rounded-bl-lg text-sm leading-relaxed
text-gray-700 dark:text-gray-200
border border-gray-200 dark:border-[#2a2d31]
shadow-sm animate-slideUpAndFade">
<div className="flex flex-col gap-1">
<div className="text-left">
<MarkdownContent content={content} />
</div>
{latency > 0 && <div className="text-right text-xs text-gray-400 dark:text-gray-500 mt-1">
{Math.round(latency / 1000)}s
</div>}
</div>
</div>
</div>
</div>
);
}
function AssistantMessageLoading() {
return (
<div className="self-start flex flex-col gap-1">
<div className="text-gray-500 dark:text-gray-400 text-xs pl-1">
Assistant
</div>
<div className="max-w-[85%] inline-block">
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-2.5
rounded-2xl rounded-bl-lg
border border-gray-200 dark:border-gray-700
shadow-sm dark:shadow-gray-950/20 animate-pulse min-h-[2.5rem] flex items-center">
<Spinner size="sm" className="ml-2" />
</div>
</div>
</div>
);
}
function ToolCalls({
toolCalls,
results,
projectId,
messages,
sender,
workflow,
testProfile = null,
systemMessage,
}: {
toolCalls: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'];
results: Record<string, z.infer<typeof apiV1.ToolMessage>>;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | null | undefined;
workflow: z.infer<typeof Workflow>;
testProfile: z.infer<typeof TestProfile> | null;
systemMessage: string | undefined;
}) {
return <div className="flex flex-col gap-4">
{toolCalls.map(toolCall => {
return <ToolCall
key={toolCall.id}
toolCall={toolCall}
result={results[toolCall.id]}
sender={sender}
workflow={workflow}
/>
})}
</div>;
}
function ToolCall({
toolCall,
result,
sender,
workflow,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
sender: string | null | undefined;
workflow: z.infer<typeof Workflow>;
}) {
let matchingWorkflowTool: z.infer<typeof WorkflowTool> | undefined;
for (const tool of workflow.tools) {
if (tool.name === toolCall.function.name) {
matchingWorkflowTool = tool;
break;
}
}
if (toolCall.function.name.startsWith('transfer_to_')) {
return <TransferToAgentToolCall
result={result}
sender={sender}
/>;
}
return <ClientToolCall
toolCall={toolCall}
result={result}
sender={sender}
/>;
}
function TransferToAgentToolCall({
result: availableResult,
sender,
}: {
result: z.infer<typeof apiV1.ToolMessage> | undefined;
sender: string | null | undefined;
}) {
const typedResult = availableResult ? JSON.parse(availableResult.content) as { assistant: string } : undefined;
if (!typedResult) {
return <></>;
}
return <div className="flex gap-1 items-center text-gray-500 text-sm justify-center">
<div>{sender}</div>
<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 12H5m14 0-4 4m4-4-4-4" />
</svg>
<div>{typedResult.assistant}</div>
</div>;
}
function ClientToolCall({
toolCall,
result: availableResult,
sender,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
sender: string | null | undefined;
}) {
return (
<div className="self-start flex flex-col gap-1">
{sender && (
<div className="text-gray-500 dark:text-gray-400 text-xs pl-1">
{sender}
</div>
)}
<div className="min-w-[85%] inline-block">
<div className="border border-gray-200 dark:border-gray-700 p-3
rounded-2xl rounded-bl-lg flex flex-col gap-2
bg-gray-50 dark:bg-gray-800 shadow-sm dark:shadow-gray-950/20">
<div className="flex flex-col gap-1">
<div className="shrink-0 flex gap-2 items-center">
{!availableResult && <Spinner size="sm" />}
{availableResult && <CircleCheckIcon size={16} />}
<div className="font-semibold text-sm">
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>
<div className="flex flex-col gap-2">
<ExpandableContent label="Params" content={toolCall.function.arguments} expanded={false} />
{availableResult && <ExpandableContent label="Result" content={availableResult.content} expanded={false} />}
</div>
</div>
</div>
</div>
);
}
function ExpandableContent({
label,
content,
expanded = false
}: {
label: string,
content: string | object | undefined,
expanded?: boolean
}) {
const [isExpanded, setIsExpanded] = useState(expanded);
const formattedContent = useMemo(() => {
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content);
return JSON.stringify(parsed, null, 2);
} catch (e) {
// If it's not JSON, return the string as-is
return content;
}
}
if (typeof content === 'object') {
return JSON.stringify(content, null, 2);
}
return 'undefined';
}, [content]);
function toggleExpanded() {
setIsExpanded(!isExpanded);
}
const isMarkdown = label === 'Result' && typeof content === 'string' && !content.startsWith('{');
return <div className='flex flex-col gap-2'>
<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 && (
isMarkdown ? (
<div className='text-sm bg-gray-100 dark:bg-gray-800 p-2 rounded text-gray-900 dark:text-gray-100'>
<MarkdownContent content={content as string} />
</div>
) : (
<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>;
}
export function Messages({
projectId,
messages,
toolCallResults,
loadingAssistantResponse,
workflow,
testProfile = null,
systemMessage,
onSystemMessageChange,
showSystemMessage,
}: {
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
toolCallResults: Record<string, z.infer<typeof apiV1.ToolMessage>>;
loadingAssistantResponse: boolean;
workflow: z.infer<typeof Workflow>;
testProfile: z.infer<typeof TestProfile> | null;
systemMessage: string | undefined;
onSystemMessageChange: (message: string) => void;
showSystemMessage: boolean;
}) {
const messagesEndRef = useRef<HTMLDivElement>(null);
let lastUserMessageTimestamp = 0;
let userMessageSeen = false;
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, loadingAssistantResponse]);
const renderMessage = (message: z.infer<typeof apiV1.ChatMessage>, index: number) => {
const isConsecutive = index > 0 && messages[index - 1].role === message.role;
if (message.role === 'assistant') {
// the assistant message createdAt is an ISO string timestamp
let latency = new Date(message.createdAt).getTime() - lastUserMessageTimestamp;
// if this is the first message, set the latency to 0
if (!userMessageSeen) {
latency = 0;
}
if ('tool_calls' in message) {
return (
<ToolCalls
toolCalls={message.tool_calls}
results={toolCallResults}
projectId={projectId}
messages={messages}
sender={message.agenticSender}
workflow={workflow}
testProfile={testProfile}
systemMessage={systemMessage}
/>
);
}
return message.agenticResponseType === 'internal' ? (
<InternalAssistantMessage
content={message.content}
sender={message.agenticSender}
latency={latency}
/>
) : (
<AssistantMessage
content={message.content}
sender={message.agenticSender}
latency={latency}
/>
);
}
if (message.role === 'user' && typeof message.content === 'string') {
lastUserMessageTimestamp = new Date(message.createdAt).getTime();
userMessageSeen = true;
return <UserMessage content={message.content} />;
}
return null;
};
if (showSystemMessage) {
return (
<ProfileContextBox
content={testProfile?.context || systemMessage || ''}
onChange={onSystemMessageChange}
locked={testProfile !== null}
/>
);
}
return (
<div className="max-w-[768px] mx-auto">
<div className="flex flex-col space-y-2">
{messages.map((message, index) => (
<div
key={index}
className={`${index > 0 && messages[index - 1].role === message.role ? 'mt-1' : 'mt-4'}`}
>
{renderMessage(message, index)}
</div>
))}
{loadingAssistantResponse && <AssistantMessageLoading />}
</div>
<div ref={messagesEndRef} />
</div>
);
}

View file

@ -0,0 +1,66 @@
'use client';
import { useRef, useState } from "react";
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
interface ProfileContextBoxProps {
content: string;
onChange: (content: string) => void;
locked?: boolean;
}
export function ProfileContextBox({
content,
onChange,
locked = false,
}: ProfileContextBoxProps) {
const [isExpanded, setIsExpanded] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Calculate the content height (number of lines * line height + padding)
const getContentHeight = () => {
if (!content) return 'auto';
const lineCount = content.split('\n').length;
const minHeight = 40; // minimum height in pixels
const lineHeight = 20; // approximate line height in pixels
const height = Math.max(minHeight, Math.min(300, lineCount * lineHeight + 32)); // 32px for padding
return `${height}px`;
};
return (
<div className="text-sm border border-gray-200 dark:border-[#2a2d31] rounded-lg">
<div
className={`flex items-center gap-2 cursor-pointer text-gray-500 dark:text-gray-400
hover:text-gray-700 dark:hover:text-gray-300
px-3 py-2 bg-transparent dark:bg-[#1e2023]
${isExpanded ? 'border-b border-gray-200 dark:border-[#2a2d31]' : ''}`}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? (
<ChevronDownIcon className="w-4 h-4" />
) : (
<ChevronRightIcon className="w-4 h-4" />
)}
<span className="font-medium">Profile Context</span>
</div>
{isExpanded && (
<Textarea
ref={textareaRef}
value={content}
readOnly
disabled
placeholder="Select a test profile to provide context"
style={{ height: getContentHeight() }}
className="border-0 rounded-none cursor-not-allowed
bg-gray-50 dark:bg-[#1e2023]
[&::-webkit-scrollbar]{width:6px}
[&::-webkit-scrollbar-track]{background:transparent}
[&::-webkit-scrollbar-thumb]{background-color:rgb(156 163 175)}
dark:[&::-webkit-scrollbar-thumb]{background-color:#2a2d31}
overflow-y-auto
placeholder:px-3 placeholder:pt-3"
/>
)}
</div>
);
}

View file

@ -1,68 +0,0 @@
'use client';
import { Button, Spinner, Textarea } from "@heroui/react";
import { CornerDownLeftIcon } from "lucide-react";
import { useRef, useState, useEffect } from "react";
import { apiV1 } from "rowboat-shared";
import { z } from "zod";
export function ComposeBox({
minRows=3,
disabled=false,
loading=false,
handleUserMessage,
messages,
}: {
minRows?: number;
disabled?: boolean;
loading?: boolean;
handleUserMessage: (prompt: string) => void;
messages: z.infer<typeof apiV1.ChatMessage>[];
}) {
const [input, setInput] = useState('');
const inputRef = useRef<HTMLTextAreaElement>(null);
function handleInput() {
const prompt = input.trim();
if (!prompt) {
return;
}
setInput('');
handleUserMessage(prompt);
}
function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleInput();
}
}
// focus on the input field
useEffect(() => {
inputRef.current?.focus();
}, [messages]);
return <Textarea
required
ref={inputRef}
variant="bordered"
placeholder="Enter message..."
minRows={minRows}
maxRows={15}
value={input}
onValueChange={setInput}
onKeyDown={handleInputKeyDown}
disabled={disabled}
className="w-full"
endContent={<Button
size="sm"
isIconOnly
disabled={disabled}
onPress={handleInput}
className="bg-default-100"
>
<CornerDownLeftIcon size={16} />
</Button>}
/>;
}

View file

@ -1,343 +0,0 @@
'use client';
import { Spinner } from "@heroui/react";
import { useEffect, useMemo, useRef, useState } from "react";
import z from "zod";
import { Workflow } from "../../../lib/types/workflow_types";
import { WorkflowTool } from "../../../lib/types/workflow_types";
import MarkdownContent from "../../../lib/components/markdown-content";
import { apiV1 } from "rowboat-shared";
import { EditableField } from "../../../lib/components/editable-field";
import { MessageSquareIcon, EllipsisIcon, CircleCheckIcon, ChevronRightIcon, ChevronDownIcon, XIcon } from "lucide-react";
import { TestProfile } from "@/app/lib/types/testing_types";
function UserMessage({ content }: { content: string }) {
return <div className="self-end ml-[30%] flex flex-col">
<div className="text-right text-gray-500 dark:text-gray-400 text-xs mr-3">
User
</div>
<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>;
}
function InternalAssistantMessage({ content, sender, latency }: { content: string, sender: string | null | undefined, latency: number }) {
const [expanded, setExpanded] = useState(false);
return <div className="self-start mr-[30%]">
{!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 dark:text-gray-400 text-xs pl-3">
{sender ?? 'Assistant'}
</div>
<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 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>}
</div>;
}
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 dark:text-gray-400 text-xs pl-3">
{sender ?? 'Assistant'}
</div>
{latency > 0 && <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 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 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" />
</div>;
}
function ToolCalls({
toolCalls,
results,
projectId,
messages,
sender,
workflow,
testProfile = null,
systemMessage,
}: {
toolCalls: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'];
results: Record<string, z.infer<typeof apiV1.ToolMessage>>;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | null | undefined;
workflow: z.infer<typeof Workflow>;
testProfile: z.infer<typeof TestProfile> | null;
systemMessage: string | undefined;
}) {
return <div className="flex flex-col gap-4">
{toolCalls.map(toolCall => {
return <ToolCall
key={toolCall.id}
toolCall={toolCall}
result={results[toolCall.id]}
sender={sender}
workflow={workflow}
/>
})}
</div>;
}
function ToolCall({
toolCall,
result,
sender,
workflow,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
sender: string | null | undefined;
workflow: z.infer<typeof Workflow>;
}) {
let matchingWorkflowTool: z.infer<typeof WorkflowTool> | undefined;
for (const tool of workflow.tools) {
if (tool.name === toolCall.function.name) {
matchingWorkflowTool = tool;
break;
}
}
if (toolCall.function.name.startsWith('transfer_to_')) {
return <TransferToAgentToolCall
result={result}
sender={sender}
/>;
}
return <ClientToolCall
toolCall={toolCall}
result={result}
sender={sender}
/>;
}
function TransferToAgentToolCall({
result: availableResult,
sender,
}: {
result: z.infer<typeof apiV1.ToolMessage> | undefined;
sender: string | null | undefined;
}) {
const typedResult = availableResult ? JSON.parse(availableResult.content) as { assistant: string } : undefined;
if (!typedResult) {
return <></>;
}
return <div className="flex gap-1 items-center text-gray-500 text-sm justify-center">
<div>{sender}</div>
<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M19 12H5m14 0-4 4m4-4-4-4" />
</svg>
<div>{typedResult.assistant}</div>
</div>;
}
function ClientToolCall({
toolCall,
result: availableResult,
sender,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
sender: string | null | undefined;
}) {
return <div className="flex flex-col gap-1">
{sender && <div className='text-gray-500 text-sm ml-3'>{sender}</div>}
<div className='border border-gray-300 p-2 pt-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%]'>
<div className="flex flex-col gap-1">
<div className='shrink-0 flex gap-2 items-center'>
{!availableResult && <Spinner size="sm" />}
{availableResult && <CircleCheckIcon size={16} />}
<div className='font-semibold text-sm'>
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>
<div className='flex flex-col gap-2'>
<ExpandableContent label='Params' content={toolCall.function.arguments} expanded={false} />
{availableResult && <ExpandableContent label='Result' content={availableResult.content} expanded={false} />}
</div>
</div>
</div>;
}
function ExpandableContent({
label,
content,
expanded = false
}: {
label: string,
content: string | object | undefined,
expanded?: boolean
}) {
const [isExpanded, setIsExpanded] = useState(expanded);
const formattedContent = useMemo(() => {
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content);
return JSON.stringify(parsed, null, 2);
} catch (e) {
return content;
}
}
if (typeof content === 'object') {
return JSON.stringify(content, null, 2);
}
return 'undefined';
}, [content]);
function toggleExpanded() {
setIsExpanded(!isExpanded);
}
return <div className='flex flex-col gap-2'>
<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 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>;
}
function SystemMessage({
content,
onChange,
locked = false,
}: {
content: string,
onChange: (content: string) => void,
locked?: boolean,
}) {
return <div className="text-sm">
<EditableField
label="Context"
value={content}
onChange={onChange}
locked={locked}
multiline
markdown
placeholder={`Provide context about the user (e.g. user ID, user name) to the assistant at the start of chat, for testing purposes.`}
showSaveButton={true}
showDiscardButton={true}
/>
</div>;
}
export function Messages({
projectId,
messages,
toolCallResults,
loadingAssistantResponse,
workflow,
testProfile = null,
systemMessage,
onSystemMessageChange,
}: {
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
toolCallResults: Record<string, z.infer<typeof apiV1.ToolMessage>>;
loadingAssistantResponse: boolean;
workflow: z.infer<typeof Workflow>;
testProfile: z.infer<typeof TestProfile> | null;
systemMessage: string | undefined;
onSystemMessageChange: (message: string) => void;
}) {
const messagesEndRef = useRef<HTMLDivElement>(null);
let lastUserMessageTimestamp = 0;
let userMessageSeen = false;
// scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages, loadingAssistantResponse]);
return <div className="grow pt-4 overflow-auto">
<div className="max-w-[768px] mx-auto flex flex-col gap-8">
<SystemMessage
content={testProfile?.context || systemMessage || ''}
onChange={onSystemMessageChange}
locked={testProfile !== null}
/>
{messages.map((message, index) => {
if (message.role === 'assistant') {
if ('tool_calls' in message) {
return <ToolCalls
key={index}
toolCalls={message.tool_calls}
results={toolCallResults}
projectId={projectId}
messages={messages}
sender={message.agenticSender}
workflow={workflow}
testProfile={testProfile}
systemMessage={systemMessage}
/>;
} else {
// the assistant message createdAt is an ISO string timestamp
let latency = new Date(message.createdAt).getTime() - lastUserMessageTimestamp;
// if this is the first message, set the latency to 0
if (!userMessageSeen) {
latency = 0;
}
if (message.agenticResponseType === 'internal') {
return (
<InternalAssistantMessage
key={index}
content={message.content}
sender={message.agenticSender}
latency={latency}
/>
);
} else {
return (
<AssistantMessage
key={index}
content={message.content}
sender={message.agenticSender}
latency={latency}
/>
);
}
}
}
if (message.role === 'user' && typeof message.content === 'string') {
lastUserMessageTimestamp = new Date(message.createdAt).getTime();
userMessageSeen = true;
return <UserMessage key={index} content={message.content} />;
}
return <></>;
})}
{loadingAssistantResponse && <AssistantMessageLoading key="assistant-loading" />}
<div ref={messagesEndRef} />
</div>
</div>;
}

View file

@ -1,275 +0,0 @@
"use client";
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";
import { z } from "zod";
import { Recrawl } from "./web-recrawl";
import { deleteDocsFromDataSource, listDocsInDataSource, recrawlWebDataSource, addDocsToDataSource } from "../../../../actions/datasource_actions";
import { useState, useEffect } from "react";
import { Spinner } from "@heroui/react";
import { Pagination } from "@heroui/react";
import { ExternalLinkIcon } from "lucide-react";
import { Textarea } from "@heroui/react";
import { FormStatusButton } from "../../../../lib/components/form-status-button";
import { PlusIcon } from "lucide-react";
function UrlListItem({
file,
onDelete,
}: {
file: WithStringId<z.infer<typeof DataSourceDoc>>,
onDelete: (fileId: string) => Promise<void>;
}) {
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteClick = async () => {
setIsDeleting(true);
try {
await onDelete(file._id);
} finally {
setIsDeleting(false);
}
};
if (file.data.type !== 'url') {
return null;
}
return (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded">
<div>
<div className="flex items-center gap-2">
<p className="font-medium">{file.name}</p>
<div className="shrink-0">
<a href={file.data.url} target="_blank" rel="noopener noreferrer">
<ExternalLinkIcon className="w-4 h-4" />
</a>
</div>
</div>
</div>
<div className="flex gap-2 items-center">
<button
onClick={handleDeleteClick}
disabled={isDeleting}
className={`${isDeleting ? 'text-gray-400' : 'text-red-600 hover:text-red-800'}`}
>
{isDeleting ? (
<Spinner size="sm" />
) : (
'Delete'
)}
</button>
</div>
</div>
);
}
function UrlList({
projectId,
sourceId,
onDelete,
}: {
projectId: string,
sourceId: string,
onDelete: (fileId: string) => Promise<void>,
}) {
const [files, setFiles] = useState<WithStringId<z.infer<typeof DataSourceDoc>>[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const totalPages = Math.ceil(total / 10);
useEffect(() => {
let ignore = false;
async function fetchFiles() {
setLoading(true);
try {
const { files, total } = await listDocsInDataSource({ projectId, sourceId, page, limit: 10 });
if (!ignore) {
setFiles(files);
setTotal(total);
}
} catch (error) {
console.error('Error fetching files:', error);
} finally {
setLoading(false);
}
}
fetchFiles();
return () => {
ignore = true;
};
}, [projectId, sourceId, page]);
return (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3">URLs</h3>
{loading && <div className="flex items-center justify-center gap-2">
<Spinner size="sm" />
<p>Loading list...</p>
</div>}
{!loading && files.length === 0 && <div className="flex items-center justify-center gap-2">
<p>No files uploaded yet</p>
</div>}
{!loading && files.length > 0 && <div className="space-y-2">
{files.map(file => (
<UrlListItem
key={file._id}
file={file}
onDelete={onDelete}
/>
))}
{totalPages > 1 && <Pagination
total={totalPages}
page={page}
onChange={setPage}
/>}
</div>}
</div>
)
}
function AddUrls({
projectId,
sourceId,
onAdd,
}: {
projectId: string,
sourceId: string,
onAdd: () => void,
}) {
const [isAdding, setIsAdding] = useState(false);
const [showForm, setShowForm] = useState(false);
async function handleSubmit(formData: FormData) {
setIsAdding(true);
try {
const urls = formData.get('urls') as string;
const urlsArray = urls.split('\n')
.map(url => url.trim())
.filter(url => url.length > 0);
const first100Urls = urlsArray.slice(0, 100);
await addDocsToDataSource({
projectId,
sourceId,
docData: first100Urls.map(url => ({
name: url,
data: {
type: 'url',
url,
},
})),
});
onAdd();
setShowForm(false); // Hide form after successful submission
} finally {
setIsAdding(false);
}
}
return (
<div>
{!showForm ? (
<FormStatusButton
props={{
onClick: () => setShowForm(true),
children: "Add more URLs",
className: "self-start",
startContent: <PlusIcon className="w-[24px] h-[24px]" />,
}}
/>
) : (
<div className="space-y-4">
<form action={handleSubmit} className="flex flex-col gap-4">
<Textarea
required
type="text"
name="urls"
label="Add more URLs (one per line)"
minRows={5}
maxRows={10}
labelPlacement="outside"
placeholder="https://example.com"
variant="bordered"
/>
<div className="flex gap-2">
<FormStatusButton
props={{
type: "submit",
children: "Add URLs",
className: "self-start",
startContent: <PlusIcon className="w-[24px] h-[24px]" />,
isLoading: isAdding,
}}
/>
<button
type="button"
onClick={() => setShowForm(false)}
className="text-gray-500 hover:text-gray-700"
>
Cancel
</button>
</div>
</form>
</div>
)}
</div>
);
}
export function ScrapeSource({
projectId,
dataSource,
handleReload,
}: {
projectId: string,
dataSource: WithStringId<z.infer<typeof DataSource>>,
handleReload: () => void;
}) {
const [fileListKey, setFileListKey] = useState(0);
async function handleRefresh() {
await recrawlWebDataSource(projectId, dataSource._id);
handleReload();
setFileListKey(prev => prev + 1);
}
async function handleDelete(docId: string) {
await deleteDocsFromDataSource({
projectId,
sourceId: dataSource._id,
docIds: [docId],
});
handleReload();
setFileListKey(prev => prev + 1);
}
return <>
<PageSection title="Add URLs">
<AddUrls
projectId={projectId}
sourceId={dataSource._id}
onAdd={() => handleReload()}
/>
</PageSection>
<PageSection title="Index details">
<UrlList
projectId={projectId}
sourceId={dataSource._id}
onDelete={handleDelete}
/>
</PageSection>
{(dataSource.status === 'ready' || dataSource.status === 'error') && <PageSection title="Refresh">
<div className="flex flex-col gap-2 items-start">
<p>Scrape the URLs again to fetch updated content:</p>
<Recrawl projectId={projectId} sourceId={dataSource._id} handleRefresh={handleRefresh} />
</div>
</PageSection>}
</>;
}

View file

@ -1,19 +1,19 @@
'use client';
import { WithStringId } from "../../../../lib/types/types";
import { DataSource } from "../../../../lib/types/datasource_types";
import { PageSection } from "../../../../lib/components/page-section";
import { ToggleSource } from "../toggle-source";
import { ToggleSource } from "../components/toggle-source";
import { Spinner } from "@heroui/react";
import { SourceStatus } from "../source-status";
import { DeleteSource } from "./delete";
import { SourceStatus } from "../components/source-status";
import { DeleteSource } from "../components/delete";
import { useEffect, useState } from "react";
import { DataSourceIcon } from "../../../../lib/components/datasource-icon";
import { z } from "zod";
import { TableLabel, TableValue } from "./shared";
import { ScrapeSource } from "./scrape-source";
import { FilesSource } from "./files-source";
import { ScrapeSource } from "../components/scrape-source";
import { FilesSource } from "../components/files-source";
import { getDataSource } from "../../../../actions/datasource_actions";
import { TextSource } from "./text-source";
import { TextSource } from "../components/text-source";
import { Panel } from "@/components/common/panel-common";
import { Section, SectionRow, SectionLabel, SectionContent } from "../components/section";
export function SourcePage({
sourceId,
@ -82,69 +82,103 @@ export function SourcePage({
};
}, [source, projectId, sourceId]);
if (!source || isLoading) {
return <div className="flex items-center gap-2">
<Spinner size="sm" />
<div>Loading...</div>
</div>
return (
<div className="flex items-center gap-2 p-4">
<Spinner size="sm" />
<div>Loading...</div>
</div>
);
}
return <div className="flex flex-col h-full">
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-b-gray-100">
<div className="flex flex-col">
<h1 className="text-lg">{source.name}</h1>
</div>
</div>
<div className="grow overflow-auto py-4">
<div className="max-w-[768px] mx-auto">
<PageSection title="Details">
<table className="table-auto">
<tbody>
<tr>
<TableLabel>Toggle:</TableLabel>
<TableValue>
<ToggleSource projectId={projectId} sourceId={sourceId} active={source.active} />
</TableValue>
</tr>
<tr>
<TableLabel>Type:</TableLabel>
<TableValue>
{source.data.type === 'urls' && <div className="flex gap-1 items-center">
<DataSourceIcon type="urls" />
<div>Specify URLs</div>
</div>}
{source.data.type === 'files' && <div className="flex gap-1 items-center">
<DataSourceIcon type="files" />
<div>File upload</div>
</div>}
{source.data.type === 'text' && <div className="flex gap-1 items-center">
<DataSourceIcon type="text" />
<div>Text</div>
</div>}
</TableValue>
</tr>
<tr>
<TableLabel>Source:</TableLabel>
<TableValue>
<SourceStatus status={source.status} projectId={projectId} />
</TableValue>
</tr>
</tbody>
</table>
</PageSection>
{source.data.type === 'urls' && <ScrapeSource projectId={projectId} dataSource={source} handleReload={handleReload} />}
{source.data.type === 'files' && <FilesSource projectId={projectId} dataSource={source} handleReload={handleReload} />}
{source.data.type === 'text' && <TextSource projectId={projectId} dataSource={source} handleReload={handleReload} />}
return (
<Panel title={source.name.toUpperCase()}>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[768px] mx-auto space-y-6">
<Section
title="Details"
description="Basic information about this data source."
>
<div className="space-y-4">
<SectionRow>
<SectionLabel>Toggle</SectionLabel>
<SectionContent>
<ToggleSource
projectId={projectId}
sourceId={sourceId}
active={source.active}
/>
</SectionContent>
</SectionRow>
<SectionRow>
<SectionLabel>Type</SectionLabel>
<SectionContent>
<div className="flex gap-2 items-center text-sm text-gray-900 dark:text-gray-100">
{source.data.type === 'urls' && <>
<DataSourceIcon type="urls" />
<div>Specify URLs</div>
</>}
{source.data.type === 'files' && <>
<DataSourceIcon type="files" />
<div>File upload</div>
</>}
{source.data.type === 'text' && <>
<DataSourceIcon type="text" />
<div>Text</div>
</>}
</div>
</SectionContent>
</SectionRow>
<PageSection title="Danger zone">
<div className="flex flex-col gap-2 items-start">
<p>Delete this data source:</p>
<DeleteSource projectId={projectId} sourceId={sourceId} />
</div>
</PageSection>
<SectionRow>
<SectionLabel>Source</SectionLabel>
<SectionContent>
<SourceStatus status={source.status} projectId={projectId} />
</SectionContent>
</SectionRow>
</div>
</Section>
{/* Source-specific sections */}
{source.data.type === 'urls' &&
<ScrapeSource
projectId={projectId}
dataSource={source}
handleReload={handleReload}
/>
}
{source.data.type === 'files' &&
<FilesSource
projectId={projectId}
dataSource={source}
handleReload={handleReload}
/>
}
{source.data.type === 'text' &&
<TextSource
projectId={projectId}
dataSource={source}
handleReload={handleReload}
/>
}
<Section
title="Danger Zone"
description="Permanently delete this data source."
>
<div className="space-y-4">
<div className="p-4 bg-red-50/10 dark:bg-red-900/10 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-300">
Deleting this data source will permanently remove all its content.
This action cannot be undone.
</p>
</div>
<DeleteSource projectId={projectId} sourceId={sourceId} />
</div>
</Section>
</div>
</div>
</div>
</div>;
</Panel>
);
}

View file

@ -1,8 +1,6 @@
"use client";
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";
import { DataSourceDoc, DataSource } from "../../../../lib/types/datasource_types";
import { z } from "zod";
import { useCallback, useEffect, useState } from "react";
import { useDropzone } from "react-dropzone";
@ -10,6 +8,7 @@ import { deleteDocsFromDataSource, getUploadUrlsForFilesDataSource, addDocsToDat
import { RelativeTime } from "@primer/react";
import { Pagination, Spinner } from "@heroui/react";
import { DownloadIcon } from "lucide-react";
import { Section } from "./section";
function FileListItem({
projectId,
@ -52,24 +51,24 @@ function FileListItem({
}
return (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded">
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700">
<div>
<div className="flex items-center gap-2">
<p className="font-medium">{file.name}</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{file.name}</p>
<div className="shrink-0">
{isDownloading ? (
<Spinner size="sm" />
) : (
<button
onClick={handleDownloadClick}
className={`shrink-0 text-gray-500 hover:text-gray-700`}
className="shrink-0 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<DownloadIcon className="w-4 h-4" />
</button>
)}
</div>
</div>
<p className="text-sm text-gray-500">
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
uploaded <RelativeTime date={new Date(file.createdAt)} /> - {formatFileSize(file.data.size)}
</p>
</div>
@ -77,7 +76,7 @@ function FileListItem({
<button
onClick={handleDeleteClick}
disabled={isDeleting}
className={`${isDeleting ? 'text-gray-400' : 'text-red-600 hover:text-red-800'}`}
className={`text-sm ${isDeleting ? 'text-gray-400' : 'text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300'}`}
>
{isDeleting ? (
<Spinner size="sm" />
@ -138,33 +137,43 @@ function PaginatedFileList({
}, [projectId, sourceId, page]);
return (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3">Uploaded Files</h3>
{loading && <div className="flex items-center justify-center gap-2">
<Spinner size="sm" />
<p>Loading list...</p>
</div>}
{!loading && files.length === 0 && <div className="flex items-center justify-center gap-2">
<p>No files uploaded yet</p>
</div>}
{!loading && files.length > 0 && <div className="space-y-2">
{files.map(file => (
<FileListItem
key={file._id}
file={file}
projectId={projectId}
sourceId={sourceId}
onDelete={onDelete}
/>
))}
{totalPages > 1 && <Pagination
total={totalPages}
page={page}
onChange={setPage}
/>}
</div>}
<div className="space-y-4">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
UPLOADED FILES ({total})
</div>
{loading ? (
<div className="flex items-center justify-center gap-2 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<Spinner size="sm" />
<p className="text-gray-600 dark:text-gray-300">Loading files...</p>
</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center p-8 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<p className="text-gray-600 dark:text-gray-300">No files uploaded yet</p>
</div>
) : (
<div className="space-y-3">
{files.map(file => (
<FileListItem
key={file._id}
file={file}
projectId={projectId}
sourceId={sourceId}
onDelete={onDelete}
/>
))}
{totalPages > 1 && (
<div className="mt-6">
<Pagination
total={totalPages}
page={page}
onChange={setPage}
/>
</div>
)}
</div>
)}
</div>
)
);
}
export function FilesSource({
@ -237,49 +246,52 @@ export function FilesSource({
},
});
const handleDelete = async (docId: string) => {
await deleteDocsFromDataSource({
projectId,
sourceId: dataSource._id,
docIds: [docId],
});
handleReload();
setFileListKey(fileListKey + 1);
};
return (
<PageSection title="Upload files">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer
${isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}`}
>
<input {...getInputProps()} />
{uploading ? (
<div className="flex items-center justify-center gap-2">
<Spinner size="sm" />
<p>Uploading files...</p>
</div>
) : isDragActive ? (
<p>Drop the files here...</p>
) : (
<div>
<p>Drag and drop files here, or click to select files</p>
<p className="text-sm text-gray-500">
Supported file types: PDF, TXT, DOC, DOCX
</p>
</div>
)}
</div>
<Section
title="File Uploads"
description="Upload and manage files for this data source."
>
<div className="space-y-8">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer
${isDragActive ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/10' : 'border-gray-300 dark:border-gray-700'}`}
>
<input {...getInputProps()} />
{uploading ? (
<div className="flex items-center justify-center gap-2">
<Spinner size="sm" />
<p>Uploading files...</p>
</div>
) : isDragActive ? (
<p>Drop the files here...</p>
) : (
<div className="space-y-2">
<p>Drag and drop files here, or click to select files</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Supported file types: PDF, TXT, DOC, DOCX
</p>
</div>
)}
</div>
<PaginatedFileList
key={fileListKey}
projectId={projectId}
sourceId={dataSource._id}
handleReload={handleReload}
onDelete={handleDelete}
/>
</PageSection>
<PaginatedFileList
key={fileListKey}
projectId={projectId}
sourceId={dataSource._id}
handleReload={handleReload}
onDelete={async (docId) => {
await deleteDocsFromDataSource({
projectId,
sourceId: dataSource._id,
docIds: [docId],
});
handleReload();
setFileListKey(prev => prev + 1);
}}
/>
</div>
</Section>
);
}

View file

@ -0,0 +1,245 @@
"use client";
import { WithStringId } from "../../../../lib/types/types";
import { DataSourceDoc, DataSource } from "../../../../lib/types/datasource_types";
import { z } from "zod";
import { Recrawl } from "./web-recrawl";
import { deleteDocsFromDataSource, listDocsInDataSource, recrawlWebDataSource, addDocsToDataSource } from "../../../../actions/datasource_actions";
import { useState, useEffect } from "react";
import { Spinner, Pagination } from "@heroui/react";
import { ExternalLinkIcon, PlusIcon } from "lucide-react";
import { FormStatusButton } from "../../../../lib/components/form-status-button";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Section } from "./section";
function UrlListItem({ file, onDelete }: {
file: WithStringId<z.infer<typeof DataSourceDoc>>,
onDelete: (fileId: string) => Promise<void>;
}) {
const [isDeleting, setIsDeleting] = useState(false);
if (file.data.type !== 'url') return null;
return (
<div className="flex items-center justify-between py-3 px-1 border-b border-gray-100 dark:border-gray-800 group hover:bg-gray-50/50 dark:hover:bg-gray-800/50 transition-colors">
<div className="flex items-center gap-2">
<p className="text-sm text-gray-900 dark:text-gray-100">{file.name}</p>
<a
href={file.data.url}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
>
<ExternalLinkIcon className="w-3.5 h-3.5" />
</a>
</div>
<button
onClick={async () => {
setIsDeleting(true);
try {
await onDelete(file._id);
} finally {
setIsDeleting(false);
}
}}
disabled={isDeleting}
className="text-sm text-gray-400 hover:text-red-600 dark:text-gray-500 dark:hover:text-red-400 transition-colors disabled:opacity-50"
>
{isDeleting ? <Spinner size="sm" /> : 'Delete'}
</button>
</div>
);
}
function UrlList({ projectId, sourceId, onDelete }: {
projectId: string,
sourceId: string,
onDelete: (fileId: string) => Promise<void>,
}) {
const [files, setFiles] = useState<WithStringId<z.infer<typeof DataSourceDoc>>[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const totalPages = Math.ceil(total / 10);
useEffect(() => {
let ignore = false;
async function fetchFiles() {
setLoading(true);
try {
const { files, total } = await listDocsInDataSource({ projectId, sourceId, page, limit: 10 });
if (!ignore) {
setFiles(files);
setTotal(total);
}
} catch (error) {
console.error('Error fetching files:', error);
} finally {
setLoading(false);
}
}
fetchFiles();
return () => {
ignore = true;
};
}, [projectId, sourceId, page]);
return (
<div className="mt-6 space-y-4">
{loading ? (
<div className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<Spinner size="sm" />
<p>Loading URLs...</p>
</div>
) : files.length === 0 ? (
<div className="text-center text-sm text-gray-600 dark:text-gray-300">
No URLs added yet
</div>
) : (
<div className="space-y-2">
{files.map(file => (
<UrlListItem key={file._id} file={file} onDelete={onDelete} />
))}
{Math.ceil(total / 10) > 1 && (
<div className="mt-4">
<Pagination
total={Math.ceil(total / 10)}
page={page}
onChange={setPage}
/>
</div>
)}
</div>
)}
</div>
);
}
export function ScrapeSource({
projectId,
dataSource,
handleReload,
}: {
projectId: string,
dataSource: WithStringId<z.infer<typeof DataSource>>,
handleReload: () => void;
}) {
const [fileListKey, setFileListKey] = useState(0);
const [showAddForm, setShowAddForm] = useState(false);
return (
<div className="space-y-6">
<Section
title="URLs"
description="Manage the URLs that will be scraped for this data source."
>
<div className="space-y-6">
{!showAddForm && (
<Button
onClick={() => setShowAddForm(true)}
variant="primary"
size="sm"
>
<div className="flex items-center gap-1.5">
<PlusIcon className="w-3.5 h-3.5" />
Add URLs
</div>
</Button>
)}
{showAddForm && (
<form
action={async (formData) => {
const urls = formData.get('urls') as string;
const urlsArray = urls.split('\n')
.map(url => url.trim())
.filter(url => url.length > 0);
const first100Urls = urlsArray.slice(0, 100);
await addDocsToDataSource({
projectId,
sourceId: dataSource._id,
docData: first100Urls.map(url => ({
name: url,
data: {
type: 'url',
url,
},
})),
});
handleReload();
setShowAddForm(false);
}}
className="space-y-4"
>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Add URLs (one per line)
</label>
<Textarea
required
name="urls"
rows={5}
placeholder="https://example.com"
className="rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750"
/>
</div>
<div className="flex gap-2">
<FormStatusButton
props={{
type: "submit",
children: "Add URLs",
startContent: <PlusIcon className="w-4 h-4" />,
}}
/>
<button
type="button"
onClick={() => setShowAddForm(false)}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Cancel
</button>
</div>
</form>
)}
<UrlList
key={fileListKey}
projectId={projectId}
sourceId={dataSource._id}
onDelete={async (docId) => {
await deleteDocsFromDataSource({
projectId,
sourceId: dataSource._id,
docIds: [docId],
});
handleReload();
setFileListKey(prev => prev + 1);
}}
/>
</div>
</Section>
{(dataSource.status === 'ready' || dataSource.status === 'error') && (
<Section
title="Refresh Content"
description="Update the content by scraping the URLs again."
>
<Recrawl
projectId={projectId}
sourceId={dataSource._id}
handleRefresh={async () => {
await recrawlWebDataSource(projectId, dataSource._id);
handleReload();
setFileListKey(prev => prev + 1);
}}
/>
</Section>
)}
</div>
);
}

View file

@ -0,0 +1,52 @@
import { ReactNode } from "react";
interface SectionProps {
title: string;
description?: string;
children: ReactNode;
className?: string;
}
export function Section({ title, description, children, className }: SectionProps) {
return (
<div className={`rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden ${className || ''}`}>
<div className="px-6 pt-5 pb-4">
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{title}
</h2>
{description && (
<p className="mt-1.5 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
<div className="px-6 pb-6">
{children}
</div>
</div>
);
}
export function SectionRow({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={`flex items-center gap-6 ${className || ''}`}>
{children}
</div>
);
}
export function SectionLabel({ children }: { children: ReactNode }) {
return (
<div className="w-24 flex-shrink-0 text-sm text-gray-500 dark:text-gray-400">
{children}
</div>
);
}
export function SectionContent({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={`flex-1 ${className || ''}`}>
{children}
</div>
);
}

View file

@ -1,6 +1,6 @@
'use client';
import { getDataSource } from "../../../actions/datasource_actions";
import { DataSource } from "../../../lib/types/datasource_types";
import { getDataSource } from "../../../../actions/datasource_actions";
import { DataSource } from "../../../../lib/types/datasource_types";
import { useEffect, useState } from "react";
import { z } from 'zod';
import { SourceStatus } from "./source-status";

View file

@ -0,0 +1,56 @@
import { DataSource } from "../../../../lib/types/datasource_types";
import { Spinner } from "@heroui/react";
import { z } from 'zod';
import { CheckCircleIcon, XCircleIcon, ClockIcon } from "lucide-react";
export function SourceStatus({
status,
projectId,
compact = false,
}: {
status: z.infer<typeof DataSource>['status'],
projectId: string,
compact?: boolean;
}) {
return (
<div className="flex items-center gap-2">
{status === 'ready' && (
<>
<CheckCircleIcon className="w-4 h-4 text-green-500 dark:text-green-400" />
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">Ready</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
This source has been indexed and is ready to use.
</span>
</div>
</>
)}
{status === 'pending' && (
<>
<div className="flex-shrink-0">
<Spinner size="sm" className="text-blue-500 dark:text-blue-400" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">Processing</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
This source is being processed. This may take a few minutes.
</span>
</div>
</>
)}
{status === 'error' && (
<>
<XCircleIcon className="w-4 h-4 text-red-500 dark:text-red-400" />
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">Error</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
There was an error processing this source.
</span>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,157 @@
'use client';
import { Link, Spinner } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { ToggleSource } from "./toggle-source";
import { SelfUpdatingSourceStatus } from "./self-updating-source-status";
import { DataSourceIcon } from "../../../../lib/components/datasource-icon";
import { useEffect, useState } from "react";
import { WithStringId } from "../../../../lib/types/types";
import { DataSource } from "../../../../lib/types/datasource_types";
import { z } from "zod";
import { listDataSources } from "../../../../actions/datasource_actions";
import { Panel } from "@/components/common/panel-common";
import { PlusIcon } from "lucide-react";
export function SourcesList({ projectId }: { projectId: string }) {
const [sources, setSources] = useState<WithStringId<z.infer<typeof DataSource>>[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let ignore = false;
async function fetchSources() {
setLoading(true);
const sources = await listDataSources(projectId);
if (!ignore) {
setSources(sources);
setLoading(false);
}
}
fetchSources();
return () => {
ignore = true;
};
}, [projectId]);
return (
<Panel
title={
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
DATA SOURCES
</div>
</div>
}
rightActions={
<div className="flex items-center gap-3">
<Link href={`/projects/${projectId}/sources/new`}>
<Button
variant="primary"
size="sm"
className="bg-blue-50 text-blue-700 hover:bg-blue-100"
startContent={<PlusIcon className="w-4 h-4" />}
>
Add data source
</Button>
</Link>
</div>
}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[1024px] mx-auto">
{loading && (
<div className="flex items-center gap-2">
<Spinner size="sm" />
<div>Loading...</div>
</div>
)}
{!loading && !sources.length && (
<p className="mt-4 text-center">You have not added any data sources.</p>
)}
{!loading && sources.length > 0 && (
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th className="w-[30%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Name
</th>
<th className="w-[20%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Type
</th>
<th className="w-[35%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Status
</th>
<th className="w-[15%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Active
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{sources.map((source) => (
<tr
key={source._id}
className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
>
<td className="px-6 py-4 text-left">
<Link
href={`/projects/${projectId}/sources/${source._id}`}
size="lg"
isBlock
className="text-sm text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
>
{source.name}
</Link>
</td>
<td className="px-6 py-4 text-left">
{source.data.type == 'urls' && (
<div className="flex gap-2 items-center text-sm text-gray-600 dark:text-gray-300">
<DataSourceIcon type="urls" />
<div>List URLs</div>
</div>
)}
{source.data.type == 'text' && (
<div className="flex gap-2 items-center text-sm text-gray-600 dark:text-gray-300">
<DataSourceIcon type="text" />
<div>Text</div>
</div>
)}
{source.data.type == 'files' && (
<div className="flex gap-2 items-center text-sm text-gray-600 dark:text-gray-300">
<DataSourceIcon type="files" />
<div>Files</div>
</div>
)}
</td>
<td className="px-6 py-4 text-left">
<div className="text-sm">
<SelfUpdatingSourceStatus
sourceId={source._id}
projectId={projectId}
initialStatus={source.status}
compact={true}
/>
</div>
</td>
<td className="px-6 py-4 text-left">
<ToggleSource
projectId={projectId}
sourceId={source._id}
active={source.active}
compact={true}
className="bg-default-100"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</Panel>
);
}

View file

@ -1,13 +1,13 @@
"use client";
import { PageSection } from "../../../../lib/components/page-section";
import { WithStringId } from "../../../../lib/types/types";
import { DataSource } from "../../../../lib/types/datasource_types";
import { z } from "zod";
import { useState, useEffect } from "react";
import { Textarea } from "@heroui/react";
import { Textarea } from "@/components/ui/textarea";
import { FormStatusButton } from "../../../../lib/components/form-status-button";
import { Spinner } from "@heroui/react";
import { addDocsToDataSource, deleteDocsFromDataSource, listDocsInDataSource } from "../../../../actions/datasource_actions";
import { Section } from "./section";
export function TextSource({
projectId,
@ -92,28 +92,30 @@ export function TextSource({
if (isLoading) {
return (
<PageSection title="Content">
<div className="flex items-center justify-center gap-2">
<Section title="Content" description="Manage the text content for this data source.">
<div className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<Spinner size="sm" />
<p>Loading content...</p>
</div>
</PageSection>
</Section>
);
}
return (
<PageSection title="Content">
<form action={handleSubmit} className="flex flex-col gap-4">
<Textarea
name="content"
label="Text content"
labelPlacement="outside"
value={content}
onValueChange={setContent}
minRows={10}
maxRows={20}
variant="bordered"
/>
<Section title="Content" description="Manage the text content for this data source.">
<form action={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Text content
</label>
<Textarea
name="content"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={10}
className="rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
</div>
<FormStatusButton
props={{
type: "submit",
@ -123,6 +125,6 @@ export function TextSource({
}}
/>
</form>
</PageSection>
</Section>
);
}

View file

@ -0,0 +1,67 @@
'use client';
import { toggleDataSource } from "../../../../actions/datasource_actions";
import { Spinner } from "@heroui/react";
import { useState } from "react";
export function ToggleSource({
projectId,
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);
async function handleToggle() {
setLoading(true);
try {
await toggleDataSource(projectId, sourceId, !isActive);
setIsActive(!isActive);
} finally {
setLoading(false);
}
}
return (
<div className="flex flex-col gap-1.5 items-start">
<div className="flex items-center gap-2">
<button
onClick={handleToggle}
disabled={loading}
className={`
relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500/20
${isActive ? 'bg-indigo-500' : 'bg-gray-200 dark:bg-gray-700'}
disabled:opacity-50 disabled:cursor-not-allowed
`}
role="switch"
aria-checked={isActive}
>
<span
className={`
pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0
transition duration-200 ease-in-out
${isActive ? 'translate-x-4' : 'translate-x-0'}
`}
/>
</button>
<span className="text-sm text-gray-700 dark:text-gray-300">
{isActive ? "Active" : "Inactive"}
</span>
{loading && <Spinner size="sm" className="text-gray-400" />}
</div>
{!compact && !isActive && (
<p className="text-xs text-red-600 dark:text-red-400">
This data source will not be used for RAG.
</p>
)}
</div>
);
}

View file

@ -1,11 +1,14 @@
'use client';
import { Input, Select, SelectItem, Textarea } from "@heroui/react"
import { Input, Select, SelectItem } from "@heroui/react"
import { Textarea } from "@/components/ui/textarea"
import { useState } from "react";
import { createDataSource, addDocsToDataSource } from "../../../../actions/datasource_actions";
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";
import { Dropdown } from "@/components/ui/dropdown";
import { Panel } from "@/components/common/panel-common";
export function Form({
projectId,
@ -19,6 +22,24 @@ export function Form({
const [sourceType, setSourceType] = useState("");
const router = useRouter();
const dropdownOptions = [
{
key: "text",
label: "Text",
startContent: <DataSourceIcon type="text" />
},
{
key: "urls",
label: "Scrape URLs",
startContent: <DataSourceIcon type="urls" />
},
{
key: "files",
label: "Upload files",
startContent: <DataSourceIcon type="files" />
}
];
async function createUrlsDataSource(formData: FormData) {
const source = await createDataSource({
projectId,
@ -86,144 +107,181 @@ export function Form({
router.push(`/projects/${projectId}/sources/${source._id}`);
}
function handleSourceTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
setSourceType(event.target.value);
}
return <div className="grow overflow-auto py-4">
<div className="max-w-[768px] mx-auto flex flex-col gap-4">
<Select
label="Select type"
selectedKeys={[sourceType]}
onChange={handleSourceTypeChange}
disabledKeys={[
...(useRagUploads ? [] : ['files']),
...(useRagScraping ? [] : ['urls']),
]}
>
<SelectItem
key="text"
startContent={<DataSourceIcon type="text" />}
>
Text
</SelectItem>
<SelectItem
key="urls"
startContent={<DataSourceIcon type="urls" />}
>
Scrape URLs
</SelectItem>
<SelectItem
key="files"
startContent={<DataSourceIcon type="files" />}
>
Upload files
</SelectItem>
</Select>
{sourceType === "urls" && <form
action={createUrlsDataSource}
className="flex flex-col gap-4"
>
<Textarea
required
type="text"
name="urls"
label="Specify URLs (one per line)"
minRows={5}
maxRows={10}
labelPlacement="outside"
placeholder="https://example.com"
variant="bordered"
/>
<div className="self-start">
<Input
required
type="text"
name="name"
label="Name this data source"
labelPlacement="outside"
placeholder="e.g. Help articles"
variant="bordered"
return (
<Panel
title={
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
NEW DATA SOURCE
</div>
</div>
}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[768px] mx-auto flex flex-col gap-4">
<Dropdown
label="Select type"
value={sourceType}
onChange={setSourceType}
options={dropdownOptions}
disabledKeys={[
...(useRagUploads ? [] : ['files']),
...(useRagScraping ? [] : ['urls']),
]}
/>
</div>
<div className="text-sm">
<p>Note:</p>
<ul className="list-disc ml-4">
<li>Expect about 5-10 minutes to scrape 100 pages</li>
<li>Only the first 100 (valid) URLs will be scraped</li>
</ul>
</div>
<FormStatusButton
props={{
type: "submit",
children: "Add data source",
className: "self-start",
startContent: <PlusIcon className="w-[24px] h-[24px]" />
}}
/>
</form>}
{sourceType === "files" && <form
action={createFilesDataSource}
className="flex flex-col gap-4"
>
<div className="self-start">
<Input
required
type="text"
name="name"
label="Name this data source"
labelPlacement="outside"
placeholder="e.g. Documentation files"
variant="bordered"
/>
</div>
<div className="text-sm">
<p>You will be able to upload files in the next step</p>
</div>
<FormStatusButton
props={{
type: "submit",
children: "Add data source",
className: "self-start",
startContent: <PlusIcon className="w-[24px] h-[24px]" />
}}
/>
</form>}
{sourceType === "urls" && <form
action={createUrlsDataSource}
className="flex flex-col gap-4"
>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Specify URLs (one per line)
</label>
<Textarea
required
name="urls"
placeholder="https://example.com"
rows={5}
className="rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Name
</label>
<Textarea
required
name="name"
placeholder="e.g. Help articles"
rows={1}
className="rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
</div>
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 mb-2 text-gray-700 dark:text-gray-300">
<svg
className="w-5 h-5 text-blue-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="font-medium">Note</span>
</div>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-400 ml-7">
<li className="flex items-start">
<span className="mr-2"></span>
<span>Expect about 5-10 minutes to scrape 100 pages</span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span>Only the first 100 (valid) URLs will be scraped</span>
</li>
</ul>
</div>
<FormStatusButton
props={{
type: "submit",
children: "Add data source",
className: "self-start",
startContent: <PlusIcon className="w-4 h-4" />
}}
/>
</form>}
{sourceType === "text" && <form
action={createTextDataSource}
className="flex flex-col gap-4"
>
<Textarea
required
type="text"
name="content"
label="Text content"
labelPlacement="outside"
minRows={10}
maxRows={30}
/>
<div className="self-start">
<Input
required
type="text"
name="name"
labelPlacement="outside"
placeholder="e.g. Product documentation"
variant="bordered"
/>
{sourceType === "files" && <form
action={createFilesDataSource}
className="flex flex-col gap-4"
>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Name
</label>
<Textarea
required
name="name"
placeholder="e.g. Documentation files"
rows={1}
className="rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
</div>
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 mb-2 text-gray-700 dark:text-gray-300">
<svg
className="w-5 h-5 text-blue-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="font-medium">Note</span>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 ml-7">
You will be able to upload files in the next step
</div>
</div>
<FormStatusButton
props={{
type: "submit",
children: "Add data source",
className: "self-start",
startContent: <PlusIcon className="w-[24px] h-[24px]" />
}}
/>
</form>}
{sourceType === "text" && <form
action={createTextDataSource}
className="flex flex-col gap-4"
>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Content
</label>
<Textarea
required
name="content"
placeholder="Enter your text content here"
rows={10}
className="rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Name
</label>
<Textarea
required
name="name"
placeholder="e.g. Product documentation"
rows={1}
className="rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
</div>
<FormStatusButton
props={{
type: "submit",
children: "Add data source",
className: "self-start",
startContent: <PlusIcon className="w-[24px] h-[24px]" />
}}
/>
</form>}
</div>
<FormStatusButton
props={{
type: "submit",
children: "Add data source",
className: "self-start",
startContent: <PlusIcon className="w-[24px] h-[24px]" />
}}
/>
</form>}
</div>
</div>;
</div>
</Panel>
);
}

View file

@ -16,16 +16,11 @@ export default async function Page({
redirect(`/projects/${params.projectId}`);
}
return <div className="flex flex-col h-full">
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-border">
<div className="flex flex-col">
<h1 className="text-lg">Add data source</h1>
</div>
</div>
return (
<Form
projectId={params.projectId}
useRagUploads={USE_RAG_UPLOADS}
useRagScraping={USE_RAG_SCRAPING}
/>
</div>;
);
}

View file

@ -1,5 +1,5 @@
import { Metadata } from "next";
import { SourcesList } from "./sources-list";
import { SourcesList } from "./components/sources-list";
export const metadata: Metadata = {
title: "Data sources",

View file

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

View file

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

View file

@ -1,50 +0,0 @@
'use client';
import { toggleDataSource } from "../../../actions/datasource_actions";
import { Spinner } from "@heroui/react";
import { Switch } from "@heroui/react";
import { useState } from "react";
export function ToggleSource({
projectId,
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);
function handleActiveSwitchChange(isSelected: boolean) {
setIsActive(isSelected);
setLoading(true);
toggleDataSource(projectId, sourceId, isSelected)
.finally(() => {
setLoading(false);
});
}
return <div className="flex flex-col gap-1 items-start">
<div className="flex items-center gap-1">
<Switch
size="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"}
</Switch>
{loading && <Spinner size="sm" />}
</div>
{!compact && !isActive && <p className="text-sm text-red-800">This data source will not be used for RAG.</p>}
</div>;
}

View file

@ -2,7 +2,8 @@ import { WithStringId } from "@/app/lib/types/types";
import { TestProfile } from "@/app/lib/types/testing_types";
import { useCallback, useEffect, useState } from "react";
import { listProfiles } from "@/app/actions/testing_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { z } from "zod";
import { useRouter } from "next/navigation";
@ -10,10 +11,11 @@ interface ProfileSelectorProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (profile: WithStringId<z.infer<typeof TestProfile>>) => void;
onSelect: (profile: WithStringId<z.infer<typeof TestProfile>> | null) => void;
selectedProfileId?: string;
}
export function ProfileSelector({ projectId, isOpen, onOpenChange, onSelect }: ProfileSelectorProps) {
export function ProfileSelector({ projectId, isOpen, onOpenChange, onSelect, selectedProfileId }: ProfileSelectorProps) {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -55,29 +57,33 @@ export function ProfileSelector({ projectId, isOpen, onOpenChange, onSelect }: P
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => fetchProfiles(page)}>Retry</Button>
<Button size="sm" variant="primary" onClick={() => fetchProfiles(page)}>Retry</Button>
</div>}
{!loading && !error && <>
{profiles.length === 0 && <div className="text-gray-600 text-center">No profiles found</div>}
{profiles.length > 0 && <div className="flex flex-col w-full">
<div className="grid grid-cols-6 py-2 bg-gray-100 dark:bg-gray-800 font-semibold text-sm">
<div className="col-span-2 px-4 text-gray-900 dark:text-gray-100">Name</div>
<div className="col-span-3 px-4 text-gray-900 dark:text-gray-100">Context</div>
<div className="col-span-1 px-4 text-gray-900 dark:text-gray-100">Mock Tools</div>
<div className="grid grid-cols-6 py-2 bg-gray-100 dark:bg-gray-800 font-semibold text-sm rounded-t-md">
<div className="col-span-2 px-4 text-gray-700 dark:text-gray-300">Name</div>
<div className="col-span-3 px-4 text-gray-700 dark:text-gray-300">Context</div>
<div className="col-span-1 px-4 text-gray-700 dark:text-gray-300">Mock Tools</div>
</div>
{profiles.map((p) => (
<div
key={p._id}
className="grid grid-cols-6 py-2 border-b hover:bg-gray-50 text-sm cursor-pointer"
className={`grid grid-cols-6 py-2.5 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 text-sm cursor-pointer transition-colors ${
p._id === selectedProfileId
? 'bg-blue-100 dark:bg-blue-900/50 border-blue-200 dark:border-blue-800'
: ''
}`}
onClick={() => {
onSelect(p);
onClose();
}}
>
<div className="col-span-2 px-4 truncate">{p.name}</div>
<div className="col-span-3 px-4 truncate">{p.context}</div>
<div className="col-span-1 px-4">{p.mockTools ? "Yes" : "No"}</div>
<div className="col-span-2 px-4 truncate text-gray-900 dark:text-gray-100">{p.name}</div>
<div className="col-span-3 px-4 truncate text-gray-600 dark:text-gray-400">{p.context}</div>
<div className="col-span-1 px-4 text-gray-600 dark:text-gray-400">{p.mockTools ? "Yes" : "No"}</div>
</div>
))}
</div>}
@ -90,17 +96,34 @@ export function ProfileSelector({ projectId, isOpen, onOpenChange, onSelect }: P
</>}
</ModalBody>
<ModalFooter>
<div className="flex gap-2">
<Button
size="sm"
color="primary"
onPress={() => router.push(`/projects/${projectId}/test/profiles`)}
>
Manage Profiles
</Button>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<div className="flex items-center gap-4 w-full">
<div className="flex-1">
<Button
size="sm"
variant="primary"
onClick={() => router.push(`/projects/${projectId}/test/profiles`)}
>
Manage Profiles
</Button>
</div>
<div className="flex items-center gap-3">
{selectedProfileId && (
<Button
size="sm"
variant="tertiary"
className="text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40"
onClick={() => {
onSelect(null);
onClose();
}}
>
Clear Selection
</Button>
)}
<Button size="sm" variant="secondary" onClick={onClose}>
Cancel
</Button>
</div>
</div>
</ModalFooter>
</>

View file

@ -1,4 +1,4 @@
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { FormStatusButton } from "@/app/lib/components/form-status-button-old";
import { Button, Input, Textarea } from "@heroui/react";
import { TestProfile, TestScenario } from "@/app/lib/types/testing_types";
import { WithStringId } from "@/app/lib/types/types";

View file

@ -1,454 +0,0 @@
"use client";
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, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Radio, RadioGroup, Divider } from "@heroui/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, ChevronRight, ChevronDown } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { usePreviewModal } from "./preview-modal";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { Textarea } from "@heroui/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";
import { createAtMentions } from "../../../lib/components/atmentions";
export function AgentConfig({
projectId,
workflow,
agent,
usedAgentNames,
agents,
tools,
prompts,
dataSources,
handleUpdate,
handleClose,
useRag,
}: {
projectId: string,
workflow: z.infer<typeof Workflow>,
agent: z.infer<typeof WorkflowAgent>,
usedAgentNames: Set<string>,
agents: z.infer<typeof WorkflowAgent>[],
tools: z.infer<typeof AgenticAPITool>[],
prompts: z.infer<typeof WorkflowPrompt>[],
dataSources: WithStringId<z.infer<typeof DataSource>>[],
handleUpdate: (agent: z.infer<typeof WorkflowAgent>) => void,
handleClose: () => void,
useRag: boolean,
}) {
const [isAdvancedConfigOpen, setIsAdvancedConfigOpen] = useState(false);
const atMentions = createAtMentions({
agents,
prompts,
tools,
currentAgentName: agent.name
});
const [showGenerateModal, setShowGenerateModal] = useState(false);
const { showPreview } = usePreviewModal();
return (
<StructuredPanel title={agent.name} actions={[
<ActionButton
key="close"
onClick={handleClose}
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
</svg>}
>
Close
</ActionButton>
]}>
<div className="flex flex-col gap-4">
{!agent.locked && (
<FormSection showDivider>
<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" };
}
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 };
}}
/>
</FormSection>
)}
<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>
<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({
...agent,
examples: value
});
}}
placeholder="Enter examples for this agent"
markdown
multiline
mentions
mentionsAtValues={atMentions}
showSaveButton={true}
showDiscardButton={true}
/>
</FormSection>
{useRag && <FormSection label="RAG" 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"
>
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} />}
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"
onPress={() => {
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>
);
})}
</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>
</FormSection>}
<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: value as z.infer<typeof WorkflowAgent>['model']
})}
className="w-40"
/>
</FormSection>
<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: value as z.infer<typeof WorkflowAgent>['controlType']
})}
/>
</FormSection>
<Divider />
<PreviewModalProvider>
<GenerateInstructionsModal
projectId={projectId}
workflow={workflow}
agent={agent}
isOpen={showGenerateModal}
onClose={() => setShowGenerateModal(false)}
currentInstructions={agent.instructions}
onApply={(newInstructions) => {
handleUpdate({
...agent,
instructions: newInstructions
});
}}
/>
</PreviewModalProvider>
</div>
</StructuredPanel>
);
}
function GenerateInstructionsModal({
projectId,
workflow,
agent,
isOpen,
onClose,
currentInstructions,
onApply
}: {
projectId: string,
workflow: z.infer<typeof Workflow>,
agent: z.infer<typeof WorkflowAgent>,
isOpen: boolean,
onClose: () => void,
currentInstructions: string,
onApply: (newInstructions: string) => void
}) {
const [prompt, setPrompt] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { showPreview } = usePreviewModal();
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (isOpen) {
setPrompt("");
setIsLoading(false);
setError(null);
textareaRef.current?.focus();
}
}, [isOpen]);
const handleGenerate = async () => {
setIsLoading(true);
setError(null);
try {
const msgs: z.infer<typeof CopilotMessage>[] = [
{
role: 'user',
content: prompt,
},
];
const newInstructions = await getCopilotAgentInstructions(projectId, msgs, workflow, agent.name);
onClose();
showPreview(
currentInstructions,
newInstructions,
true, // markdown enabled
"Generated Instructions",
"Review the changes below:", // message before diff
() => onApply(newInstructions) // apply callback
);
} catch (err) {
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (prompt.trim() && !isLoading) {
handleGenerate();
}
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalContent>
<ModalHeader>Generate Instructions</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
{error && (
<div className="p-2 bg-red-50 border border-red-200 rounded-lg flex gap-2 justify-between items-center text-sm">
<p className="text-red-600">{error}</p>
<Button
size="sm"
color="danger"
onPress={() => {
setError(null);
handleGenerate();
}}
>
Retry
</Button>
</div>
)}
<Textarea
ref={textareaRef}
label="What should this agent do?"
placeholder="e.g., This agent should help users analyze their data and provide insights..."
variant="bordered"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isLoading}
/>
</div>
</ModalBody>
<ModalFooter>
<Button
variant="light"
onPress={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
color="primary"
onPress={handleGenerate}
isLoading={isLoading}
disabled={!prompt.trim()}
>
Generate
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View file

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

View file

@ -4,10 +4,16 @@ import { WorkflowPrompt } from "../../../lib/types/workflow_types";
import { WorkflowAgent } from "../../../lib/types/workflow_types";
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@heroui/react";
import { useRef, useEffect } from "react";
import { ActionButton, StructuredPanel } from "../../../lib/components/structured-panel";
import clsx from "clsx";
import { EllipsisVerticalIcon, ImportIcon, PlusIcon } from "lucide-react";
import { SectionHeader, ListItem } from "../../../lib/components/structured-list";
import { EllipsisVerticalIcon, ImportIcon, PlusIcon, Brain, Wrench, PenLine } from "lucide-react";
import { Panel } from "@/components/common/panel-common";
import { Button } from "@/components/ui/button";
import { clsx } from "clsx";
const MAX_SECTION_HEIGHTS = {
AGENTS: '20rem',
TOOLS: '15rem',
PROMPTS: '15rem',
} as const;
interface EntityListProps {
agents: z.infer<typeof WorkflowAgent>[];
@ -32,6 +38,66 @@ interface EntityListProps {
triggerMcpImport: () => void;
}
interface EmptyStateProps {
entity: string;
}
const EmptyState: React.FC<EmptyStateProps> = ({ entity }) => (
<div className="flex items-center justify-center h-24 text-sm text-zinc-400 dark:text-zinc-500">
No {entity} created
</div>
);
const ListItemWithMenu = ({
name,
isSelected,
onClick,
disabled,
selectedRef,
menuContent,
statusLabel,
icon,
}: {
name: string;
isSelected?: boolean;
onClick?: () => void;
disabled?: boolean;
selectedRef?: React.RefObject<HTMLButtonElement>;
menuContent: React.ReactNode;
statusLabel?: React.ReactNode;
icon?: React.ReactNode;
}) => (
<div className="group flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-zinc-50 dark:hover:bg-zinc-800">
<button
ref={selectedRef}
className={clsx(
"flex-1 flex items-center gap-2 text-sm text-left",
{
"text-zinc-900 dark:text-zinc-100": !disabled,
"text-zinc-400 dark:text-zinc-600": disabled,
}
)}
onClick={onClick}
disabled={disabled}
>
{icon}
{name}
</button>
<div className="flex items-center gap-2">
{statusLabel}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
{menuContent}
</div>
</div>
</div>
);
const StartLabel = () => (
<div className="text-xs text-indigo-500 dark:text-indigo-400 bg-indigo-50/50 dark:bg-indigo-950/30 px-1.5 py-0.5 rounded">
Start
</div>
);
export function EntityList({
agents,
tools,
@ -52,6 +118,8 @@ export function EntityList({
triggerMcpImport,
}: EntityListProps) {
const selectedRef = useRef<HTMLButtonElement | null>(null);
const headerClasses = "font-semibold text-zinc-700 dark:text-zinc-300 flex items-center justify-between w-full";
const buttonClasses = "text-sm px-3 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-950 dark:hover:bg-indigo-900 dark:text-indigo-400";
useEffect(() => {
if (selectedEntity && selectedRef.current) {
@ -60,94 +128,168 @@ export function EntityList({
}, [selectedEntity]);
return (
<StructuredPanel
title="WORKFLOW"
tooltip="Browse and manage your agents, tools, and prompts in this sidebar"
>
<div className="overflow-auto flex flex-col gap-1 justify-start">
{/* Agents Section */}
<SectionHeader title="Agents">
<ActionButton
icon={<PlusIcon className="w-4 h-4" />}
onClick={() => onAddAgent({})}
>
Add
</ActionButton>
</SectionHeader>
{agents.map((agent, index) => (
<ListItem
key={`agent-${index}`}
name={agent.name}
isSelected={selectedEntity?.type === "agent" && selectedEntity.name === agent.name}
onClick={() => onSelectAgent(agent.name)}
disabled={agent.disabled}
selectedRef={selectedEntity?.type === "agent" && selectedEntity.name === agent.name ? selectedRef : undefined}
rightElement={
<div className="flex flex-col gap-6 h-full overflow-hidden">
<div className="flex flex-col gap-6 overflow-y-auto custom-scrollbar">
{/* Agents Panel */}
<Panel variant="projects"
title={
<div className={headerClasses}>
<div className="flex items-center gap-2">
{startAgentName === agent.name && (
<div className="text-xs border bg-blue-500 text-white px-2 py-1 rounded-md">Start</div>
)}
<AgentDropdown
agent={agent}
isStartAgent={startAgentName === agent.name}
onToggle={onToggleAgent}
onSetMainAgent={onSetMainAgent}
onDelete={onDeleteAgent}
/>
<Brain className="w-4 h-4" />
<span>Agents</span>
</div>
}
/>
))}
<Button
variant="secondary"
size="sm"
onClick={() => onAddAgent({})}
className={`group ${buttonClasses}`}
showHoverContent={true}
hoverContent="Add Agent"
>
<PlusIcon className="w-4 h-4" />
</Button>
</div>
}
maxHeight={MAX_SECTION_HEIGHTS.AGENTS}
>
<div className="flex flex-col">
{agents.length > 0 ? (
<div className="space-y-1 pb-2">
{agents.map((agent, index) => (
<ListItemWithMenu
key={`agent-${index}`}
name={agent.name}
isSelected={selectedEntity?.type === "agent" && selectedEntity.name === agent.name}
onClick={() => onSelectAgent(agent.name)}
disabled={agent.disabled}
selectedRef={selectedEntity?.type === "agent" && selectedEntity.name === agent.name ? selectedRef : undefined}
statusLabel={startAgentName === agent.name ? <StartLabel /> : null}
menuContent={
<AgentDropdown
agent={agent}
isStartAgent={startAgentName === agent.name}
onToggle={onToggleAgent}
onSetMainAgent={onSetMainAgent}
onDelete={onDeleteAgent}
/>
}
/>
))}
</div>
) : (
<EmptyState entity="agents" />
)}
</div>
</Panel>
{/* Tools Section */}
<SectionHeader title="Tools">
<ActionButton
icon={<PlusIcon className="w-4 h-4" />}
onClick={() => onAddTool({})}
>
Add
</ActionButton>
<ActionButton
icon={<ImportIcon className="w-4 h-4" />}
onClick={triggerMcpImport}
>
MCP
</ActionButton>
</SectionHeader>
{/* Tools Panel */}
<Panel variant="projects"
title={
<div className={headerClasses}>
<div className="flex items-center gap-2">
<Wrench className="w-4 h-4" />
<span>Tools</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={triggerMcpImport}
className={buttonClasses}
showHoverContent={true}
hoverContent="Import from MCP"
>
<ImportIcon className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onAddTool({})}
className={`group ${buttonClasses}`}
showHoverContent={true}
hoverContent="Add Tool"
>
<PlusIcon className="w-4 h-4" />
</Button>
</div>
</div>
}
maxHeight={MAX_SECTION_HEIGHTS.TOOLS}
>
<div className="flex flex-col">
{tools.length > 0 ? (
<div className="space-y-1 pb-2">
{tools.map((tool, index) => (
<ListItemWithMenu
key={`tool-${index}`}
name={tool.name}
isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name}
onClick={() => onSelectTool(tool.name)}
selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined}
icon={tool.isMcp ? <ImportIcon className="w-4 h-4 text-blue-700" /> : undefined}
menuContent={
<EntityDropdown
name={tool.name}
onDelete={onDeleteTool}
/>
}
/>
))}
</div>
) : (
<EmptyState entity="tools" />
)}
</div>
</Panel>
{tools.map((tool, index) => (
<ListItem
key={`tool-${index}`}
name={tool.name}
isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name}
onClick={() => onSelectTool(tool.name)}
selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined}
rightElement={<EntityDropdown name={tool.name} onDelete={onDeleteTool} />}
icon={tool.isMcp ? <ImportIcon className="w-4 h-4 text-blue-700" /> : undefined}
/>
))}
{/* Prompts Section */}
<SectionHeader title="Prompts">
<ActionButton
icon={<PlusIcon className="w-4 h-4" />}
onClick={() => onAddPrompt({})}
>
Add
</ActionButton>
</SectionHeader>
{prompts.map((prompt, index) => (
<ListItem
key={`prompt-${index}`}
name={prompt.name}
isSelected={selectedEntity?.type === "prompt" && selectedEntity.name === prompt.name}
onClick={() => onSelectPrompt(prompt.name)}
selectedRef={selectedEntity?.type === "prompt" && selectedEntity.name === prompt.name ? selectedRef : undefined}
rightElement={<EntityDropdown name={prompt.name} onDelete={onDeletePrompt} />}
/>
))}
{/* Prompts Panel */}
<Panel variant="projects"
title={
<div className={headerClasses}>
<div className="flex items-center gap-2">
<PenLine className="w-4 h-4" />
<span>Prompts</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => onAddPrompt({})}
className={`group ${buttonClasses}`}
showHoverContent={true}
hoverContent="Add Prompt"
>
<PlusIcon className="w-4 h-4" />
</Button>
</div>
}
maxHeight={MAX_SECTION_HEIGHTS.PROMPTS}
>
<div className="flex flex-col">
{prompts.length > 0 ? (
<div className="space-y-1 pb-2">
{prompts.map((prompt, index) => (
<ListItemWithMenu
key={`prompt-${index}`}
name={prompt.name}
isSelected={selectedEntity?.type === "prompt" && selectedEntity.name === prompt.name}
onClick={() => onSelectPrompt(prompt.name)}
selectedRef={selectedEntity?.type === "prompt" && selectedEntity.name === prompt.name ? selectedRef : undefined}
menuContent={
<EntityDropdown
name={prompt.name}
onDelete={onDeletePrompt}
/>
}
/>
))}
</div>
) : (
<EmptyState entity="prompts" />
)}
</div>
</Panel>
</div>
</StructuredPanel>
</div>
);
}

View file

@ -21,8 +21,10 @@ export default async function Page({
notFound();
}
return <App
projectId={params.projectId}
useRag={USE_RAG}
/>;
return (
<App
projectId={params.projectId}
useRag={USE_RAG}
/>
);
}

View file

@ -1,107 +0,0 @@
"use client";
import { WorkflowAgent, WorkflowPrompt, WorkflowTool } from "../../../lib/types/workflow_types";
import { Divider } from "@heroui/react";
import { z } from "zod";
import { ActionButton, StructuredPanel } from "../../../lib/components/structured-panel";
import { EditableField } from "../../../lib/components/editable-field";
import { XMarkIcon } from "@heroicons/react/24/outline";
export function PromptConfig({
prompt,
agents,
tools,
prompts,
usedPromptNames,
handleUpdate,
handleClose,
}: {
prompt: z.infer<typeof WorkflowPrompt>,
agents: z.infer<typeof WorkflowAgent>[],
tools: z.infer<typeof WorkflowTool>[],
prompts: z.infer<typeof WorkflowPrompt>[],
usedPromptNames: Set<string>,
handleUpdate: (prompt: z.infer<typeof WorkflowPrompt>) => void,
handleClose: () => void,
}) {
const atMentions = [];
for (const a of agents) {
const id = `agent:${a.name}`;
atMentions.push({
id,
value: id,
});
}
for (const p of prompts) {
if (p.name === prompt.name) {
continue;
}
const id = `prompt:${p.name}`;
atMentions.push({
id,
value: id,
});
}
for (const tool of tools) {
const id = `tool:${tool.name}`;
atMentions.push({
id,
value: id,
});
}
return <StructuredPanel title={prompt.name} actions={[
<ActionButton
key="close"
onClick={handleClose}
icon={<XMarkIcon className="w-4 h-4" />}
>
Close
</ActionButton>
]}>
<div className="flex flex-col gap-4">
{prompt.type === "base_prompt" && (
<>
<EditableField
label="Name"
value={prompt.name}
onChange={(value) => {
handleUpdate({
...prompt,
name: value
});
}}
placeholder="Enter prompt name"
validate={(value) => {
if (value.length === 0) {
return { valid: false, errorMessage: "Name cannot be empty" };
}
if (usedPromptNames.has(value)) {
return { valid: false, errorMessage: "This name is already taken" };
}
return { valid: true };
}}
/>
<Divider />
</>
)}
<div className="w-full flex flex-col">
<EditableField
value={prompt.prompt}
onChange={(value) => {
handleUpdate({
...prompt,
prompt: value
});
}}
placeholder="Edit prompt here..."
markdown
label="Prompt"
multiline
mentions
mentionsAtValues={atMentions}
/>
</div>
</div>
</StructuredPanel>;
}

View file

@ -1,376 +0,0 @@
"use client";
import { WorkflowTool } from "../../../lib/types/workflow_types";
import { Accordion, AccordionItem, Button, Checkbox, Select, SelectItem, Switch, RadioGroup, Radio } from "@heroui/react";
import { z } from "zod";
import { ActionButton, StructuredPanel } from "../../../lib/components/structured-panel";
import { EditableField } from "../../../lib/components/editable-field";
import { Divider } from "@heroui/react";
import { Label } from "../../../lib/components/label";
import { ImportIcon, XIcon } from "lucide-react";
import { useState } from "react";
export function ParameterConfig({
param,
handleUpdate,
handleDelete,
handleRename,
readOnly
}: {
param: {
name: string,
description: string,
type: string,
required: boolean
},
handleUpdate: (name: string, data: {
description: string,
type: string,
required: boolean
}) => void,
handleDelete: (name: string) => void,
handleRename: (oldName: string, newName: string) => void,
readOnly?: boolean
}) {
return <StructuredPanel
title={param.name}
actions={!readOnly ? [
<ActionButton
key="delete"
onClick={() => handleDelete(param.name)}
icon={<XIcon size={16} />}
>
Remove
</ActionButton>
] : []}
>
<div className="flex flex-col gap-2">
<EditableField
label="Name"
value={param.name}
onChange={(newName) => {
if (newName && newName !== param.name) {
handleRename(param.name, newName);
}
}}
locked={readOnly}
/>
<Divider />
<div className="flex flex-col gap-2">
<Label label="Type" />
<Select
variant="bordered"
className="w-52"
size="sm"
selectedKeys={new Set([param.type])}
onSelectionChange={(keys) => {
handleUpdate(param.name, {
...param,
type: Array.from(keys)[0] as string
});
}}
isDisabled={readOnly}
>
{['string', 'number', 'boolean', 'array', 'object'].map(type => (
<SelectItem key={type}>
{type}
</SelectItem>
))}
</Select>
</div>
<Divider />
<EditableField
label="Description"
value={param.description}
onChange={(desc) => {
handleUpdate(param.name, {
...param,
description: desc
});
}}
locked={readOnly}
/>
<Divider />
<Checkbox
size="sm"
isSelected={param.required}
onValueChange={() => {
handleUpdate(param.name, {
...param,
required: !param.required
});
}}
isDisabled={readOnly}
>
Required
</Checkbox>
</div>
</StructuredPanel>;
}
export function ToolConfig({
tool,
usedToolNames,
handleUpdate,
handleClose
}: {
tool: z.infer<typeof WorkflowTool>,
usedToolNames: Set<string>,
handleUpdate: (tool: z.infer<typeof WorkflowTool>) => void,
handleClose: () => void
}) {
const [selectedParams, setSelectedParams] = useState(new Set([]));
const isReadOnly = tool.isMcp;
function handleParamRename(oldName: string, newName: string) {
const newProperties = { ...tool.parameters!.properties };
newProperties[newName] = newProperties[oldName];
delete newProperties[oldName];
const newRequired = [...(tool.parameters?.required || [])];
newRequired.splice(newRequired.indexOf(oldName), 1);
newRequired.push(newName);
handleUpdate({
...tool,
parameters: { ...tool.parameters!, properties: newProperties, required: newRequired }
});
}
function handleParamUpdate(name: string, data: {
description: string,
type: string,
required: boolean
}) {
const newProperties = { ...tool.parameters!.properties };
newProperties[name] = {
type: data.type,
description: data.description
};
const newRequired = [...(tool.parameters?.required || [])];
if (data.required) {
newRequired.push(name);
} else {
newRequired.splice(newRequired.indexOf(name), 1);
}
handleUpdate({
...tool,
parameters: {
...tool.parameters!,
properties: newProperties,
required: newRequired,
}
});
}
function handleParamDelete(paramName: string) {
const newProperties = { ...tool.parameters!.properties };
delete newProperties[paramName];
const newRequired = [...(tool.parameters?.required || [])];
newRequired.splice(newRequired.indexOf(paramName), 1);
handleUpdate({
...tool,
parameters: {
...tool.parameters!,
properties: newProperties,
required: newRequired,
}
});
}
return (
<StructuredPanel title={tool.name} actions={[
<ActionButton
key="close"
onClick={handleClose}
icon={<XIcon className="w-4 h-4" />}
>
Close
</ActionButton>
]}>
<div className="flex flex-col gap-4">
{tool.isMcp && <div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-sm font-normal bg-gray-100 px-2 py-1 rounded-md text-gray-700">
<ImportIcon className="w-4 h-4 text-blue-700" />
<div className="text-sm font-normal">Imported from MCP server: <span className="font-bold">{tool.mcpServerName}</span></div>
</div>
</div>}
<EditableField
label="Name"
value={tool.name}
onChange={(value) => handleUpdate({
...tool,
name: value
})}
validate={(value) => {
if (value.length === 0) {
return { valid: false, errorMessage: "Name cannot be empty" };
}
if (usedToolNames.has(value)) {
return { valid: false, errorMessage: "Tool name already exists" };
}
return { valid: true };
}}
locked={isReadOnly}
/>
<Divider />
<EditableField
label="Description"
value={tool.description}
onChange={(value) => handleUpdate({
...tool,
description: value
})}
placeholder="Describe what this tool does..."
locked={isReadOnly}
/>
<Divider />
{!isReadOnly && <>
<Label label="TOOL RESPONSES" />
<div className="ml-4 flex flex-col gap-2">
<RadioGroup
defaultValue="mock"
value={tool.mockTool ? "mock" : "api"}
onValueChange={(value) => handleUpdate({
...tool,
mockTool: value === "mock",
autoSubmitMockedResponse: value === "mock" ? true : undefined
})}
orientation="horizontal"
classNames={{
wrapper: "gap-8",
label: "text-sm"
}}
>
<Radio
value="mock"
size="sm"
classNames={{
base: "max-w-[50%]",
label: "text-sm font-normal"
}}
>
Mock tool responses
</Radio>
<Radio
value="api"
size="sm"
classNames={{
base: "max-w-[50%]",
label: "text-sm font-normal"
}}
>
Connect tool to your API
</Radio>
</RadioGroup>
{tool.mockTool && <>
<div className="ml-0">
<Checkbox
key="autoSubmitMockedResponse"
size="sm"
classNames={{
label: "text-xs font-normal"
}}
isSelected={tool.autoSubmitMockedResponse ?? true}
onValueChange={(value) => handleUpdate({
...tool,
autoSubmitMockedResponse: value
})}
>
Auto-submit mocked response in playground
</Checkbox>
</div>
<Divider />
<EditableField
label="Mock instructions"
value={tool.mockInstructions || ''}
onChange={(value) => handleUpdate({
...tool,
mockInstructions: value
})}
placeholder="Enter mock instructions..."
multiline
/>
</>}
{!tool.mockTool && (
<div className="ml-0 text-danger text-xs">
Please configure your webhook in the <strong>Integrate</strong> page if you haven&apos;t already.
</div>
)}
</div>
<Divider />
</>}
<Label label="Parameters" />
<div className="ml-4 flex flex-col gap-2">
{Object.entries(tool.parameters?.properties || {}).map(([paramName, param], index) => (
<ParameterConfig
key={paramName}
param={{
name: paramName,
description: param.description,
type: param.type,
required: tool.parameters?.required?.includes(paramName) ?? false
}}
handleUpdate={handleParamUpdate}
handleDelete={handleParamDelete}
handleRename={handleParamRename}
readOnly={isReadOnly}
/>
))}
</div>
{!isReadOnly && <Button
className="self-start shrink-0"
variant="light"
size="sm"
startContent={<svg className="w-6 h-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M5 12h14m-7 7V5" />
</svg>}
onPress={() => {
const newParamName = `param${Object.keys(tool.parameters?.properties || {}).length + 1}`;
const newProperties = {
...(tool.parameters?.properties || {}),
[newParamName]: {
type: 'string',
description: ''
}
};
handleUpdate({
...tool,
parameters: {
type: 'object',
properties: newProperties,
required: [...(tool.parameters?.required || []), newParamName]
}
});
}}
>
Add Parameter
</Button>}
</div>
</StructuredPanel>
);
}

View file

@ -1,18 +1,18 @@
"use client";
import React, { useReducer, Reducer, useState, useCallback, useEffect, useRef } from "react";
import { MCPServer, WithStringId } from "../../../lib/types/types";
import { Workflow } from "../../../lib/types/workflow_types";
import { WorkflowTool } from "../../../lib/types/workflow_types";
import { WorkflowPrompt } from "../../../lib/types/workflow_types";
import { WorkflowAgent } from "../../../lib/types/workflow_types";
import { DataSource } from "../../../lib/types/datasource_types";
import { useReducer, Reducer, useState, useCallback, useEffect, useRef } from "react";
import { produce, applyPatches, enablePatches, produceWithPatches, Patch } from 'immer';
import { AgentConfig } from "./agent_config";
import { ToolConfig } from "./tool_config";
import { AgentConfig } from "../entities/agent_config";
import { ToolConfig } from "../entities/tool_config";
import { App as ChatApp } from "../playground/app";
import { z } from "zod";
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip } from "@heroui/react";
import { PromptConfig } from "./prompt_config";
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownSection, DropdownTrigger, Spinner } from "@heroui/react";
import { PromptConfig } from "../entities/prompt_config";
import { EditableField } from "../../../lib/components/editable-field";
import { RelativeTime } from "@primer/react";
@ -20,8 +20,8 @@ import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "../../../../components/ui/resizable"
import { Copilot } from "./copilot";
} from "@/components/ui/resizable"
import { Copilot } from "../copilot/app";
import { apiV1 } from "rowboat-shared";
import { publishWorkflow, renameWorkflow, saveWorkflow } from "../../../actions/workflow_actions";
import { PublishedBadge } from "./published_badge";
@ -32,6 +32,12 @@ import { McpImportTools } from "./mcp_imports";
enablePatches();
const PANEL_RATIOS = {
entityList: 25, // Left panel
chatApp: 50, // Middle panel
copilot: 25 // Right panel
} as const;
interface StateItem {
workflow: WithStringId<z.infer<typeof Workflow>>;
publishedWorkflowId: string | null;
@ -595,7 +601,7 @@ export function WorkflowEditor({
const isLive = state.present.workflow._id == state.present.publishedWorkflowId;
const [showCopySuccess, setShowCopySuccess] = useState(false);
const [showCopilot, setShowCopilot] = useState(false);
const [copilotWidth, setCopilotWidth] = useState(25);
const [copilotWidth, setCopilotWidth] = useState<number>(PANEL_RATIOS.copilot);
const [isMcpImportModalOpen, setIsMcpImportModalOpen] = useState(false);
console.log(`workflow editor chat key: ${state.present.chatKey}`);
@ -788,31 +794,47 @@ export function WorkflowEditor({
}
}}
>
<DropdownItem
key="switch"
startContent={<BackIcon size={16} />}
>
Switch version
</DropdownItem>
<DropdownItem
key="clone"
startContent={<Layers2Icon size={16} />}
>
Clone this version
</DropdownItem>
<DropdownItem
key="publish"
color="danger"
startContent={<RadioIcon size={16} />}
>
Deploy to Production
</DropdownItem>
<DropdownItem
key="clipboard"
startContent={<CopyIcon size={16} />}
>
Copy as JSON
</DropdownItem>
<DropdownSection>
<DropdownItem
key="switch"
startContent={<div className="text-gray-500"><BackIcon size={16} /></div>}
className="gap-x-2"
>
View versions
</DropdownItem>
</DropdownSection>
<div className="border-t border-gray-200 dark:border-gray-700" />
<DropdownSection>
<DropdownItem
key="clone"
startContent={<div className="text-gray-500"><Layers2Icon size={16} /></div>}
className="gap-x-2"
>
Clone this version
</DropdownItem>
<DropdownItem
key="publish"
startContent={<div className="text-indigo-500"><RadioIcon size={16} /></div>}
className="gap-x-2 text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/20"
>
Make version live
</DropdownItem>
</DropdownSection>
<div className="border-t border-gray-200 dark:border-gray-700" />
<DropdownSection>
<DropdownItem
key="clipboard"
startContent={<div className="text-gray-500"><CopyIcon size={16} /></div>}
className="gap-x-2"
>
Export as JSON
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>
</div>
@ -869,7 +891,7 @@ export function WorkflowEditor({
</div>
</div>
<ResizablePanelGroup direction="horizontal" className="grow flex overflow-auto gap-1">
<ResizablePanel minSize={10} defaultSize={15}>
<ResizablePanel minSize={10} defaultSize={PANEL_RATIOS.entityList}>
<EntityList
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
@ -890,10 +912,10 @@ export function WorkflowEditor({
triggerMcpImport={triggerMcpImport}
/>
</ResizablePanel>
<ResizableHandle />
<ResizableHandle className="w-[3px] bg-transparent" />
<ResizablePanel
minSize={20}
defaultSize={showCopilot ? 85 - copilotWidth : 85}
defaultSize={showCopilot ? PANEL_RATIOS.chatApp : PANEL_RATIOS.chatApp + PANEL_RATIOS.copilot}
className="overflow-auto"
>
<ChatApp
@ -937,29 +959,31 @@ export function WorkflowEditor({
handleClose={handleUnselectPrompt}
/>}
</ResizablePanel>
{showCopilot && <>
<ResizableHandle />
<ResizablePanel
minSize={10}
defaultSize={copilotWidth}
onResize={(size) => setCopilotWidth(size)}
>
<Copilot
projectId={state.present.workflow.projectId}
workflow={state.present.workflow}
dispatch={dispatch}
chatContext={
state.present.selection ? {
type: state.present.selection.type,
name: state.present.selection.name
} : chatMessages.length > 0 ? {
type: 'chat',
messages: chatMessages
} : undefined
}
/>
</ResizablePanel>
</>}
{showCopilot && (
<>
<ResizableHandle className="w-[3px] bg-transparent" />
<ResizablePanel
minSize={10}
defaultSize={PANEL_RATIOS.copilot}
onResize={(size) => setCopilotWidth(size)}
>
<Copilot
projectId={state.present.workflow.projectId}
workflow={state.present.workflow}
dispatch={dispatch}
chatContext={
state.present.selection ? {
type: state.present.selection.type,
name: state.present.selection.name
} : chatMessages.length > 0 ? {
type: 'chat',
messages: chatMessages
} : undefined
}
/>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
<McpImportTools
projectId={state.present.workflow.projectId}

View file

@ -1,78 +0,0 @@
'use client';
import { Link, Button, Spinner } from "@heroui/react";
import { RelativeTime } from "@primer/react";
import { Project } from "../lib/types/project_types";
import { default as NextLink } from "next/link";
import { useEffect, useState } from "react";
import { z } from "zod";
import { listProjects } from "../actions/project_actions";
import { useRouter } from 'next/navigation';
export default function App() {
const router = useRouter();
const [projects, setProjects] = useState<z.infer<typeof Project>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let ignore = false;
async function fetchProjects() {
setIsLoading(true);
const projects = await listProjects();
if (!ignore) {
setProjects(projects);
setIsLoading(false);
if (projects.length === 0) {
router.push('/projects/new');
}
}
}
fetchProjects();
return () => {
ignore = true;
}
}, [router]);
return (
<div className="h-full pt-4 px-4 overflow-auto dark:bg-gray-900">
<div className="max-w-[768px] mx-auto">
<div className="flex justify-between items-center">
<div className="text-lg dark:text-white">Select a project</div>
<Button
href="/projects/new"
as={Link}
startContent={
<svg className="w-[18px] h-[18px]" 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>
}
>
Create new project
</Button>
</div>
{isLoading && <Spinner size="sm" />}
{!isLoading && projects.length == 0 && <p className="mt-4 text-center text-gray-600 dark:text-gray-400 text-sm">You do not have any projects.</p>}
{!isLoading && projects.length > 0 && <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
{projects.map((project) => (
<NextLink
key={project._id}
href={`/projects/${project._id}`}
className="flex flex-col gap-2 border border-gray-300 dark:border-gray-700 hover:border-gray-500 dark:hover:border-gray-500 rounded p-4 bg-white dark:bg-gray-800 dark:text-white"
>
<div className="text-lg">
{project.name}
</div>
<div className="shrink-0 text-sm text-gray-500 dark:text-gray-400">
Created <RelativeTime date={new Date(project.createdAt)} />
</div>
</NextLink>
))}
</div>}
</div>
</div>
);
}

View file

@ -1,10 +1,5 @@
import logo from "@/public/logo.png";
import logoDark from "@/public/dark-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";
import { USE_AUTH } from "../lib/feature_flags";
import { USE_AUTH, USE_RAG } from "../lib/feature_flags";
import AppLayout from './layout/components/app-layout';
export const dynamic = 'force-dynamic';
@ -13,31 +8,9 @@ export default function Layout({
}: Readonly<{
children: React.ReactNode;
}>) {
return <>
<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"
className="block dark:hidden"
/>
<Image
src={logoDark}
height={24}
alt="RowBoat Labs Logo"
className="hidden dark:block"
/>
</Link>
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
{USE_AUTH && <UserButton />}
</div>
</header>
<main className="grow overflow-auto">
return (
<AppLayout useRag={USE_RAG} useAuth={USE_AUTH}>
{children}
</main>
</>;
</AppLayout>
);
}

View file

@ -0,0 +1,42 @@
'use client';
import { ReactNode, useState } from 'react';
import Sidebar from './sidebar';
import { usePathname } from 'next/navigation';
interface AppLayoutProps {
children: ReactNode;
useRag?: boolean;
useAuth?: boolean;
}
export default function AppLayout({ children, useRag = false, useAuth = false }: AppLayoutProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const pathname = usePathname();
const projectId = pathname.split('/')[2];
// For invalid projectId, return just the children
if (!projectId && !pathname.startsWith('/projects')) {
return children;
}
// Layout with sidebar for all routes
return (
<div className="h-screen flex gap-5 p-5 bg-zinc-50 dark:bg-zinc-900">
{/* Sidebar with improved shadow and blur */}
<div className="overflow-hidden rounded-xl bg-white/70 dark:bg-zinc-800/70 shadow-sm backdrop-blur-sm">
<Sidebar
projectId={projectId}
useRag={useRag}
useAuth={useAuth}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
</div>
{/* Main content area */}
<main className="flex-1 overflow-auto rounded-xl bg-white dark:bg-zinc-800 shadow-sm p-4">
{children}
</main>
</div>
);
}

View file

@ -0,0 +1,43 @@
import Link from "next/link";
import { LucideIcon } from "lucide-react";
interface MenuItemProps {
href?: string;
icon: LucideIcon;
selected?: boolean;
collapsed?: boolean;
onClick?: () => void;
children?: React.ReactNode;
}
export default function MenuItem({
href,
icon: Icon,
selected = false,
collapsed = false,
onClick,
children
}: MenuItemProps) {
const ButtonContent = (
<button
onClick={onClick}
className={`
w-full px-3 py-2 rounded-md flex items-center gap-3
text-sm font-medium transition-all duration-200
${selected
? 'text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-500/10'
: 'text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800'
}
`}
>
<Icon size={16} />
{!collapsed && children}
</button>
);
if (href) {
return <Link href={href}>{ButtonContent}</Link>;
}
return ButtonContent;
}

View file

@ -0,0 +1,216 @@
'use client';
import { useEffect, useState } from 'react';
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Tooltip } from "@heroui/react";
import { UserButton } from "@/app/lib/components/user_button";
import {
DatabaseIcon,
SettingsIcon,
WorkflowIcon,
PlayIcon,
FolderOpenIcon,
ChevronLeftIcon,
ChevronRightIcon,
Moon
} from "lucide-react";
import { getProjectConfig } from "@/app/actions/project_actions";
import { useTheme } from "@/app/providers/theme-provider";
interface SidebarProps {
projectId: string;
useRag: boolean;
useAuth: boolean;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
const EXPANDED_ICON_SIZE = 20;
const COLLAPSED_ICON_SIZE = 20; // DO NOT CHANGE THIS
export default function Sidebar({ projectId, useRag, useAuth, collapsed = false, onToggleCollapse }: SidebarProps) {
const pathname = usePathname();
const [projectName, setProjectName] = useState<string>("Select Project");
const isProjectsRoute = pathname === '/projects' || pathname === '/projects/select';
const { theme, toggleTheme } = useTheme();
useEffect(() => {
async function fetchProjectName() {
if (!isProjectsRoute && projectId) {
try {
const project = await getProjectConfig(projectId);
setProjectName(project.name);
} catch (error) {
console.error('Failed to fetch project name:', error);
setProjectName("Select Project");
}
}
}
fetchProjectName();
}, [projectId, isProjectsRoute]);
const navItems = [
{
href: 'workflow',
label: 'Build',
icon: WorkflowIcon,
requiresProject: true
},
{
href: 'test',
label: 'Test',
icon: PlayIcon,
requiresProject: true
},
...(useRag ? [{
href: 'sources',
label: 'Connect',
icon: DatabaseIcon,
requiresProject: true
}] : []),
{
href: 'config',
label: 'Integrate',
icon: SettingsIcon,
requiresProject: true
}
];
return (
<aside className={`${collapsed ? 'w-16' : 'w-60'} bg-transparent flex flex-col h-full transition-all duration-300`}>
<div className="flex flex-col flex-grow">
{!isProjectsRoute && (
<>
{/* Project Selector */}
<div className="p-3 border-b border-zinc-100 dark:border-zinc-800">
<Tooltip content={collapsed ? projectName : "Change project"} showArrow placement="right">
<Link
href="/projects"
className={`
flex items-center rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800/50 transition-all
${collapsed ? 'justify-center py-4' : 'gap-3 px-4 py-2.5'}
`}
>
<FolderOpenIcon
size={collapsed ? COLLAPSED_ICON_SIZE : EXPANDED_ICON_SIZE}
className="text-zinc-500 dark:text-zinc-400 transition-all duration-200"
/>
{!collapsed && (
<span className="text-sm font-medium truncate">
{projectName}
</span>
)}
</Link>
</Tooltip>
</div>
{/* Navigation Items */}
<nav className="p-3 space-y-4">
{navItems.map((item) => {
const Icon = item.icon;
const fullPath = `/projects/${projectId}/${item.href}`;
const isActive = pathname.startsWith(fullPath);
const isDisabled = isProjectsRoute && item.requiresProject;
return (
<Tooltip
key={item.href}
content={collapsed ? item.label : ""}
showArrow
placement="right"
>
<Link
href={isDisabled ? '#' : fullPath}
className={isDisabled ? 'pointer-events-none' : ''}
>
<button
className={`
relative w-full rounded-md flex items-center
text-[15px] font-medium transition-all duration-200
${collapsed ? 'justify-center py-4' : 'px-4 py-4 gap-3'}
${isActive
? 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 border-l-2 border-indigo-600 dark:border-indigo-400'
: isDisabled
? 'text-zinc-300 dark:text-zinc-600 cursor-not-allowed'
: 'text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800/50 hover:text-zinc-900 dark:hover:text-zinc-300'
}
`}
disabled={isDisabled}
>
<Icon
size={collapsed ? COLLAPSED_ICON_SIZE : EXPANDED_ICON_SIZE}
className={`
transition-all duration-200
${isDisabled
? 'text-zinc-300 dark:text-zinc-600'
: isActive
? 'text-indigo-600 dark:text-indigo-400'
: 'text-zinc-500 dark:text-zinc-400'
}
`}
/>
{!collapsed && <span>{item.label}</span>}
</button>
</Link>
</Tooltip>
);
})}
</nav>
</>
)}
</div>
{/* Bottom section */}
<div className="mt-auto">
{/* Collapse Toggle Button */}
<div className="p-3 border-t border-zinc-100 dark:border-zinc-800">
<button
onClick={onToggleCollapse}
className="w-full flex items-center justify-center p-2 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800/50 transition-all"
>
{collapsed ? (
<ChevronRightIcon size={20} className="text-zinc-500 dark:text-zinc-400" />
) : (
<ChevronLeftIcon size={20} className="text-zinc-500 dark:text-zinc-400" />
)}
</button>
</div>
{/* Theme and Auth Controls */}
<div className="p-3 border-t border-zinc-100 dark:border-zinc-800 space-y-2">
<Tooltip content={collapsed ? "Appearance" : ""} showArrow placement="right">
<button
onClick={toggleTheme}
className={`
w-full rounded-md flex items-center
text-[15px] font-medium transition-all duration-200
${collapsed ? 'justify-center py-4' : 'px-4 py-4 gap-3'}
hover:bg-zinc-100 dark:hover:bg-zinc-800/50
text-zinc-600 dark:text-zinc-400
`}
>
<Moon size={COLLAPSED_ICON_SIZE} />
{!collapsed && <span>Appearance</span>}
</button>
</Tooltip>
{useAuth && (
<Tooltip content={collapsed ? "Account" : ""} showArrow placement="right">
<div
className={`
w-full rounded-md flex items-center
text-[15px] font-medium transition-all duration-200
${collapsed ? 'justify-center py-4' : 'px-4 py-4 gap-3'}
hover:bg-zinc-100 dark:hover:bg-zinc-800/50
`}
>
<UserButton />
{!collapsed && <span>Account</span>}
</div>
</Tooltip>
)}
</div>
</div>
</aside>
);
}

View file

@ -0,0 +1,16 @@
import { USE_RAG } from "@/app/lib/feature_flags";
import AppLayout from './components/app-layout';
export default async function Layout({
params,
children
}: {
params: { projectId: string }
children: React.ReactNode
}) {
return (
<AppLayout>
{children}
</AppLayout>
);
}

View file

@ -1,39 +1,24 @@
'use client';
import { usePathname } from "next/navigation";
import { Tooltip } from "@heroui/react";
import Link from "next/link";
import { DatabaseIcon, SettingsIcon, WorkflowIcon, PlayIcon } from "lucide-react";
import MenuItem from "../../lib/components/menu-item";
import { DatabaseIcon, SettingsIcon, WorkflowIcon, PlayIcon, LucideIcon } from "lucide-react";
import MenuItem from "./components/menu-item";
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>
);
}
interface NavLinkProps {
href: string;
label: string;
icon: LucideIcon;
collapsed?: boolean;
selected?: boolean;
}
function NavLink({ href, label, icon, collapsed, selected = false }: NavLinkProps) {
return (
<Link href={href}>
<Link href={href} className="block">
<MenuItem
icon={icon}
selected={selected}
onClick={() => {}}
collapsed={collapsed}
>
{label}
</MenuItem>
@ -58,30 +43,30 @@ export default function Menu({
href={`/projects/${projectId}/workflow`}
label="Build"
collapsed={collapsed}
icon={<WorkflowIcon size={16} />}
icon={WorkflowIcon}
selected={pathname.startsWith(`/projects/${projectId}/workflow`)}
/>
<NavLink
href={`/projects/${projectId}/test`}
label="Test"
collapsed={collapsed}
icon={<PlayIcon size={16} />}
icon={PlayIcon}
selected={pathname.startsWith(`/projects/${projectId}/test`)}
/>
{useRag && (
<NavLink
href={`/projects/${projectId}/sources`}
label="Connect"
label="Knowledge"
collapsed={collapsed}
icon={<DatabaseIcon size={16} />}
icon={DatabaseIcon}
selected={pathname.startsWith(`/projects/${projectId}/sources`)}
/>
)}
<NavLink
href={`/projects/${projectId}/config`}
label="Integrate"
label="Settings"
collapsed={collapsed}
icon={<SettingsIcon size={16} />}
icon={SettingsIcon}
selected={pathname.startsWith(`/projects/${projectId}/config`)}
/>
</div>

View file

@ -4,7 +4,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import clsx from "clsx";
import Menu from "./menu";
import { getProjectConfig } from "../../actions/project_actions";
import { getProjectConfig } from "@/app/actions/project_actions";
import { FolderOpenIcon, PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
export function Nav({

View file

@ -1,288 +0,0 @@
'use client';
import { cn, Input, Textarea } from "@heroui/react";
import { createProject, createProjectFromPrompt } from "../../actions/project_actions";
import { templates, starting_copilot_prompts } from "../../lib/project_templates";
import { WorkflowTemplate } from "../../lib/types/workflow_types";
import { FormStatusButton } from "../../lib/components/form-status-button";
import { useFormStatus } from "react-dom";
import { z } from "zod";
import { useState } from "react";
import { CheckIcon, PlusIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { useRouter } from 'next/navigation';
import React from "react";
function CustomPromptCard({
onSelect,
selected,
onPromptChange,
customPrompt
}: {
onSelect: () => void,
selected: boolean,
onPromptChange: (prompt: string) => void,
customPrompt: string
}) {
return <button
className={cn(
"relative flex flex-col gap-2 rounded p-4 pt-6 shadow-sm w-full",
"border border-gray-300 dark:border-gray-700",
"hover:border-gray-500 dark:hover:border-gray-500",
"bg-white dark:bg-gray-900",
selected && "border-gray-800 dark:border-gray-300 shadow-md"
)}
type="button"
onClick={onSelect}
>
{selected && <div className="absolute top-0 right-0 bg-gray-200 dark:bg-gray-800 flex items-center justify-center rounded p-1">
<CheckIcon size={16} />
</div>}
<div className="text-lg dark:text-gray-100 text-left">Custom Prompt</div>
{selected ? (
<Textarea
placeholder="Enter your custom prompt here..."
value={customPrompt}
onChange={(e) => {
e.stopPropagation();
onPromptChange(e.target.value);
}}
onClick={(e) => e.stopPropagation()}
className="min-h-[100px] text-sm w-full"
/>
) : (
<div
className={cn(
"min-h-[60px] w-full p-2 text-sm text-gray-500 dark:text-gray-400 text-left",
"border border-gray-200 dark:border-gray-700 rounded",
"bg-gray-50 dark:bg-gray-800"
)}
>
&ldquo;Create an assistant for a food delivery app that can take new orders, cancel existing orders and answer questions about refund policies&rdquo;
</div>
)}
</button>
}
function TemplateCard({
templateKey,
template,
onSelect,
selected,
type = "template"
}: {
templateKey: string,
template: z.infer<typeof WorkflowTemplate> | string,
onSelect: (templateKey: string) => void,
selected: boolean,
type?: "template" | "prompt"
}) {
const [isExpanded, setIsExpanded] = useState(false);
const name = typeof template === "string" ? templateKey : template.name;
const description = typeof template === "string"
? `"${template}"`
: template.description;
// Check if text needs expansion button
const textRef = React.useRef<HTMLDivElement>(null);
const [needsExpansion, setNeedsExpansion] = useState(false);
React.useEffect(() => {
if (textRef.current) {
const needsButton = textRef.current.scrollHeight > textRef.current.clientHeight;
setNeedsExpansion(needsButton);
}
}, [description]);
return <div
className={cn(
"relative flex flex-col rounded p-4 pt-6 shadow-sm cursor-pointer",
"border border-gray-300 dark:border-gray-700",
"hover:border-gray-500 dark:hover:border-gray-500",
"bg-white dark:bg-gray-900",
selected && "border-gray-800 dark:border-gray-300 shadow-md",
isExpanded ? "h-auto" : "h-[160px]"
)}
onClick={() => onSelect(templateKey)}
>
{selected && <div className="absolute top-0 right-0 bg-gray-200 dark:bg-gray-800 flex items-center justify-center rounded p-1">
<CheckIcon size={16} />
</div>}
<div className="flex flex-col h-full">
<div className="text-lg dark:text-gray-100 text-left mb-2">{name}</div>
<div className="relative flex-1">
<div
ref={textRef}
className={cn(
"text-sm text-gray-500 dark:text-gray-400 text-left pr-6",
!isExpanded && "line-clamp-3"
)}
>
{description}
</div>
{needsExpansion && (
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setIsExpanded(!isExpanded);
}
}}
className={cn(
"absolute right-0 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 cursor-pointer",
isExpanded ? "relative mt-1" : "bottom-0"
)}
aria-label={isExpanded ? "Show less" : "Show more"}
>
{isExpanded ? (
<ChevronUpIcon size={16} />
) : (
<ChevronDownIcon size={16} />
)}
</div>
)}
</div>
</div>
</div>
}
function Submit() {
const { pending } = useFormStatus();
return <>
{pending && <div className="text-gray-400">Please hold on while we set up your project&hellip;</div>}
<FormStatusButton
props={{
type: "submit",
children: "Create project",
className: "self-start",
startContent: <PlusIcon size={16} />,
}}
/>
</>;
}
export default function App() {
const [selectedTemplate, setSelectedTemplate] = useState<string>('default');
const [selectedType, setSelectedType] = useState<"template" | "prompt">("template");
const [customPrompt, setCustomPrompt] = useState<string>('');
const { default: defaultTemplate, ...otherTemplates } = templates;
const router = useRouter();
function handleTemplateClick(templateKey: string, type: "template" | "prompt" = "template") {
setSelectedTemplate(templateKey);
setSelectedType(type);
}
async function handleSubmit(formData: FormData) {
if (selectedType === "template") {
console.log('Creating template project');
return await createProject(formData);
}
if (selectedType === "prompt") {
console.log('Starting prompt-based project creation');
try {
const newFormData = new FormData();
const projectName = formData.get('name') as string;
const promptText = selectedTemplate === 'custom'
? customPrompt
: starting_copilot_prompts[selectedTemplate];
newFormData.append('name', projectName);
newFormData.append('prompt', promptText);
console.log('Creating project...');
const response = await createProjectFromPrompt(newFormData);
console.log('Create project response:', response);
if (!response?.id) {
throw new Error('Project creation failed - no project ID returned');
}
// write prompt to local storage
localStorage.setItem(`project_prompt_${response.id}`, promptText);
router.push(`/projects/${response.id}/workflow`);
} catch (error) {
console.error('Error creating project:', error);
}
}
}
return <div className="h-full pt-4 px-4 overflow-auto bg-gray-50 dark:bg-gray-950">
<div className="max-w-[768px] mx-auto p-4 bg-white dark:bg-gray-900 rounded-lg">
<div className="text-lg pb-2 border-b border-b-gray-100 dark:border-b-gray-800 dark:text-gray-100 text-left">Create a new project</div>
<form className="mt-4 flex flex-col gap-6" action={handleSubmit}>
<div>
<div className="text-lg dark:text-gray-300 mb-4 text-left">Name your assistant</div>
<Input
required
name="name"
placeholder="Give an internal name for your assistant"
variant="bordered"
/>
</div>
<input type="hidden" name="template" value={selectedTemplate} />
<input type="hidden" name="type" value={selectedType} />
<div className="space-y-8">
<div>
<div className="text-lg dark:text-gray-300 mb-4 text-left">Tell us what you would like to build</div>
<CustomPromptCard
onSelect={() => handleTemplateClick('custom', 'prompt')}
selected={selectedTemplate === 'custom' && selectedType === "prompt"}
onPromptChange={setCustomPrompt}
customPrompt={customPrompt}
/>
</div>
<div>
<div className="text-lg dark:text-gray-300 mb-4 text-left">Or start with an example starting prompt</div>
<div className="grid grid-cols-3 gap-4">
{Object.entries(starting_copilot_prompts).map(([key, prompt]) => (
<TemplateCard
key={key}
templateKey={key}
template={prompt}
onSelect={(key) => handleTemplateClick(key, "prompt")}
selected={selectedTemplate === key && selectedType === "prompt"}
type="prompt"
/>
))}
</div>
</div>
<div>
<div className="text-lg dark:text-gray-300 mb-4 text-left">Or choose a pre-built example assistant</div>
<div className="grid grid-cols-3 gap-4">
<TemplateCard
key="default"
templateKey="default"
template={defaultTemplate}
onSelect={(key) => handleTemplateClick(key, "template")}
selected={selectedTemplate === 'default' && selectedType === "template"}
/>
{Object.entries(otherTemplates).map(([key, template]) => (
<TemplateCard
key={key}
templateKey={key}
template={template}
onSelect={(key) => handleTemplateClick(key, "template")}
selected={selectedTemplate === key && selectedType === "template"}
/>
))}
</div>
</div>
</div>
<Submit />
</form>
</div>
</div>;
}

View file

@ -1,5 +1,5 @@
import App from "./app";
import { redirect } from 'next/navigation';
export default function Page() {
return <App />
}
redirect('/projects/select');
}

View file

@ -0,0 +1,238 @@
'use client';
import { Project } from "../../lib/types/project_types";
import { useEffect, useState } from "react";
import { z } from "zod";
import { listProjects, createProject, createProjectFromPrompt } from "../../actions/project_actions";
import { useRouter } from 'next/navigation';
import { tokens } from "@/app/styles/design-tokens";
import clsx from 'clsx';
import { templates } from "@/app/lib/project_templates";
import { SectionHeading } from "@/components/ui/section-heading";
import { Textarea } from "@/components/ui/textarea";
import { TemplateCardsList } from "./components/template-cards-list";
import { SearchProjects } from "./components/search-projects";
import { CustomPromptCard } from "./components/custom-prompt-card";
import { Submit } from "./components/submit-button";
import { PageHeading } from "@/components/ui/page-heading";
import { ChevronDown, ChevronUp } from "lucide-react";
export default function App() {
const [projects, setProjects] = useState<z.infer<typeof Project>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedCard, setSelectedCard] = useState<'custom' | any>('custom');
const [customPrompt, setCustomPrompt] = useState("Create a customer support assistant with one example agent");
const [name, setName] = useState("");
const [defaultName, setDefaultName] = useState('Untitled 1');
const [isExamplesExpanded, setIsExamplesExpanded] = useState(false);
const getNextUntitledNumber = (projects: z.infer<typeof Project>[]) => {
const untitledProjects = projects
.map(p => p.name)
.filter(name => name.startsWith('Untitled '))
.map(name => {
const num = parseInt(name.replace('Untitled ', ''));
return isNaN(num) ? 0 : num;
});
if (untitledProjects.length === 0) return 1;
return Math.max(...untitledProjects) + 1;
};
useEffect(() => {
let ignore = false;
async function fetchProjects() {
setIsLoading(true);
const projects = await listProjects();
if (!ignore) {
// Sort projects by createdAt in descending order (newest first)
const sortedProjects = [...projects].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setProjects(sortedProjects);
setIsLoading(false);
const nextNumber = getNextUntitledNumber(sortedProjects);
const newDefaultName = `Untitled ${nextNumber}`;
setDefaultName(newDefaultName);
setName(newDefaultName);
}
}
fetchProjects();
return () => {
ignore = true;
}
}, []);
const handleCardSelect = (card: 'custom' | any) => {
setSelectedCard(card);
if (card === 'custom') {
setCustomPrompt("Create a customer support assistant with one example agent");
} else {
setCustomPrompt(card.prompt || card.description);
}
};
const router = useRouter();
async function handleSubmit(formData: FormData) {
// Check if it's a template (from templates object) or a copilot prompt
const isTemplate = selectedCard?.id && selectedCard.id in templates;
if (selectedCard === 'custom' || !isTemplate) {
// Handle custom prompt or copilot starting prompts
console.log('Creating project from prompt');
try {
const newFormData = new FormData();
newFormData.append('name', name);
newFormData.append('prompt', selectedCard === 'custom' ? customPrompt : selectedCard.prompt);
const response = await createProjectFromPrompt(newFormData);
if (!response?.id) {
throw new Error('Project creation failed');
}
// Store prompt in local storage
const promptToStore = selectedCard === 'custom' ? customPrompt : selectedCard.prompt;
if (promptToStore) {
localStorage.setItem(`project_prompt_${response.id}`, promptToStore);
}
router.push(`/projects/${response.id}/workflow`);
} catch (error) {
console.error('Error creating project:', error);
}
} else {
// Handle regular template
console.log('Creating template project');
try {
const newFormData = new FormData();
newFormData.append('name', name);
newFormData.append('template', selectedCard.id);
return await createProject(newFormData);
} catch (error) {
console.error('Error creating project:', error);
}
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'TEXTAREA') {
e.preventDefault();
const formData = new FormData();
formData.append('name', name);
handleSubmit(formData);
}
};
return (
<div className={clsx(
"min-h-screen flex flex-col",
tokens.colors.light.background,
tokens.colors.dark.background
)}>
<div className={clsx(
"flex-1 px-12 pt-4 pb-32"
)}>
<PageHeading
title="Projects"
description="Select an existing project or create a new one"
/>
<div className="grid grid-cols-1 lg:grid-cols-[1fr,2fr] gap-8 mt-8">
{/* Left side: Project Selection */}
<div className="overflow-auto">
<SearchProjects
projects={projects}
isLoading={isLoading}
heading="Select an existing project"
subheading="Choose from your projects"
className="h-full"
/>
</div>
{/* Right side: Project Creation */}
<div className="overflow-auto">
<section className="card h-full">
<div className="px-4 pt-4 flex justify-between items-start">
<div>
<SectionHeading
subheading="Set up a new AI assistant"
>
Create a new project
</SectionHeading>
</div>
<div className="pt-1">
<Submit />
</div>
</div>
<form
id="create-project-form"
action={handleSubmit}
onKeyDown={handleKeyDown}
className="px-4 pt-4 pb-8 space-y-6"
>
<div className="space-y-3">
<SectionHeading>Name your assistant</SectionHeading>
<Textarea
required
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="min-h-[60px] px-4 py-3"
placeholder={defaultName}
/>
</div>
<input type="hidden" name="template" value={selectedCard} />
<div className="space-y-6">
<div className="space-y-3">
<SectionHeading>Start with your own prompt</SectionHeading>
<CustomPromptCard
selected={selectedCard === 'custom'}
onSelect={() => handleCardSelect('custom')}
customPrompt={customPrompt}
onCustomPromptChange={setCustomPrompt}
/>
</div>
<div className="space-y-3">
<button
type="button"
onClick={() => setIsExamplesExpanded(!isExamplesExpanded)}
className="flex items-center gap-2 w-full"
>
<div className="flex-1 text-left">
<SectionHeading>
Or choose an example
</SectionHeading>
</div>
{isExamplesExpanded ? (
<ChevronUp className="w-5 h-5 text-gray-500" />
) : (
<ChevronDown className="w-5 h-5 text-gray-500" />
)}
</button>
{isExamplesExpanded && (
<TemplateCardsList
selectedCard={selectedCard}
onSelectCard={handleCardSelect}
/>
)}
</div>
</div>
</form>
</section>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,109 @@
'use client';
import clsx from 'clsx';
import { CheckIcon } from "lucide-react";
import { tokens } from "@/app/styles/design-tokens";
import { Textarea } from "@/components/ui/textarea";
interface CustomPromptCardProps {
selected: boolean;
onSelect: () => void;
customPrompt: string;
onCustomPromptChange: (value: string) => void;
}
export function CustomPromptCard({
selected,
onSelect,
customPrompt,
onCustomPromptChange
}: CustomPromptCardProps) {
const DEFAULT_PROMPT = "Create a customer support assistant with one example agent";
// When unselected, show default text. When selected, show editable customPrompt
const displayText = selected ? customPrompt : DEFAULT_PROMPT;
return (
<div
onClick={onSelect}
className={clsx(
"w-full text-left cursor-pointer",
"p-4",
tokens.radius.lg,
tokens.transitions.default,
tokens.shadows.sm,
"border",
selected ? [
"border-indigo-600 dark:border-indigo-400",
"bg-indigo-50/50 dark:bg-indigo-500/10",
] : [
tokens.colors.light.border,
tokens.colors.dark.border,
tokens.colors.light.surface,
tokens.colors.dark.surface,
"hover:border-indigo-600/30 dark:hover:border-indigo-400/30",
"hover:bg-indigo-50/30 dark:hover:bg-indigo-500/5",
"transform hover:scale-[1.01]",
tokens.shadows.hover,
]
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<h3 className={clsx(
tokens.typography.sizes.base,
tokens.typography.weights.medium,
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary
)}>
Custom Prompt
</h3>
<div
onClick={(e) => e.stopPropagation()}
className="w-full"
>
{selected ? (
<Textarea
value={customPrompt}
onChange={(e) => onCustomPromptChange(e.target.value)}
className={clsx(
"w-full min-h-[100px]",
"resize-none",
"px-4 py-3",
tokens.radius.md,
tokens.transitions.default,
"bg-white dark:bg-[#1F1F23]"
)}
autoFocus
/>
) : (
<div
onClick={onSelect}
className={clsx(
tokens.typography.sizes.sm,
tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary
)}
>
{displayText}
</div>
)}
</div>
</div>
<div className={clsx(
"w-5 h-5 rounded-full border-2",
tokens.transitions.default,
selected ? [
"border-indigo-600 dark:border-indigo-400",
"bg-indigo-600 dark:bg-indigo-400",
] : [
"border-gray-300 dark:border-gray-600",
]
)}>
{selected && (
<CheckIcon className="w-4 h-4 text-white" />
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,57 @@
'use client';
import { Project } from "@/app/lib/types/project_types";
import { default as NextLink } from "next/link";
import { z } from "zod";
import clsx from 'clsx';
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { formatDistanceToNow } from "date-fns";
import { tokens } from "@/app/styles/design-tokens";
interface ProjectCardProps {
project: z.infer<typeof Project>;
}
export function ProjectCard({ project }: ProjectCardProps) {
return (
<NextLink
href={`/projects/${project._id}`}
className={clsx(
"block px-4 py-3",
tokens.transitions.default,
tokens.colors.light.surfaceHover,
tokens.colors.dark.surfaceHover,
"group"
)}
>
<div className="flex justify-between items-start">
<div className="space-y-1">
<h3 className={clsx(
tokens.typography.sizes.base,
tokens.typography.weights.medium,
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary,
"group-hover:text-indigo-600 dark:group-hover:text-indigo-400",
tokens.transitions.default
)}>
{project.name}
</h3>
<p className={clsx(
tokens.typography.sizes.xs,
tokens.colors.light.text.muted,
tokens.colors.dark.text.muted
)}>
Created {formatDistanceToNow(new Date(project.createdAt))} ago
</p>
</div>
<ChevronRightIcon
className={clsx(
"w-5 h-5",
tokens.colors.light.text.muted,
tokens.colors.dark.text.muted,
"transform transition-transform group-hover:translate-x-0.5"
)}
/>
</div>
</NextLink>
);
}

View file

@ -0,0 +1,128 @@
'use client';
import { Project } from "@/types/project_types";
import { z } from "zod";
import { useState } from "react";
import clsx from 'clsx';
import { tokens } from "@/app/styles/design-tokens";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
interface ProjectListProps {
projects: z.infer<typeof Project>[];
isLoading: boolean;
searchQuery: string;
}
const ITEMS_PER_PAGE = 10;
export function ProjectList({ projects, isLoading, searchQuery }: ProjectListProps) {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = Math.ceil(projects.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const currentProjects = projects.slice(startIndex, endIndex);
if (isLoading) {
return (
<div className="px-4 py-6 text-center text-sm text-gray-500">
Loading projects...
</div>
);
}
if (projects.length === 0) {
return (
<div className="px-4 py-6 text-center text-sm text-gray-500">
{searchQuery
? "No projects match your search"
: "You haven't created any projects yet"}
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Scrollable project list */}
<div className="flex-1 overflow-y-auto" style={{ maxHeight: 'calc(100vh - 400px)' }}>
{currentProjects.map((project) => (
<Link
key={project._id}
href={`/projects/${project._id}`}
className={clsx(
"block px-4 py-3",
tokens.transitions.default,
tokens.colors.light.surfaceHover,
tokens.colors.dark.surfaceHover,
"group"
)}
>
<div className="flex justify-between items-start">
<div className="space-y-1">
<h3 className={clsx(
tokens.typography.sizes.base,
tokens.typography.weights.medium,
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary,
"group-hover:text-indigo-600 dark:group-hover:text-indigo-400",
tokens.transitions.default
)}>
{project.name}
</h3>
<p className={clsx(
tokens.typography.sizes.xs,
tokens.colors.light.text.muted,
tokens.colors.dark.text.muted
)}>
Created {formatDistanceToNow(new Date(project.createdAt))} ago
</p>
</div>
<ChevronRightIcon className={clsx(
"w-5 h-5",
tokens.colors.light.text.muted,
tokens.colors.dark.text.muted,
"transform transition-transform group-hover:translate-x-0.5"
)} />
</div>
</Link>
))}
</div>
{/* Pagination controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-gray-800">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className={clsx(
"p-1 rounded-md",
"text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
<ChevronLeftIcon className="w-5 h-5" />
</button>
<span className={clsx(
tokens.typography.sizes.sm,
tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary
)}>
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className={clsx(
"p-1 rounded-md",
"text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
<ChevronRightIcon className="w-5 h-5" />
</button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,78 @@
'use client';
import clsx from 'clsx';
import { SearchIcon } from "lucide-react";
import { tokens } from "@/app/styles/design-tokens";
export type TimeFilter = 'all' | 'today' | 'week' | 'month';
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
onTimeFilterChange: (filter: TimeFilter) => void;
timeFilter: TimeFilter;
placeholder?: string;
}
export function SearchInput({
value,
onChange,
onTimeFilterChange,
timeFilter,
placeholder = "Search projects..."
}: SearchInputProps) {
return (
<div className="space-y-3">
<div className="relative">
<SearchIcon
size={16}
className={clsx(
"absolute left-3 top-1/2 -translate-y-1/2",
tokens.colors.light.text.tertiary,
tokens.colors.dark.text.tertiary
)}
/>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={clsx(
"w-full pl-9 pr-4 py-2",
tokens.typography.sizes.sm,
tokens.radius.md,
tokens.transitions.default,
"bg-gray-50 dark:bg-gray-800",
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary,
"placeholder:text-gray-400 dark:placeholder:text-gray-500",
"border border-gray-200 dark:border-gray-700",
"focus:ring-2 focus:ring-indigo-500 focus:ring-opacity-50",
"focus:border-transparent"
)}
/>
</div>
<div className="flex gap-2">
{(['all', 'today', 'week', 'month'] as const).map(filter => (
<button
key={filter}
onClick={() => onTimeFilterChange(filter)}
className={clsx(
"px-3 py-1",
tokens.typography.sizes.sm,
tokens.typography.weights.medium,
tokens.radius.md,
tokens.transitions.default,
timeFilter === filter
? "bg-indigo-600 text-white"
: "bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400",
"hover:bg-gray-100 dark:hover:bg-gray-700",
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-opacity-50"
)}
>
{filter.charAt(0).toUpperCase() + filter.slice(1)}
</button>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,42 @@
import { Project } from "@/types/project_types";
import { z } from "zod";
import { ProjectList } from "./project-list";
import { SectionHeading } from "@/components/ui/section-heading";
import { HorizontalDivider } from "@/components/ui/horizontal-divider";
import clsx from 'clsx';
interface SearchProjectsProps {
projects: z.infer<typeof Project>[];
isLoading: boolean;
heading: string;
subheading: string;
className?: string;
}
export function SearchProjects({
projects,
isLoading,
heading,
subheading,
className
}: SearchProjectsProps) {
return (
<div className={clsx("card", className)}>
<div className="px-4 pt-4 pb-6 flex-none">
<SectionHeading
subheading={subheading}
>
{heading}
</SectionHeading>
</div>
<HorizontalDivider />
<div className="flex-1 overflow-hidden">
<ProjectList
projects={projects}
isLoading={isLoading}
searchQuery=""
/>
</div>
</div>
);
}

View file

@ -0,0 +1,34 @@
'use client';
import { useFormStatus } from "react-dom";
import clsx from 'clsx';
import { tokens } from "@/app/styles/design-tokens";
import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
export function Submit() {
const { pending } = useFormStatus();
return (
<div className="flex flex-col items-start gap-2">
{pending && (
<div className={clsx(
"text-sm",
tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary
)}>
Please hold on while we set up your project&hellip;
</div>
)}
<Button
type="submit"
form="create-project-form"
variant="primary"
size="lg"
isLoading={pending}
startContent={<PlusIcon size={16} />}
>
Create project
</Button>
</div>
);
}

View file

@ -0,0 +1,101 @@
'use client';
import clsx from 'clsx';
import { CheckIcon } from "lucide-react";
import { useState } from "react";
import React from "react";
import { WorkflowTemplate } from "@/app/lib/types/workflow_types";
import { z } from "zod";
import { tokens } from "@/app/styles/design-tokens";
interface TemplateCardProps {
templateKey: string;
template: z.infer<typeof WorkflowTemplate> | string;
onSelect: (templateKey: string) => void;
selected: boolean;
type?: "template" | "prompt";
}
export function TemplateCard({
templateKey,
template,
onSelect,
selected,
type = "template"
}: TemplateCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
const name = typeof template === "string" ? templateKey : template.name;
const description = typeof template === "string" ? template : template.description;
const textRef = React.useRef<HTMLDivElement>(null);
const [needsExpansion, setNeedsExpansion] = useState(false);
React.useEffect(() => {
if (textRef.current) {
const needsButton = textRef.current.scrollHeight > textRef.current.clientHeight;
setNeedsExpansion(needsButton);
}
}, [description]);
return (
<div
onClick={() => onSelect(templateKey)}
className={clsx(
"w-full text-left cursor-pointer",
"p-4",
tokens.radius.lg,
tokens.transitions.default,
tokens.shadows.sm,
"border",
selected ? [
"border-indigo-600 dark:border-indigo-400",
"bg-indigo-50/50 dark:bg-indigo-500/10",
] : [
tokens.colors.light.border,
tokens.colors.dark.border,
tokens.colors.light.surface,
tokens.colors.dark.surface,
"hover:border-indigo-600/30 dark:hover:border-indigo-400/30",
"hover:bg-indigo-50/30 dark:hover:bg-indigo-500/5",
"transform hover:scale-[1.01]",
tokens.shadows.hover,
],
tokens.focus.default,
tokens.focus.dark
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<h3 className={clsx(
tokens.typography.sizes.base,
tokens.typography.weights.medium,
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary
)}>
{name}
</h3>
<p className={clsx(
tokens.typography.sizes.sm,
tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary
)}>
{description}
</p>
</div>
<div className={clsx(
"w-5 h-5 rounded-full border-2",
tokens.transitions.default,
selected ? [
"border-indigo-600 dark:border-indigo-400",
"bg-indigo-600 dark:bg-indigo-400",
] : [
"border-gray-300 dark:border-gray-600",
]
)}>
{selected && (
<CheckIcon className="w-4 h-4 text-white" />
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import { templates, starting_copilot_prompts } from "@/app/lib/project_templates";
import { TemplateCard } from "./template-card";
import { WorkflowTemplate } from "@/types/workflow_types";
import { z } from "zod";
// Use the existing template type but make id optional
type Template = z.infer<typeof WorkflowTemplate> & {
id?: string;
prompt?: string;
};
type TemplateCardsListProps = {
selectedCard: 'custom' | Template;
onSelectCard: (template: Template) => void;
};
export function TemplateCardsList({ selectedCard, onSelectCard }: TemplateCardsListProps) {
return (
<div className="grid grid-cols-2 gap-4">
{Object.entries(templates).map(([id, template]) => (
<TemplateCard
key={id}
templateKey={id}
template={template} // Remove the type assertion
selected={selectedCard !== 'custom' && selectedCard.id === id}
onSelect={() => onSelectCard({ ...template, id })}
/>
))}
{Object.entries(starting_copilot_prompts).map(([name, prompt]) => {
// Create a template-compatible object
const promptTemplate: Template = {
name,
description: prompt,
prompt,
id: name.toLowerCase(),
agents: [], // Required by WorkflowTemplate
prompts: [], // Required by WorkflowTemplate
tools: [], // Required by WorkflowTemplate
startAgent: '' // Required by WorkflowTemplate
};
return (
<TemplateCard
key={name}
templateKey={name.toLowerCase()}
template={promptTemplate}
selected={selectedCard !== 'custom' && selectedCard.id === name.toLowerCase()}
onSelect={() => onSelectCard(promptTemplate)}
/>
);
})}
</div>
);
}

View file

@ -2,4 +2,4 @@ import App from "./app";
export default function Page() {
return <App />
}
}

View file

@ -0,0 +1,107 @@
export const tokens = {
typography: {
fonts: {
sans: 'Inter, system-ui, -apple-system, sans-serif',
},
weights: {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
},
sizes: {
xs: 'text-xs',
sm: 'text-sm',
base: 'text-base',
lg: 'text-lg',
xl: 'text-xl',
'2xl': 'text-2xl',
}
},
colors: {
light: {
background: 'bg-[#F9FAFB]',
surface: 'bg-white',
surfaceHover: 'hover:bg-gray-50',
border: 'border-[#E5E7EB]',
text: {
primary: 'text-[#111827]',
secondary: 'text-[#4B5563]',
tertiary: 'text-[#6B7280]',
muted: 'text-[#9CA3AF]',
}
},
dark: {
background: 'dark:bg-[#0E0E10]',
surface: 'dark:bg-[#1A1A1D]',
surfaceHover: 'dark:hover:bg-[#1F1F23]',
border: 'dark:border-[#2E2E30]',
text: {
primary: 'dark:text-[#F3F4F6]',
secondary: 'dark:text-[#E5E7EB]',
tertiary: 'dark:text-[#D1D5DB]',
muted: 'dark:text-[#9CA3AF]',
}
},
accent: {
primary: 'bg-indigo-600 hover:bg-indigo-500',
primaryDark: 'dark:bg-indigo-500 dark:hover:bg-indigo-400',
}
},
shadows: {
sm: 'shadow-[0_2px_8px_rgba(0,0,0,0.05)]',
md: 'shadow-[0_4px_12px_rgba(0,0,0,0.08)]',
hover: 'hover:shadow-[0_8px_16px_rgba(0,0,0,0.1)]',
},
transitions: {
default: 'transition-all duration-200 ease-in-out',
transform: 'transition-transform duration-200 ease-in-out',
},
radius: {
sm: 'rounded-md', // 6px
md: 'rounded-lg', // 8px
lg: 'rounded-xl', // 12px
full: 'rounded-full',
},
focus: {
default: 'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2',
dark: 'dark:focus:ring-offset-[#0E0E10]',
},
spacing: {
page: 'max-w-[768px] mx-auto',
section: 'space-y-8'
},
navigation: {
colors: {
item: {
base: 'text-zinc-600 dark:text-zinc-400',
hover: 'hover:text-zinc-900 dark:hover:text-zinc-200',
active: 'text-zinc-900 dark:text-zinc-100',
icon: {
base: 'text-zinc-400 dark:text-zinc-500',
hover: 'group-hover:text-zinc-600 dark:group-hover:text-zinc-300',
active: 'text-indigo-600 dark:text-indigo-400'
},
indicator: 'bg-indigo-600 dark:bg-indigo-400'
},
background: {
hover: 'hover:bg-zinc-100 dark:hover:bg-zinc-800/50'
}
},
typography: {
size: 'text-[15px]',
weight: {
base: 'font-medium',
active: 'font-semibold'
}
},
layout: {
padding: {
container: 'px-6',
item: 'px-3 py-1.5'
},
gap: 'gap-6'
}
}
}
export type Tokens = typeof tokens;

View file

@ -0,0 +1,5 @@
export const getPaneClasses = (isActive: boolean, otherIsActive: boolean) => [
"transition-all duration-300",
isActive ? "scale-[1.02] shadow-xl relative z-10" : "",
otherIsActive ? "scale-[0.98] opacity-50" : ""
];

View file

@ -0,0 +1,145 @@
'use client';
import { Button, Spinner } from "@heroui/react";
import { useRef, useState, useEffect } from "react";
import { Textarea } from "@/components/ui/textarea";
// Add a type to support both message formats
type FlexibleMessage = {
role: 'user' | 'assistant' | 'system' | 'tool';
content: string | any;
version?: string;
chatId?: string;
createdAt?: string;
// Add any other optional fields that might be needed
};
export function ComposeBox({
minRows=3,
disabled=false,
loading=false,
handleUserMessage,
messages,
}: {
minRows?: number;
disabled?: boolean;
loading?: boolean;
handleUserMessage: (prompt: string) => void;
messages: FlexibleMessage[]; // Use the flexible message type
}) {
const [input, setInput] = useState('');
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
function handleInput() {
const prompt = input.trim();
if (!prompt) {
return;
}
setInput('');
handleUserMessage(prompt);
}
function handleInputKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleInput();
}
}
// focus on the input field
useEffect(() => {
inputRef.current?.focus();
}, [messages]);
return (
<div className="relative group">
{/* Keyboard shortcut hint */}
<div className="absolute -top-6 right-0 text-xs text-gray-500 dark:text-gray-400 opacity-0
group-hover:opacity-100 transition-opacity">
Press + Enter to send
</div>
{/* Outer container with padding */}
<div className="rounded-2xl border-[1.5px] border-gray-200 dark:border-[#2a2d31] p-3 relative
bg-white dark:bg-[#1e2023] flex items-end gap-2">
{/* Textarea */}
<div className="flex-1">
<Textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={disabled || loading}
placeholder="Type a message..."
autoResize={true}
maxHeight={120}
className={`
!min-h-0
!border-0 !shadow-none !ring-0
bg-transparent
resize-none
overflow-y-auto
[&::-webkit-scrollbar]:w-1
[&::-webkit-scrollbar-track]:bg-transparent
[&::-webkit-scrollbar-thumb]:bg-gray-300
[&::-webkit-scrollbar-thumb]:dark:bg-[#2a2d31]
[&::-webkit-scrollbar-thumb]:rounded-full
placeholder:text-gray-500 dark:placeholder:text-gray-400
`}
/>
</div>
{/* Send button */}
<Button
size="sm"
isIconOnly
disabled={disabled || loading || !input.trim()}
onPress={handleInput}
className={`
transition-all duration-200
${input.trim()
? 'bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:hover:bg-indigo-800/60 dark:text-indigo-300'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'
}
scale-100 hover:scale-105 active:scale-95
disabled:opacity-50 disabled:scale-95
hover:shadow-md dark:hover:shadow-indigo-950/10
mb-0.5
`}
>
{loading ? (
<Spinner size="sm" color={input.trim() ? "primary" : "default"} />
) : (
<SendIcon
size={16}
className={`transform transition-transform ${isFocused ? 'translate-x-0.5' : ''}`}
/>
)}
</Button>
</div>
</div>
);
}
// Custom SendIcon component for better visual alignment
function SendIcon({ size, className }: { size: number, className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M22 2L11 13" />
<path d="M22 2L15 22L11 13L2 9L22 2Z" />
</svg>
);
}

View file

@ -1,4 +1,4 @@
import { CopyButton } from "../../../lib/components/copy-button";
import { CopyButton } from "@/components/common/copy-button";
export function CopyAsJsonButton({ onCopy }: { onCopy: () => void }) {
return <div className="absolute top-0 right-0">

View file

@ -0,0 +1,41 @@
'use client';
import { Button } from "@/components/ui/button";
import { CopyIcon, CheckIcon } from "lucide-react";
import { useState } from "react";
export function CopyButton({
onCopy,
label,
successLabel,
}: {
onCopy: () => void;
label: string;
successLabel: string;
}) {
const [showCopySuccess, setShowCopySuccess] = useState(false);
const handleCopy = () => {
onCopy();
setShowCopySuccess(true);
setTimeout(() => {
setShowCopySuccess(false);
}, 500);
}
return (
<Button
variant="secondary"
size="sm"
onClick={handleCopy}
className="gap-2"
showHoverContent
hoverContent={showCopySuccess ? successLabel : label}
>
{showCopySuccess ? (
<CheckIcon className="h-4 w-4" />
) : (
<CopyIcon className="h-4 w-4" />
)}
</Button>
);
}

View file

@ -0,0 +1,93 @@
import clsx from "clsx";
export function ActionButton({
icon = null,
children,
onClick = undefined,
disabled = false,
primary = false,
}: {
icon?: React.ReactNode;
children: React.ReactNode;
onClick?: () => void | undefined;
disabled?: boolean;
primary?: boolean;
}) {
const onClickProp = onClick ? { onClick } : {};
return <button
disabled={disabled}
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}
>
{icon}
{children}
</button>;
}
interface PanelProps {
title: React.ReactNode;
rightActions?: React.ReactNode;
actions?: React.ReactNode;
children: React.ReactNode;
maxHeight?: string;
variant?: 'default' | 'copilot' | 'projects';
}
export function Panel({
title,
rightActions,
actions,
children,
maxHeight,
variant = 'default',
}: PanelProps) {
return <div className={clsx(
"flex flex-col overflow-hidden rounded-xl border",
"border-zinc-200 dark:border-zinc-800",
"bg-white dark:bg-zinc-900",
maxHeight ? "max-h-[var(--panel-height)]" : "h-full"
)}
style={{ '--panel-height': maxHeight } as React.CSSProperties}
>
<div className={clsx(
"shrink-0 border-b border-zinc-100 dark:border-zinc-800",
variant === 'projects' ? "flex flex-col gap-3 px-4 py-3" : "flex items-center justify-between px-4 py-3"
)}>
{variant === 'projects' ? (
<>
<div className="text-sm uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
{title}
</div>
{actions && <div className="flex items-center gap-2">
{actions}
</div>}
</>
) : variant === 'copilot' ? (
<>
<div className="flex items-center gap-2">
{title}
</div>
{rightActions}
</>
) : (
<>
{title}
{rightActions}
</>
)}
</div>
<div className={clsx(
"min-h-0 flex-1 overflow-y-auto",
variant === 'projects' && "custom-scrollbar"
)}>
{variant === 'projects' ? (
<div className="px-3 py-2 pb-4">
{children}
</div>
) : children}
</div>
</div>;
}

View file

@ -0,0 +1,77 @@
import clsx from 'clsx';
import { ButtonHTMLAttributes, forwardRef } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'tertiary';
size?: 'sm' | 'md' | 'lg';
startContent?: React.ReactNode;
endContent?: React.ReactNode;
isLoading?: boolean;
hoverContent?: React.ReactNode;
showHoverContent?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
className,
variant = 'primary',
size = 'md',
startContent,
endContent,
isLoading,
children,
disabled,
hoverContent,
showHoverContent = false,
...props
}, ref) => {
return (
<button
ref={ref}
disabled={isLoading || disabled}
className={clsx(
"inline-flex items-center justify-center rounded-full font-medium transition-all",
"focus-visible:outline-none transform hover:scale-[1.02] hover:shadow-md",
"disabled:pointer-events-none disabled:opacity-50",
{
'primary': "text-sm px-3 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-950 dark:hover:bg-indigo-900 dark:text-indigo-400",
'secondary': "bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-100",
'tertiary': "bg-transparent hover:bg-gray-100 text-gray-700 dark:hover:bg-gray-800 dark:text-gray-300",
}[variant],
{
'sm': "min-h-[2rem] px-3 text-sm py-1",
'md': "min-h-[2.5rem] px-4 py-1",
'lg': "min-h-[3rem] px-4 py-2 text-sm",
}[size],
"group",
className
)}
{...props}
>
{isLoading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
{startContent && (
<span className={clsx(
"shrink-0",
children || hoverContent ? "mr-2" : ""
)}>
{startContent}
</span>
)}
<span className="truncate">
{showHoverContent ? (
<>
<span className="group-hover:hidden">{children}</span>
<span className="hidden group-hover:inline">{hoverContent}</span>
</>
) : children}
</span>
{endContent && <span className="ml-2 shrink-0">{endContent}</span>}
</button>
);
});
Button.displayName = "Button";

View file

@ -0,0 +1,49 @@
import { Select, SelectItem, SelectProps } from "@heroui/react";
import { ReactNode, ChangeEvent } from "react";
export interface DropdownOption {
key: string;
label: string;
startContent?: ReactNode;
endContent?: ReactNode;
}
interface DropdownProps extends Omit<SelectProps, 'children' | 'onChange'> {
options: DropdownOption[];
value: string;
onChange: (value: string) => void;
className?: string;
width?: string | number;
containerClassName?: string;
}
export function Dropdown({
options,
value,
onChange,
className = "",
width = "100%",
containerClassName = "",
...selectProps
}: DropdownProps) {
return (
<div className={`${containerClassName}`} style={{ width }}>
<Select
{...selectProps}
selectedKeys={[value]}
onChange={(e: ChangeEvent<HTMLSelectElement>) => onChange(e.target.value)}
className={`${className}`}
>
{options.map((option) => (
<SelectItem
key={option.key}
startContent={option.startContent}
endContent={option.endContent}
>
{option.label}
</SelectItem>
))}
</Select>
</div>
);
}

View file

@ -0,0 +1,14 @@
import clsx from 'clsx';
interface HorizontalDividerProps {
className?: string;
}
export function HorizontalDivider({ className }: HorizontalDividerProps) {
return (
<div className={clsx(
"border-t border-gray-200 dark:border-gray-700",
className
)} />
);
}

View file

@ -0,0 +1,42 @@
import { clsx } from "clsx";
import { InputHTMLAttributes, forwardRef } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
error?: string;
label?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(({
className,
error,
label,
...props
}, ref) => {
return (
<div className="space-y-2">
{label && (
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</label>
)}
<input
ref={ref}
className={clsx(
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2",
"text-sm placeholder:text-gray-400",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400",
"disabled:cursor-not-allowed disabled:opacity-50",
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100",
error && "border-red-500 focus-visible:ring-red-500",
className
)}
{...props}
/>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
</div>
);
});
Input.displayName = "Input";

View file

@ -0,0 +1,32 @@
import clsx from 'clsx';
import { tokens } from "@/app/styles/design-tokens";
interface PageHeadingProps {
title: string;
description?: string;
}
export function PageHeading({ title, description }: PageHeadingProps) {
return (
<div>
<h1 className={clsx(
tokens.typography.weights.semibold,
tokens.typography.sizes["2xl"],
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary
)}>
{title}
</h1>
{description && (
<p className={clsx(
"mt-2",
tokens.typography.sizes.base,
tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary
)}>
{description}
</p>
)}
</div>
);
}

View file

@ -3,14 +3,14 @@
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "../../lib/utils"
import clsx from 'clsx';
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
className={clsx(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
@ -28,7 +28,7 @@ const ResizableHandle = ({
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
className={clsx(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}

View file

@ -0,0 +1,44 @@
'use client';
import { Input } from "@/components/ui/input";
import { SearchIcon, XIcon } from "lucide-react";
import { InputHTMLAttributes } from "react";
import clsx from 'clsx';
interface SearchBarProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
value: string;
onChange: (value: string) => void;
onClear?: () => void;
}
export function SearchBar({
value,
onChange,
onClear,
className,
...props
}: SearchBarProps) {
return (
<div className="relative">
<SearchIcon
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<Input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
className={clsx("pl-9 pr-8 bg-transparent", className)}
{...props}
/>
{value && (
<button
type="button"
onClick={onClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<XIcon size={14} />
</button>
)}
</div>
);
}

View file

@ -0,0 +1,32 @@
import clsx from 'clsx';
import { tokens } from "@/app/styles/design-tokens";
interface SectionHeadingProps {
children: React.ReactNode;
subheading?: React.ReactNode;
}
export function SectionHeading({ children, subheading }: SectionHeadingProps) {
return (
<div className="space-y-1">
<div className={clsx(
tokens.typography.weights.medium,
tokens.typography.sizes.lg,
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary
)}>
{children}
</div>
{subheading && (
<p className={clsx(
tokens.typography.sizes.sm,
tokens.typography.weights.normal,
tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary
)}>
{subheading}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,151 @@
import clsx from 'clsx';
import { TextareaHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react";
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
autoResize?: boolean;
maxHeight?: number;
useValidation?: boolean;
validate?: (value: string) => { valid: boolean; errorMessage?: string };
onValidatedChange?: (value: string) => void;
updateOnBlur?: boolean;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(({
className,
label,
autoResize = false,
maxHeight = 120, // default max height (roughly 5 lines)
value: propValue,
onChange,
// New validation props
useValidation = false,
validate,
onValidatedChange,
updateOnBlur = false,
onBlur,
onKeyDown,
...props
}, ref) => {
const internalRef = useRef<HTMLTextAreaElement>(null);
const textareaRef = (ref as any) || internalRef;
// Local state for validation mode
const [localValue, setLocalValue] = useState(propValue as string);
const [validationError, setValidationError] = useState<string | undefined>();
const [isEditing, setIsEditing] = useState(false);
// Sync local state with prop value when not editing
useEffect(() => {
if (!isEditing) {
setLocalValue(propValue as string);
}
}, [propValue, isEditing]);
useEffect(() => {
if (!autoResize) return;
const textarea = textareaRef.current;
if (!textarea) return;
const adjustHeight = () => {
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
// Add scrolling if content exceeds maxHeight
textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
};
adjustHeight();
// Add window resize listener
window.addEventListener('resize', adjustHeight);
return () => window.removeEventListener('resize', adjustHeight);
}, [localValue, autoResize, maxHeight, textareaRef]);
const validateAndUpdate = (value: string) => {
if (validate) {
const result = validate(value);
setValidationError(result.errorMessage);
if (result.valid && onValidatedChange) {
onValidatedChange(value);
return true;
}
return false;
} else if (onValidatedChange) {
onValidatedChange(value);
return true;
}
return false;
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
setIsEditing(true);
if (!updateOnBlur) {
if (useValidation) {
validateAndUpdate(newValue);
} else {
onChange?.(e);
}
}
};
const handleBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
setIsEditing(false);
if (updateOnBlur) {
if (useValidation) {
validateAndUpdate(localValue);
} else {
const syntheticEvent = {
...e,
target: { ...e.target, value: localValue },
currentTarget: { ...e.currentTarget, value: localValue }
};
onChange?.(syntheticEvent as any);
}
}
onBlur?.(e);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (updateOnBlur && e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
textareaRef.current?.blur();
}
onKeyDown?.(e);
};
return (
<div className="space-y-2">
{label && (
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</label>
)}
<textarea
ref={textareaRef}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
value={localValue}
className={clsx(
"flex w-full text-sm focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
"transition-colors",
className
)}
style={{
...props.style,
minHeight: autoResize ? '24px' : undefined,
}}
{...props}
/>
</div>
);
});
Textarea.displayName = "Textarea";

View file

@ -0,0 +1,19 @@
export function isToday(date: Date): boolean {
const today = new Date();
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear();
}
export function isThisWeek(date: Date): boolean {
const now = new Date();
const weekStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay());
const weekEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() + (6 - now.getDay()));
return date >= weekStart && date <= weekEnd;
}
export function isThisMonth(date: Date): boolean {
const now = new Date();
return date.getMonth() === now.getMonth() &&
date.getFullYear() === now.getFullYear();
}

View file

@ -27,8 +27,10 @@
"cheerio": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^16.4.5",
"framer-motion": "^11.5.4",
"fuse.js": "^7.1.0",
"immer": "^10.1.1",
"jose": "^5.9.6",
"lucide-react": "^0.465.0",
@ -14889,6 +14891,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
@ -15214,6 +15217,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
@ -16785,6 +16798,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
}
},
"node_modules/fzy.js": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/fzy.js/-/fzy.js-0.4.1.tgz",

View file

@ -34,8 +34,10 @@
"cheerio": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^16.4.5",
"framer-motion": "^11.5.4",
"fuse.js": "^7.1.0",
"immer": "^10.1.1",
"jose": "^5.9.6",
"lucide-react": "^0.465.0",

View file

@ -0,0 +1,31 @@
import { z } from "zod";
import { MCPServer } from "@/app/lib/types/types";
export const Project = z.object({
_id: z.string().uuid(),
name: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
createdByUserId: z.string(),
secret: z.string(),
chatClientId: z.string(),
webhookUrl: z.string().optional(),
publishedWorkflowId: z.string().optional(),
nextWorkflowNumber: z.number().optional(),
testRunCounter: z.number().default(0),
mcpServers: z.array(MCPServer).optional(),
});
export const ProjectMember = z.object({
userId: z.string(),
projectId: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const ApiKey = z.object({
projectId: z.string(),
key: z.string(),
createdAt: z.string().datetime(),
lastUsedAt: z.string().datetime().optional(),
});

View file

@ -0,0 +1,131 @@
import { z } from "zod";
export const WorkflowAgent = z.object({
name: z.string(),
type: z.union([
z.literal('conversation'),
z.literal('post_process'),
z.literal('escalation'),
]),
description: z.string(),
disabled: z.boolean().default(false).optional(),
instructions: z.string(),
examples: z.string().optional(),
model: z.union([
z.literal('gpt-4o'),
z.literal('gpt-4o-mini'),
]),
locked: z.boolean().default(false).describe('Whether this agent is locked and cannot be deleted').optional(),
toggleAble: z.boolean().default(true).describe('Whether this agent can be enabled or disabled').optional(),
global: z.boolean().default(false).describe('Whether this agent is a global agent, in which case it cannot be connected to other agents').optional(),
ragDataSources: z.array(z.string()).optional(),
ragReturnType: z.union([z.literal('chunks'), z.literal('content')]).default('chunks'),
ragK: z.number().default(3),
controlType: z.union([z.literal('retain'), z.literal('relinquish_to_parent'), z.literal('relinquish_to_start')]).default('retain').describe('Whether this agent retains control after a turn, relinquishes to the parent agent, or relinquishes to the start agent'),
});
export const WorkflowPrompt = z.object({
name: z.string(),
type: z.union([
z.literal('base_prompt'),
z.literal('style_prompt'),
z.literal('greeting'),
]),
prompt: z.string(),
});
export const WorkflowTool = z.object({
name: z.string(),
description: z.string(),
mockTool: z.boolean().default(false).optional(),
autoSubmitMockedResponse: z.boolean().default(false).optional(),
mockInstructions: z.string().optional(),
parameters: z.object({
type: z.literal('object'),
properties: z.record(z.object({
type: z.string(),
description: z.string(),
})),
required: z.array(z.string()).optional(),
}),
isMcp: z.boolean().default(false).optional(),
mcpServerName: z.string().optional(),
});
export const Workflow = z.object({
name: z.string().optional(),
agents: z.array(WorkflowAgent),
prompts: z.array(WorkflowPrompt),
tools: z.array(WorkflowTool),
startAgent: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
projectId: z.string(),
});
export const WorkflowTemplate = Workflow
.omit({
projectId: true,
lastUpdatedAt: true,
createdAt: true,
})
.extend({
name: z.string(),
description: z.string(),
});
export const ConnectedEntity = z.object({
type: z.union([z.literal('tool'), z.literal('prompt'), z.literal('agent')]),
name: z.string(),
});
export function sanitizeTextWithMentions(
text: string,
workflow: {
agents: z.infer<typeof WorkflowAgent>[],
tools: z.infer<typeof WorkflowTool>[],
prompts: z.infer<typeof WorkflowPrompt>[],
},
): {
sanitized: string;
entities: z.infer<typeof ConnectedEntity>[];
} {
// Regex to match [@type:name](#type:something) pattern where type is tool/prompt/agent
const mentionRegex = /\[@(tool|prompt|agent):([^\]]+)\]\(#mention\)/g;
const seen = new Set<string>();
// collect entities
const entities = Array
.from(text.matchAll(mentionRegex))
.filter(match => {
if (seen.has(match[0])) {
return false;
}
seen.add(match[0]);
return true;
})
.map(match => {
return {
type: match[1] as 'tool' | 'prompt' | 'agent',
name: match[2],
};
})
.filter(entity => {
seen.add(entity.name);
if (entity.type === 'agent') {
return workflow.agents.some(a => a.name === entity.name);
} else if (entity.type === 'tool') {
return workflow.tools.some(t => t.name === entity.name);
} else if (entity.type === 'prompt') {
return workflow.prompts.some(p => p.name === entity.name);
}
return false;
})
// sanitize text
for (const entity of entities) {
const id = `${entity.type}:${entity.name}`;
const textToReplace = `[@${id}](#mention)`;
text = text.replace(textToReplace, `[@${id}]`);
}
return {
sanitized: text,
entities,
};
}