mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-24 16:06:25 +02:00
Mega UI revamp
This commit is contained in:
parent
650f481a96
commit
bcb686a20d
94 changed files with 6984 additions and 3889 deletions
0
.gitattributes
vendored
Normal file
0
.gitattributes
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
@ -98,3 +159,26 @@ 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
14
apps/rowboat/app/lib/components/form-status-button-old.tsx
Normal file
14
apps/rowboat/app/lib/components/form-status-button-old.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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) => (
|
||||
<ListItem
|
||||
key={item}
|
||||
name={item}
|
||||
isSelected={selected === item}
|
||||
onClick={() => onSelect(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>
|
||||
))}
|
||||
</StructuredPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -822,27 +854,61 @@ 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">
|
||||
<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:
|
||||
return null;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
function PhoneNumberSection({
|
||||
value,
|
||||
onChange,
|
||||
disabled
|
||||
}: {
|
||||
projectId: string;
|
||||
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: '',
|
||||
|
|
@ -223,7 +339,5 @@ export function VoiceSection({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StructuredPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
385
apps/rowboat/app/projects/[projectId]/copilot/app.tsx
Normal file
385
apps/rowboat/app/projects/[projectId]/copilot/app.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 = () => {
|
||||
|
|
@ -222,82 +227,3 @@ export function ActionField({
|
|||
</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>;
|
||||
// }
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
450
apps/rowboat/app/projects/[projectId]/entities/agent_config.tsx
Normal file
450
apps/rowboat/app/projects/[projectId]/entities/agent_config.tsx
Normal 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;
|
||||
}
|
||||
133
apps/rowboat/app/projects/[projectId]/entities/prompt_config.tsx
Normal file
133
apps/rowboat/app/projects/[projectId]/entities/prompt_config.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
448
apps/rowboat/app/projects/[projectId]/entities/tool_config.tsx
Normal file
448
apps/rowboat/app/projects/[projectId]/entities/tool_config.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,25 +63,79 @@ 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} />}
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
New chat
|
||||
</ActionButton>,
|
||||
]}
|
||||
<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'}
|
||||
>
|
||||
<div className="h-full overflow-auto">
|
||||
<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}
|
||||
isOpen={isProfileSelectorOpen}
|
||||
onOpenChange={setIsProfileSelectorOpen}
|
||||
onSelect={handleTestProfileChange}
|
||||
selectedProfileId={testProfile?._id}
|
||||
/>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<Chat
|
||||
key={`chat-${counter}`}
|
||||
chat={chat}
|
||||
|
|
@ -92,6 +150,7 @@ export function App({
|
|||
toolWebhookUrl={toolWebhookUrl}
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,41 +223,21 @@ 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>}
|
||||
</div>
|
||||
<ProfileSelector
|
||||
projectId={projectId}
|
||||
isOpen={isProfileSelectorOpen}
|
||||
onOpenChange={setIsProfileSelectorOpen}
|
||||
onSelect={onTestProfileChange}
|
||||
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>
|
||||
|
||||
<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}
|
||||
|
|
@ -268,28 +247,31 @@ export function Chat({
|
|||
testProfile={testProfile}
|
||||
systemMessage={systemMessage}
|
||||
onSystemMessageChange={onSystemMessageChange}
|
||||
showSystemMessage={false}
|
||||
/>
|
||||
<div className="shrink-0">
|
||||
</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}
|
||||
messages={messages.filter(msg => msg.content !== undefined) as any}
|
||||
loading={loadingAssistantResponse}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>}
|
||||
/>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>}
|
||||
</>;
|
||||
}
|
||||
|
|
@ -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">
|
||||
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">
|
||||
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>
|
||||
</div>}
|
||||
{source.data.type === 'files' && <div className="flex gap-1 items-center">
|
||||
</>}
|
||||
{source.data.type === 'files' && <>
|
||||
<DataSourceIcon type="files" />
|
||||
<div>File upload</div>
|
||||
</div>}
|
||||
{source.data.type === 'text' && <div className="flex gap-1 items-center">
|
||||
</>}
|
||||
{source.data.type === 'text' && <>
|
||||
<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} />}
|
||||
</>}
|
||||
</div>
|
||||
</SectionContent>
|
||||
</SectionRow>
|
||||
|
||||
<PageSection title="Danger zone">
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<p>Delete this data source:</p>
|
||||
<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>
|
||||
</PageSection>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,16 +137,21 @@ 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">
|
||||
<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>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">
|
||||
<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}
|
||||
|
|
@ -157,14 +161,19 @@ function PaginatedFileList({
|
|||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
{totalPages > 1 && <Pagination
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6">
|
||||
<Pagination
|
||||
total={totalPages}
|
||||
page={page}
|
||||
onChange={setPage}
|
||||
/>}
|
||||
</div>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilesSource({
|
||||
|
|
@ -237,22 +246,16 @@ 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">
|
||||
<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' : 'border-gray-300'}`}
|
||||
${isDragActive ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/10' : 'border-gray-300 dark:border-gray-700'}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{uploading ? (
|
||||
|
|
@ -263,9 +266,9 @@ export function FilesSource({
|
|||
) : isDragActive ? (
|
||||
<p>Drop the files here...</p>
|
||||
) : (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>Drag and drop files here, or click to select files</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Supported file types: PDF, TXT, DOC, DOCX
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -277,9 +280,18 @@ export function FilesSource({
|
|||
projectId={projectId}
|
||||
sourceId={dataSource._id}
|
||||
handleReload={handleReload}
|
||||
onDelete={handleDelete}
|
||||
onDelete={async (docId) => {
|
||||
await deleteDocsFromDataSource({
|
||||
projectId,
|
||||
sourceId: dataSource._id,
|
||||
docIds: [docId],
|
||||
});
|
||||
handleReload();
|
||||
setFileListKey(prev => prev + 1);
|
||||
}}
|
||||
/>
|
||||
</PageSection>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
<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"
|
||||
label="Text content"
|
||||
labelPlacement="outside"
|
||||
value={content}
|
||||
onValueChange={setContent}
|
||||
minRows={10}
|
||||
maxRows={20}
|
||||
variant="bordered"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,72 +107,83 @@ export function Form({
|
|||
router.push(`/projects/${projectId}/sources/${source._id}`);
|
||||
}
|
||||
|
||||
function handleSourceTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||
setSourceType(event.target.value);
|
||||
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>
|
||||
}
|
||||
|
||||
return <div className="grow overflow-auto py-4">
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[768px] mx-auto flex flex-col gap-4">
|
||||
<Select
|
||||
<Dropdown
|
||||
label="Select type"
|
||||
selectedKeys={[sourceType]}
|
||||
onChange={handleSourceTypeChange}
|
||||
value={sourceType}
|
||||
onChange={setSourceType}
|
||||
options={dropdownOptions}
|
||||
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"
|
||||
>
|
||||
<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
|
||||
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"
|
||||
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="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>
|
||||
<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
|
||||
|
|
@ -159,7 +191,7 @@ export function Form({
|
|||
type: "submit",
|
||||
children: "Add data source",
|
||||
className: "self-start",
|
||||
startContent: <PlusIcon className="w-[24px] h-[24px]" />
|
||||
startContent: <PlusIcon className="w-4 h-4" />
|
||||
}}
|
||||
/>
|
||||
</form>}
|
||||
|
|
@ -168,19 +200,38 @@ export function Form({
|
|||
action={createFilesDataSource}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="self-start">
|
||||
<Input
|
||||
<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
|
||||
type="text"
|
||||
name="name"
|
||||
label="Name this data source"
|
||||
labelPlacement="outside"
|
||||
placeholder="e.g. Documentation files"
|
||||
variant="bordered"
|
||||
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="text-sm">
|
||||
<p>You will be able to upload files in the next step</p>
|
||||
<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={{
|
||||
|
|
@ -196,23 +247,28 @@ export function 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
|
||||
type="text"
|
||||
name="content"
|
||||
label="Text content"
|
||||
labelPlacement="outside"
|
||||
minRows={10}
|
||||
maxRows={30}
|
||||
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 className="self-start">
|
||||
<Input
|
||||
</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
|
||||
type="text"
|
||||
name="name"
|
||||
labelPlacement="outside"
|
||||
placeholder="e.g. Product documentation"
|
||||
variant="bordered"
|
||||
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
|
||||
|
|
@ -225,5 +281,7 @@ export function Form({
|
|||
/>
|
||||
</form>}
|
||||
</div>
|
||||
</div>;
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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…
|
||||
</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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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,18 +96,35 @@ export function ProfileSelector({ projectId, isOpen, onOpenChange, onSelect }: P
|
|||
</>}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<div className="flex-1">
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={() => router.push(`/projects/${projectId}/test/profiles`)}
|
||||
variant="primary"
|
||||
onClick={() => router.push(`/projects/${projectId}/test/profiles`)}
|
||||
>
|
||||
Manage Profiles
|
||||
</Button>
|
||||
<Button size="sm" variant="flat" onPress={onClose}>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,33 +128,43 @@ 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" />}
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
Add
|
||||
</ActionButton>
|
||||
</SectionHeader>
|
||||
<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) => (
|
||||
<ListItem
|
||||
<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}
|
||||
rightElement={
|
||||
<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>
|
||||
)}
|
||||
statusLabel={startAgentName === agent.name ? <StartLabel /> : null}
|
||||
menuContent={
|
||||
<AgentDropdown
|
||||
agent={agent}
|
||||
isStartAgent={startAgentName === agent.name}
|
||||
|
|
@ -94,60 +172,124 @@ export function EntityList({
|
|||
onSetMainAgent={onSetMainAgent}
|
||||
onDelete={onDeleteAgent}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</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" />}
|
||||
{/* 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"
|
||||
>
|
||||
MCP
|
||||
</ActionButton>
|
||||
</SectionHeader>
|
||||
|
||||
<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) => (
|
||||
<ListItem
|
||||
<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}
|
||||
rightElement={<EntityDropdown name={tool.name} onDelete={onDeleteTool} />}
|
||||
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>
|
||||
|
||||
{/* Prompts Section */}
|
||||
<SectionHeader title="Prompts">
|
||||
<ActionButton
|
||||
icon={<PlusIcon className="w-4 h-4" />}
|
||||
{/* 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"
|
||||
>
|
||||
Add
|
||||
</ActionButton>
|
||||
</SectionHeader>
|
||||
<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) => (
|
||||
<ListItem
|
||||
<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}
|
||||
rightElement={<EntityDropdown name={prompt.name} onDelete={onDeletePrompt} />}
|
||||
menuContent={
|
||||
<EntityDropdown
|
||||
name={prompt.name}
|
||||
onDelete={onDeletePrompt}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StructuredPanel>
|
||||
) : (
|
||||
<EmptyState entity="prompts" />
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ export default async function Page({
|
|||
notFound();
|
||||
}
|
||||
|
||||
return <App
|
||||
return (
|
||||
<App
|
||||
projectId={params.projectId}
|
||||
useRag={USE_RAG}
|
||||
/>;
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<DropdownSection>
|
||||
<DropdownItem
|
||||
key="switch"
|
||||
startContent={<BackIcon size={16} />}
|
||||
startContent={<div className="text-gray-500"><BackIcon size={16} /></div>}
|
||||
className="gap-x-2"
|
||||
>
|
||||
Switch version
|
||||
View versions
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700" />
|
||||
|
||||
<DropdownSection>
|
||||
<DropdownItem
|
||||
key="clone"
|
||||
startContent={<Layers2Icon size={16} />}
|
||||
startContent={<div className="text-gray-500"><Layers2Icon size={16} /></div>}
|
||||
className="gap-x-2"
|
||||
>
|
||||
Clone this version
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem
|
||||
key="publish"
|
||||
color="danger"
|
||||
startContent={<RadioIcon size={16} />}
|
||||
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"
|
||||
>
|
||||
Deploy to Production
|
||||
Make version live
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700" />
|
||||
|
||||
<DropdownSection>
|
||||
<DropdownItem
|
||||
key="clipboard"
|
||||
startContent={<CopyIcon size={16} />}
|
||||
startContent={<div className="text-gray-500"><CopyIcon size={16} /></div>}
|
||||
className="gap-x-2"
|
||||
>
|
||||
Copy as JSON
|
||||
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,11 +959,12 @@ export function WorkflowEditor({
|
|||
handleClose={handleUnselectPrompt}
|
||||
/>}
|
||||
</ResizablePanel>
|
||||
{showCopilot && <>
|
||||
<ResizableHandle />
|
||||
{showCopilot && (
|
||||
<>
|
||||
<ResizableHandle className="w-[3px] bg-transparent" />
|
||||
<ResizablePanel
|
||||
minSize={10}
|
||||
defaultSize={copilotWidth}
|
||||
defaultSize={PANEL_RATIOS.copilot}
|
||||
onResize={(size) => setCopilotWidth(size)}
|
||||
>
|
||||
<Copilot
|
||||
|
|
@ -959,7 +982,8 @@ export function WorkflowEditor({
|
|||
}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</>}
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
<McpImportTools
|
||||
projectId={state.present.workflow.projectId}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
42
apps/rowboat/app/projects/layout/components/app-layout.tsx
Normal file
42
apps/rowboat/app/projects/layout/components/app-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/rowboat/app/projects/layout/components/menu-item.tsx
Normal file
43
apps/rowboat/app/projects/layout/components/menu-item.tsx
Normal 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;
|
||||
}
|
||||
216
apps/rowboat/app/projects/layout/components/sidebar.tsx
Normal file
216
apps/rowboat/app/projects/layout/components/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/rowboat/app/projects/layout/index.tsx
Normal file
16
apps/rowboat/app/projects/layout/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
interface NavLinkProps {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
collapsed?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
function NavLink({ href, label, icon, collapsed, selected = false }: NavLinkProps) {
|
||||
return (
|
||||
<Tooltip content={label} showArrow placement="right">
|
||||
<Link href={href} className="block">
|
||||
<MenuItem
|
||||
icon={icon}
|
||||
selected={selected}
|
||||
onClick={() => {}}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href}>
|
||||
<MenuItem
|
||||
icon={icon}
|
||||
selected={selected}
|
||||
onClick={() => {}}
|
||||
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>
|
||||
|
|
@ -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({
|
||||
|
|
@ -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"
|
||||
)}
|
||||
>
|
||||
“Create an assistant for a food delivery app that can take new orders, cancel existing orders and answer questions about refund policies”
|
||||
</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…</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>;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import App from "./app";
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
return <App />
|
||||
redirect('/projects/select');
|
||||
}
|
||||
238
apps/rowboat/app/projects/select/app.tsx
Normal file
238
apps/rowboat/app/projects/select/app.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
57
apps/rowboat/app/projects/select/components/project-card.tsx
Normal file
57
apps/rowboat/app/projects/select/components/project-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
apps/rowboat/app/projects/select/components/project-list.tsx
Normal file
128
apps/rowboat/app/projects/select/components/project-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
apps/rowboat/app/projects/select/components/search-input.tsx
Normal file
78
apps/rowboat/app/projects/select/components/search-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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…
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-project-form"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={pending}
|
||||
startContent={<PlusIcon size={16} />}
|
||||
>
|
||||
Create project
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
apps/rowboat/app/projects/select/components/template-card.tsx
Normal file
101
apps/rowboat/app/projects/select/components/template-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
107
apps/rowboat/app/styles/design-tokens.ts
Normal file
107
apps/rowboat/app/styles/design-tokens.ts
Normal 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;
|
||||
5
apps/rowboat/app/styles/pane-effects.ts
Normal file
5
apps/rowboat/app/styles/pane-effects.ts
Normal 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" : ""
|
||||
];
|
||||
145
apps/rowboat/components/common/compose-box.tsx
Normal file
145
apps/rowboat/components/common/compose-box.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
41
apps/rowboat/components/common/copy-button.tsx
Normal file
41
apps/rowboat/components/common/copy-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
apps/rowboat/components/common/panel-common.tsx
Normal file
93
apps/rowboat/components/common/panel-common.tsx
Normal 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>;
|
||||
}
|
||||
77
apps/rowboat/components/ui/button.tsx
Normal file
77
apps/rowboat/components/ui/button.tsx
Normal 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";
|
||||
49
apps/rowboat/components/ui/dropdown.tsx
Normal file
49
apps/rowboat/components/ui/dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
apps/rowboat/components/ui/horizontal-divider.tsx
Normal file
14
apps/rowboat/components/ui/horizontal-divider.tsx
Normal 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
|
||||
)} />
|
||||
);
|
||||
}
|
||||
42
apps/rowboat/components/ui/input.tsx
Normal file
42
apps/rowboat/components/ui/input.tsx
Normal 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";
|
||||
32
apps/rowboat/components/ui/page-heading.tsx
Normal file
32
apps/rowboat/components/ui/page-heading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
)}
|
||||
|
|
|
|||
44
apps/rowboat/components/ui/search-bar.tsx
Normal file
44
apps/rowboat/components/ui/search-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
apps/rowboat/components/ui/section-heading.tsx
Normal file
32
apps/rowboat/components/ui/section-heading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
apps/rowboat/components/ui/textarea.tsx
Normal file
151
apps/rowboat/components/ui/textarea.tsx
Normal 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";
|
||||
19
apps/rowboat/lib/utils/date.ts
Normal file
19
apps/rowboat/lib/utils/date.ts
Normal 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();
|
||||
}
|
||||
22
apps/rowboat/package-lock.json
generated
22
apps/rowboat/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
31
apps/rowboat/types/project_types.ts
Normal file
31
apps/rowboat/types/project_types.ts
Normal 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(),
|
||||
});
|
||||
131
apps/rowboat/types/workflow_types.ts
Normal file
131
apps/rowboat/types/workflow_types.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue