Merge pull request #68 from rowboatlabs/new_tools_and_ui_changes

New tools and UI changes
This commit is contained in:
Ramnique Singh 2025-04-15 22:01:20 +05:30 committed by GitHub
commit ba14bc9bd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1001 additions and 274 deletions

View file

@ -74,6 +74,8 @@ export async function getCopilotResponse(
const test = {
name: 'test',
description: 'test',
type: 'custom' as const,
implementation: 'mock' as const,
parameters: {
type: 'object',
properties: {},

View file

@ -8,4 +8,6 @@ export const USE_AUTH = process.env.USE_AUTH === 'true';
export const USE_MULTIPLE_PROJECTS = true;
export const USE_TESTING_FEATURE = false;
export const USE_VOICE_FEATURE = false;
export const USE_TRANSFER_CONTROL_OPTIONS = false;
export const USE_TRANSFER_CONTROL_OPTIONS = false;
export const USE_PRODUCT_TOUR = true;
export const SHOW_COPILOT_MARQUEE = false;

View file

@ -49,14 +49,19 @@ You are an helpful customer support assistant
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: [],
tools: [
{
"name": "web_search",
"description": "Fetch information from the web based on chat context",
"parameters": {
"type": "object",
"properties": {},
},
"isLibrary": true
}
],
}
}

View file

@ -11,7 +11,9 @@ export const WorkflowAgent = z.object({
instructions: z.string(),
examples: z.string().optional(),
model: z.union([
z.literal('gpt-4.1'),
z.literal('gpt-4o'),
z.literal('gpt-4.1-mini'),
z.literal('gpt-4o-mini'),
]),
locked: z.boolean().default(false).describe('Whether this agent is locked and cannot be deleted').optional(),
@ -46,6 +48,7 @@ export const WorkflowTool = z.object({
required: z.array(z.string()).optional(),
}),
isMcp: z.boolean().default(false).optional(),
isLibrary: z.boolean().default(false).optional(),
mcpServerName: z.string().optional(),
});
export const Workflow = z.object({
@ -116,7 +119,7 @@ export function sanitizeTextWithMentions(
}
return false;
})
// sanitize text
for (const entity of entities) {
const id = `${entity.type}:${entity.name}`;

View file

@ -1,5 +1,6 @@
'use client';
import { Button } from "@/components/ui/button";
import { Dropdown, DropdownItem, DropdownMenu, DropdownSection, DropdownTrigger, Spinner, Tooltip } from "@heroui/react";
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";
@ -11,7 +12,7 @@ import { Action as WorkflowDispatch } from "../workflow/workflow_editor";
import { Panel } from "@/components/common/panel-common";
import { ComposeBoxCopilot } from "@/components/common/compose-box-copilot";
import { Messages } from "./components/messages";
import { CopyIcon, CheckIcon, PlusIcon, XIcon } from "lucide-react";
import { CopyIcon, CheckIcon, PlusIcon, XIcon, InfoIcon } from "lucide-react";
const CopilotContext = createContext<{
workflow: z.infer<typeof Workflow> | null;
@ -30,6 +31,7 @@ interface AppProps {
chatContext?: any;
onCopyJson?: (data: { messages: any[], lastRequest: any, lastResponse: any }) => void;
onMessagesChange?: (messages: z.infer<typeof CopilotMessage>[]) => void;
isInitialState?: boolean;
}
const App = forwardRef<{ handleCopyChat: () => void }, AppProps>(function App({
@ -39,6 +41,7 @@ const App = forwardRef<{ handleCopyChat: () => void }, AppProps>(function App({
chatContext = undefined,
onCopyJson,
onMessagesChange,
isInitialState = false,
}, ref) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const [messages, setMessages] = useState<z.infer<typeof CopilotMessage>[]>([]);
@ -48,6 +51,9 @@ const App = forwardRef<{ handleCopyChat: () => void }, AppProps>(function App({
const [discardContext, setDiscardContext] = useState(false);
const [lastRequest, setLastRequest] = useState<unknown | null>(null);
const [lastResponse, setLastResponse] = useState<unknown | null>(null);
const [currentStatus, setCurrentStatus] = useState<'thinking' | 'planning' | 'generating'>('thinking');
const statusIntervalRef = useRef<NodeJS.Timeout>();
const [isLastInteracted, setIsLastInteracted] = useState(isInitialState);
// Notify parent of message changes
useEffect(() => {
@ -80,6 +86,7 @@ const App = forwardRef<{ handleCopyChat: () => void }, AppProps>(function App({
content: prompt
}]);
setResponseError(null);
setIsLastInteracted(true);
}
const handleApplyChange = useCallback((
@ -193,6 +200,16 @@ const App = forwardRef<{ handleCopyChat: () => void }, AppProps>(function App({
if (lastMessage.role !== 'user') return;
setLoadingResponse(true);
setCurrentStatus('thinking');
// Start cycling through statuses
statusIntervalRef.current = setInterval(() => {
setCurrentStatus(prev => {
if (prev === 'thinking') return 'planning';
if (prev === 'planning') return 'generating';
return 'generating'; // Stay on generating once reached
});
}, 3000);
try {
const response = await getCopilotResponse(
@ -214,6 +231,9 @@ const App = forwardRef<{ handleCopyChat: () => void }, AppProps>(function App({
} finally {
if (!ignore) {
setLoadingResponse(false);
if (statusIntervalRef.current) {
clearInterval(statusIntervalRef.current);
}
}
}
}
@ -222,6 +242,9 @@ const App = forwardRef<{ handleCopyChat: () => void }, AppProps>(function App({
return () => {
ignore = true;
if (statusIntervalRef.current) {
clearInterval(statusIntervalRef.current);
}
};
}, [messages, projectId, workflow, effectiveContext]);
@ -251,6 +274,7 @@ const App = forwardRef<{ handleCopyChat: () => void }, AppProps>(function App({
<Messages
messages={messages}
loadingResponse={loadingResponse}
currentStatus={currentStatus}
workflow={workflow}
handleApplyChange={handleApplyChange}
appliedChanges={appliedChanges}
@ -290,6 +314,9 @@ const App = forwardRef<{ handleCopyChat: () => void }, AppProps>(function App({
messages={messages}
loading={loadingResponse}
disabled={loadingResponse}
initialFocus={isInitialState}
shouldAutoFocus={isLastInteracted}
onFocus={() => setIsLastInteracted(true)}
/>
</div>
</div>
@ -302,11 +329,13 @@ export function Copilot({
workflow,
chatContext = undefined,
dispatch,
isInitialState = false,
}: {
projectId: string;
workflow: z.infer<typeof Workflow>;
chatContext?: z.infer<typeof CopilotChatContext>;
dispatch: (action: WorkflowDispatch) => void;
isInitialState?: boolean;
}) {
const [copilotKey, setCopilotKey] = useState(0);
const [showCopySuccess, setShowCopySuccess] = useState(false);
@ -329,11 +358,17 @@ export function Copilot({
return (
<Panel variant="copilot"
tourTarget="copilot"
showWelcome={messages.length === 0}
title={
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
COPILOT
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
COPILOT
</div>
<Tooltip content="Ask copilot to help you build and modify your workflow">
<InfoIcon className="w-4 h-4 text-gray-400 cursor-help" />
</Tooltip>
</div>
<Button
variant="primary"
@ -375,6 +410,7 @@ export function Copilot({
chatContext={chatContext}
onCopyJson={handleCopyJson}
onMessagesChange={setMessages}
isInitialState={isInitialState}
/>
</div>
</Panel>

View file

@ -97,14 +97,21 @@ function AssistantMessage({
);
}
function AssistantMessageLoading() {
function AssistantMessageLoading({ currentStatus }: { currentStatus: 'thinking' | 'planning' | 'generating' }) {
const statusText = {
thinking: "Thinking...",
planning: "Planning...",
generating: "Generating..."
};
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">
shadow-sm dark:shadow-gray-950/20 animate-pulse min-h-[2.5rem] flex items-center gap-2">
<Spinner size="sm" className="ml-2" />
<span className="text-sm text-gray-600 dark:text-gray-400">{statusText[currentStatus]}</span>
</div>
</div>
);
@ -113,12 +120,14 @@ function AssistantMessageLoading() {
export function Messages({
messages,
loadingResponse,
currentStatus,
workflow,
handleApplyChange,
appliedChanges
}: {
messages: z.infer<typeof CopilotMessage>[];
loadingResponse: boolean;
currentStatus: 'thinking' | 'planning' | 'generating';
workflow: z.infer<typeof Workflow>;
handleApplyChange: (messageIndex: number, actionIndex: number, field?: string) => void;
appliedChanges: Record<string, boolean>;
@ -160,7 +169,7 @@ export function Messages({
))}
{loadingResponse && (
<div className="animate-pulse">
<AssistantMessageLoading />
<AssistantMessageLoading currentStatus={currentStatus} />
</div>
)}
</div>

View file

@ -67,7 +67,7 @@ export function AgentConfig({
if (!USE_TRANSFER_CONTROL_OPTIONS && agent.controlType !== 'retain') {
handleUpdate({ ...agent, controlType: 'retain' });
}
}, [USE_TRANSFER_CONTROL_OPTIONS, agent.controlType, agent, handleUpdate]);
}, [agent.controlType, agent, handleUpdate]);
const validateName = (value: string) => {
if (value.length === 0) {

View file

@ -2,7 +2,7 @@
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 { ImportIcon, XIcon, PlusIcon, FolderIcon } from "lucide-react";
import { useState, useEffect } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Panel } from "@/components/common/panel-common";
@ -161,7 +161,7 @@ export function ToolConfig({
handleClose: () => void
}) {
const [selectedParams, setSelectedParams] = useState(new Set([]));
const isReadOnly = tool.isMcp;
const isReadOnly = tool.isMcp || tool.isLibrary;
const [nameError, setNameError] = useState<string | null>(null);
function handleParamRename(oldName: string, newName: string) {
@ -245,6 +245,12 @@ export function ToolConfig({
<span className="text-xs">MCP: {tool.mcpServerName}</span>
</div>
)}
{tool.isLibrary && (
<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">
<FolderIcon className="w-4 h-4 text-blue-700 dark:text-blue-400" />
<span className="text-xs">Library Tool</span>
</div>
)}
</div>
<Button
variant="secondary"

View file

@ -6,12 +6,14 @@ 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 { Tooltip } from "@heroui/react";
import { apiV1 } from "rowboat-shared";
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";
import { CheckIcon, CopyIcon, PlusIcon, UserIcon, InfoIcon } from "lucide-react";
import { USE_TESTING_FEATURE } from "@/app/lib/feature_flags";
import { clsx } from "clsx";
const defaultSystemMessage = '';
@ -22,6 +24,8 @@ export function App({
messageSubscriber,
mcpServerUrls,
toolWebhookUrl,
isInitialState = false,
onPanelClick,
}: {
hidden?: boolean;
projectId: string;
@ -29,6 +33,8 @@ export function App({
messageSubscriber?: (messages: z.infer<typeof apiV1.ChatMessage>[]) => void;
mcpServerUrls: Array<z.infer<typeof MCPServer>>;
toolWebhookUrl: string;
isInitialState?: boolean;
onPanelClick?: () => void;
}) {
const [counter, setCounter] = useState<number>(0);
const [testProfile, setTestProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
@ -88,10 +94,17 @@ export function App({
return (
<>
<Panel
variant="playground"
tourTarget="playground"
title={
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
PLAYGROUND
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
PLAYGROUND
</div>
<Tooltip content="Test your workflow and chat with your agents in real-time">
<InfoIcon className="w-4 h-4 text-gray-400 cursor-help" />
</Tooltip>
</div>
<Button
variant="primary"
@ -133,6 +146,10 @@ export function App({
</Button>
</div>
}
className={clsx(
isInitialState && "opacity-50 transition-opacity duration-300"
)}
onClick={onPanelClick}
>
<ProfileSelector
projectId={projectId}

View file

@ -8,7 +8,7 @@ import { AgenticAPIChatMessage, convertFromAgenticAPIChatMessages, convertToAgen
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 { ComposeBoxPlayground } from "@/components/common/compose-box-playground";
import { Button } from "@heroui/react";
import { apiV1 } from "rowboat-shared";
import { TestProfile } from "@/app/lib/types/testing_types";
@ -50,6 +50,7 @@ export function Chat({
const [lastAgenticRequest, setLastAgenticRequest] = useState<unknown | null>(null);
const [lastAgenticResponse, setLastAgenticResponse] = useState<unknown | null>(null);
const [optimisticMessages, setOptimisticMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages);
const [isLastInteracted, setIsLastInteracted] = useState(false);
const getCopyContent = useCallback(() => {
return JSON.stringify({
@ -90,6 +91,7 @@ export function Chat({
}];
setMessages(updatedMessages);
setFetchResponseError(null);
setIsLastInteracted(true);
}
// reset state when workflow changes
@ -289,10 +291,12 @@ export function Chat({
</div>
)}
<ComposeBox
<ComposeBoxPlayground
handleUserMessage={handleUserMessage}
messages={messages.filter(msg => msg.content !== undefined) as any}
loading={loadingAssistantResponse}
shouldAutoFocus={isLastInteracted}
onFocus={() => setIsLastInteracted(true)}
/>
</div>
</div>;

View file

@ -3,18 +3,20 @@ import { AgenticAPITool } from "../../../lib/types/agents_api_types";
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 { useRef, useEffect, useState } from "react";
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',
const SECTION_HEIGHT_PERCENTAGES = {
AGENTS: 40, // 50% of available height
TOOLS: 30, // 30% of available height
PROMPTS: 30, // 20% of available height
} as const;
const GAP_SIZE = 24; // 6 units * 4px (tailwind's default spacing unit)
interface EntityListProps {
agents: z.infer<typeof WorkflowAgent>[];
tools: z.infer<typeof AgenticAPITool>[];
@ -124,20 +126,42 @@ export function EntityList({
triggerMcpImport,
}: EntityListProps) {
const selectedRef = useRef<HTMLButtonElement | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState<number>(0);
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(() => {
const updateHeight = () => {
if (containerRef.current) {
setContainerHeight(containerRef.current.clientHeight);
}
};
updateHeight();
window.addEventListener('resize', updateHeight);
return () => window.removeEventListener('resize', updateHeight);
}, []);
useEffect(() => {
if (selectedEntity && selectedRef.current) {
selectedRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [selectedEntity]);
const calculateSectionHeight = (percentage: number) => {
// Total gaps = 2 gaps between 3 sections
const totalGaps = GAP_SIZE * 2;
const availableHeight = containerHeight - totalGaps;
return `${(availableHeight * percentage) / 100}px`;
};
return (
<div className="flex flex-col gap-6 h-full overflow-hidden">
<div className="flex flex-col gap-6 overflow-y-auto custom-scrollbar">
<div ref={containerRef} className="flex flex-col h-full">
<div className="flex flex-col gap-6 h-full flex-1">
{/* Agents Panel */}
<Panel variant="projects"
tourTarget="entity-agents"
title={
<div className={headerClasses}>
<div className="flex items-center gap-2">
@ -156,9 +180,10 @@ export function EntityList({
</Button>
</div>
}
maxHeight={MAX_SECTION_HEIGHTS.AGENTS}
maxHeight={calculateSectionHeight(SECTION_HEIGHT_PERCENTAGES.AGENTS)}
className="overflow-hidden flex-[50]"
>
<div className="flex flex-col">
<div className="flex flex-col h-full overflow-y-auto">
{agents.length > 0 ? (
<div className="space-y-1 pb-2">
{agents.map((agent, index) => (
@ -190,6 +215,7 @@ export function EntityList({
{/* Tools Panel */}
<Panel variant="projects"
tourTarget="entity-tools"
title={
<div className={headerClasses}>
<div className="flex items-center gap-2">
@ -210,7 +236,13 @@ export function EntityList({
<Button
variant="secondary"
size="sm"
onClick={() => onAddTool({})}
onClick={() => onAddTool({
mockTool: true,
parameters: {
type: 'object',
properties: {}
}
})}
className={`group ${buttonClasses}`}
showHoverContent={true}
hoverContent="Add Tool"
@ -220,9 +252,10 @@ export function EntityList({
</div>
</div>
}
maxHeight={MAX_SECTION_HEIGHTS.TOOLS}
maxHeight={calculateSectionHeight(SECTION_HEIGHT_PERCENTAGES.TOOLS)}
className="overflow-hidden flex-[30]"
>
<div className="flex flex-col">
<div className="flex flex-col h-full overflow-y-auto">
{tools.length > 0 ? (
<div className="space-y-1 pb-2">
{tools.map((tool, index) => (
@ -250,6 +283,7 @@ export function EntityList({
{/* Prompts Panel */}
<Panel variant="projects"
tourTarget="entity-prompts"
title={
<div className={headerClasses}>
<div className="flex items-center gap-2">
@ -268,9 +302,10 @@ export function EntityList({
</Button>
</div>
}
maxHeight={MAX_SECTION_HEIGHTS.PROMPTS}
maxHeight={calculateSectionHeight(SECTION_HEIGHT_PERCENTAGES.PROMPTS)}
className="overflow-hidden flex-[20]"
>
<div className="flex flex-col">
<div className="flex flex-col h-full overflow-y-auto">
{prompts.length > 0 ? (
<div className="space-y-1 pb-2">
{prompts.map((prompt, index) => (

View file

@ -1,5 +1,5 @@
"use client";
import React, { useReducer, Reducer, useState, useCallback, useEffect, useRef } from "react";
import React, { useReducer, Reducer, useState, useCallback, useEffect, useRef, createContext, useContext } from "react";
import { MCPServer, WithStringId } from "../../../lib/types/types";
import { Workflow } from "../../../lib/types/workflow_types";
import { WorkflowTool } from "../../../lib/types/workflow_types";
@ -15,6 +15,7 @@ import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownSection, Dropdown
import { PromptConfig } from "../entities/prompt_config";
import { EditableField } from "../../../lib/components/editable-field";
import { RelativeTime } from "@primer/react";
import { USE_PRODUCT_TOUR } from "@/app/lib/feature_flags";
import {
ResizableHandle,
@ -29,13 +30,14 @@ import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/i
import { CopyIcon, ImportIcon, Layers2Icon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon } from "lucide-react";
import { EntityList } from "./entity_list";
import { McpImportTools } from "./mcp_imports";
import { ProductTour } from "@/components/common/product-tour";
enablePatches();
const PANEL_RATIOS = {
entityList: 25, // Left panel
chatApp: 50, // Middle panel
copilot: 25 // Right panel
chatApp: 40, // Middle panel
copilot: 35 // Right panel
} as const;
interface StateItem {
@ -290,7 +292,7 @@ function reducer(state: State, action: Action): State {
name: newToolName,
description: "",
parameters: {
type: "object",
type: 'object',
properties: {},
},
mockTool: true,
@ -604,6 +606,8 @@ export function WorkflowEditor({
const [showCopilot, setShowCopilot] = useState(true);
const [copilotWidth, setCopilotWidth] = useState<number>(PANEL_RATIOS.copilot);
const [isMcpImportModalOpen, setIsMcpImportModalOpen] = useState(false);
const [isInitialState, setIsInitialState] = useState(true);
const [showTour, setShowTour] = useState(true);
console.log(`workflow editor chat key: ${state.present.chatKey}`);
@ -616,6 +620,20 @@ export function WorkflowEditor({
}
}, [state.present.workflow.projectId]);
// Reset initial state when user interacts with copilot or opens other menus
useEffect(() => {
if (state.present.selection !== null) {
setIsInitialState(false);
}
}, [state.present.selection]);
// Track copilot actions
useEffect(() => {
if (state.present.pendingChanges && state.present.workflow) {
setIsInitialState(false);
}
}, [state.present.workflow, state.present.pendingChanges]);
function handleSelectAgent(name: string) {
dispatch({ type: "select_agent", name });
}
@ -755,6 +773,10 @@ export function WorkflowEditor({
}
}, [state.present.workflow, state.present.pendingChanges, processQueue, state]);
function handlePlaygroundClick() {
setIsInitialState(false);
}
return <div className="flex flex-col h-full relative">
<div className="shrink-0 flex justify-between items-center pb-6">
<div className="workflow-version-selector flex items-center gap-1 px-2 text-gray-800 dark:text-gray-100">
@ -881,9 +903,7 @@ export function WorkflowEditor({
variant="solid"
size="lg"
onPress={() => setShowCopilot(!showCopilot)}
className="gap-2 px-6 bg-indigo-600 hover:bg-indigo-700 text-white relative overflow-hidden animate-pulse-subtle
before:absolute before:inset-0 before:bg-gradient-to-r before:from-transparent before:via-white/20 before:to-transparent
before:translate-x-[-200%] before:animate-shine before:duration-1000 font-semibold text-base"
className="gap-2 px-6 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-base"
startContent={<Sparkles size={20} />}
>
Copilot
@ -927,6 +947,8 @@ export function WorkflowEditor({
messageSubscriber={updateChatMessages}
mcpServerUrls={mcpServerUrls}
toolWebhookUrl={toolWebhookUrl}
isInitialState={isInitialState}
onPanelClick={handlePlaygroundClick}
/>
{state.present.selection?.type === "agent" && <AgentConfig
key={state.present.selection.name}
@ -981,11 +1003,18 @@ export function WorkflowEditor({
messages: chatMessages
} : undefined
}
isInitialState={isInitialState}
/>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
{USE_PRODUCT_TOUR && showTour && (
<ProductTour
projectId={state.present.workflow.projectId}
onComplete={() => setShowTour(false)}
/>
)}
<McpImportTools
projectId={state.present.workflow.projectId}
isOpen={isMcpImportModalOpen}

View file

@ -10,7 +10,7 @@ interface AppLayoutProps {
}
export default function AppLayout({ children, useRag = false, useAuth = false }: AppLayoutProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const pathname = usePathname();
const projectId = pathname.split('/')[2];

View file

@ -12,11 +12,12 @@ import {
FolderOpenIcon,
ChevronLeftIcon,
ChevronRightIcon,
Moon
Moon,
HelpCircle
} from "lucide-react";
import { getProjectConfig } from "@/app/actions/project_actions";
import { useTheme } from "@/app/providers/theme-provider";
import { USE_TESTING_FEATURE } from '@/app/lib/feature_flags';
import { USE_TESTING_FEATURE, USE_PRODUCT_TOUR } from '@/app/lib/feature_flags';
interface SidebarProps {
projectId: string;
@ -137,6 +138,7 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
}
`}
disabled={isDisabled}
data-tour-target={item.href === 'config' ? 'settings' : undefined}
>
<Icon
size={collapsed ? COLLAPSED_ICON_SIZE : EXPANDED_ICON_SIZE}
@ -179,6 +181,27 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
{/* Theme and Auth Controls */}
<div className="p-3 border-t border-zinc-100 dark:border-zinc-800 space-y-2">
{USE_PRODUCT_TOUR && !isProjectsRoute && (
<Tooltip content={collapsed ? "Take Tour" : ""} showArrow placement="right">
<button
onClick={() => {
localStorage.removeItem('user_product_tour_completed');
window.location.reload();
}}
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
`}
>
<HelpCircle size={COLLAPSED_ICON_SIZE} />
{!collapsed && <span>Take Tour</span>}
</button>
</Tooltip>
)}
<Tooltip content={collapsed ? "Appearance" : ""} showArrow placement="right">
<button
onClick={toggleTheme}

View file

@ -1,8 +1,6 @@
'use client';
import { Project } from "@/app/lib/types/project_types";
import { useEffect, useState, useRef } from "react";
import { z } from "zod";
import { createProject, createProjectFromPrompt } from "@/app/actions/project_actions";
import { useRouter } from 'next/navigation';
import clsx from 'clsx';

View file

@ -1,7 +1,6 @@
import { Project } from "@/app/lib/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';
import { XMarkIcon } from "@heroicons/react/24/outline";

View file

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

View file

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

View file

@ -14,22 +14,44 @@ type FlexibleMessage = {
// Add any other optional fields that might be needed
};
interface ComposeBoxCopilotProps {
handleUserMessage: (message: string) => void;
messages: any[];
loading: boolean;
disabled?: boolean;
initialFocus?: boolean;
shouldAutoFocus?: boolean;
onFocus?: () => void;
}
export function ComposeBoxCopilot({
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
}) {
loading,
disabled = false,
initialFocus = false,
shouldAutoFocus = false,
onFocus,
}: ComposeBoxCopilotProps) {
const [input, setInput] = useState('');
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const previousMessagesLength = useRef(messages.length);
// Handle initial focus
useEffect(() => {
if (initialFocus && textareaRef.current) {
textareaRef.current.focus();
}
}, [initialFocus]);
// Handle auto-focus when new messages arrive
useEffect(() => {
if (shouldAutoFocus && messages.length > previousMessagesLength.current && textareaRef.current) {
textareaRef.current.focus();
}
previousMessagesLength.current = messages.length;
}, [messages.length, shouldAutoFocus]);
function handleInput() {
const prompt = input.trim();
@ -47,12 +69,10 @@ export function ComposeBoxCopilot({
}
}
// focus on the input field only when there is at least one message
useEffect(() => {
if (messages.length > 0) {
inputRef.current?.focus();
}
}, [messages]);
const handleFocus = () => {
setIsFocused(true);
onFocus?.();
};
return (
<div className="relative group">
@ -68,11 +88,11 @@ export function ComposeBoxCopilot({
{/* Textarea */}
<div className="flex-1">
<Textarea
ref={inputRef}
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
onFocus={() => setIsFocused(true)}
onFocus={handleFocus}
onBlur={() => setIsFocused(false)}
disabled={disabled || loading}
placeholder="Type a message..."

View file

@ -0,0 +1,146 @@
import { useState, useRef, useEffect } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { Button, Spinner } from "@heroui/react";
interface ComposeBoxPlaygroundProps {
handleUserMessage: (message: string) => void;
messages: any[];
loading: boolean;
disabled?: boolean;
shouldAutoFocus?: boolean;
onFocus?: () => void;
}
export function ComposeBoxPlayground({
handleUserMessage,
messages,
loading,
disabled = false,
shouldAutoFocus = false,
onFocus,
}: ComposeBoxPlaygroundProps) {
const [input, setInput] = useState('');
const [isFocused, setIsFocused] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const previousMessagesLength = useRef(messages.length);
// Handle auto-focus when new messages arrive
useEffect(() => {
if (shouldAutoFocus && messages.length > previousMessagesLength.current && textareaRef.current) {
textareaRef.current.focus();
}
previousMessagesLength.current = messages.length;
}, [messages.length, shouldAutoFocus]);
function handleInput() {
const prompt = input.trim();
if (!prompt) {
return;
}
setInput('');
handleUserMessage(prompt);
}
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleInput();
}
};
const handleFocus = () => {
setIsFocused(true);
onFocus?.();
};
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={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
onFocus={handleFocus}
onBlur={() => setIsFocused(false)}
disabled={disabled || loading}
placeholder="Type a message..."
autoResize={true}
maxHeight={120}
className={`
!min-h-0
!border-0 !shadow-none !ring-0
bg-transparent
resize-none
overflow-y-auto
[&::-webkit-scrollbar]:w-1
[&::-webkit-scrollbar-track]:bg-transparent
[&::-webkit-scrollbar-thumb]:bg-gray-300
[&::-webkit-scrollbar-thumb]:dark:bg-[#2a2d31]
[&::-webkit-scrollbar-thumb]:rounded-full
placeholder:text-gray-500 dark:placeholder:text-gray-400
`}
/>
</div>
{/* Send button */}
<Button
size="sm"
isIconOnly
disabled={disabled || loading || !input.trim()}
onPress={handleInput}
className={`
transition-all duration-200
${input.trim()
? 'bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:hover:bg-indigo-800/60 dark:text-indigo-300'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'
}
scale-100 hover:scale-105 active:scale-95
disabled:opacity-50 disabled:scale-95
hover:shadow-md dark:hover:shadow-indigo-950/10
mb-0.5
`}
>
{loading ? (
<Spinner size="sm" color={input.trim() ? "primary" : "default"} />
) : (
<SendIcon
size={16}
className={`transform transition-transform ${isFocused ? 'translate-x-0.5' : ''}`}
/>
)}
</Button>
</div>
</div>
);
}
// Custom SendIcon component for better visual alignment
function SendIcon({ size, className }: { size: number, className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M22 2L11 13" />
<path d="M22 2L15 22L11 13L2 9L22 2Z" />
</svg>
);
}

View file

@ -1,5 +1,6 @@
import clsx from "clsx";
import { Sparkles } from "lucide-react";
import { SHOW_COPILOT_MARQUEE } from "@/app/lib/feature_flags";
export function ActionButton({
icon = null,
@ -34,8 +35,11 @@ interface PanelProps {
actions?: React.ReactNode;
children: React.ReactNode;
maxHeight?: string;
variant?: 'default' | 'copilot' | 'projects';
variant?: 'default' | 'copilot' | 'playground' | 'projects';
showWelcome?: boolean;
className?: string;
onClick?: () => void;
tourTarget?: string;
}
export function Panel({
@ -46,32 +50,43 @@ export function Panel({
maxHeight,
variant = 'default',
showWelcome = true,
className,
onClick,
tourTarget,
}: PanelProps) {
return <div className={clsx(
"flex flex-col overflow-hidden rounded-xl border relative",
"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}
return <div
className={clsx(
"flex flex-col overflow-hidden rounded-xl border relative",
variant === 'copilot' ? "border-blue-200 dark:border-blue-800" : "border-zinc-200 dark:border-zinc-800",
"bg-white dark:bg-zinc-900",
maxHeight ? "max-h-[var(--panel-height)]" : "h-full",
className
)}
style={{
'--panel-height': maxHeight
} as React.CSSProperties}
onClick={onClick}
data-tour-target={tourTarget}
>
{variant === 'copilot' && showWelcome && (
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none -mt-16">
<Sparkles className="w-32 h-32 text-blue-400/40 dark:text-blue-500/25 animate-sparkle" />
<div className="relative mt-8 max-w-full px-8">
<div className="font-mono text-sm whitespace-nowrap text-blue-400/60 dark:text-blue-500/40 font-small inline-flex">
<div className="overflow-hidden w-0 animate-typing">What can I help you build?</div>
<div className="border-r-2 border-blue-400 dark:border-blue-500 animate-cursor">&nbsp;</div>
{SHOW_COPILOT_MARQUEE && (
<div className="relative mt-8 max-w-full px-8">
<div className="font-mono text-sm whitespace-nowrap text-blue-400/60 dark:text-blue-500/40 font-small inline-flex">
<div className="overflow-hidden w-0 animate-typing">What can I help you build?</div>
<div className="border-r-2 border-blue-400 dark:border-blue-500 animate-cursor">&nbsp;</div>
</div>
</div>
</div>
)}
</div>
)}
<div className={clsx(
"shrink-0 border-b border-zinc-100 dark:border-zinc-800 relative",
variant === 'projects' ? "flex flex-col gap-3 px-4 py-3" : "flex items-center justify-between px-4 py-3"
)}>
<div
className={clsx(
"shrink-0 border-b border-zinc-100 dark:border-zinc-800 relative",
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">

View file

@ -0,0 +1,251 @@
import { useFloating, offset, flip, shift, arrow, FloatingArrow, FloatingPortal, autoUpdate } from '@floating-ui/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { XIcon } from 'lucide-react';
interface TourStep {
target: string;
content: string;
title: string;
}
const TOUR_STEPS: TourStep[] = [
{
target: 'copilot',
content: 'Build agents with the help of copilot.\nThis might take a minute.',
title: 'Step 1/6'
},
{
target: 'playground',
content: 'Test your assistant in the playground.\nDebug tool calls and responses.',
title: 'Step 2/6'
},
{
target: 'entity-agents',
content: 'Manage your agents.\nSpecify instructions, examples and tool usage.',
title: 'Step 3/6'
},
{
target: 'entity-tools',
content: 'Create your own tools, import MCP tools or use existing ones.\nMock tools for quick testing.',
title: 'Step 4/6'
},
{
target: 'entity-prompts',
content: 'Manage prompts which will be used by agents.\nConfigure greeting message.',
title: 'Step 5/6'
},
{
target: 'settings',
content: 'Configure project settings\nGet API keys, configure tool webhooks.',
title: 'Step 6/6'
}
];
function TourBackdrop({ targetElement }: { targetElement: Element | null }) {
const [rect, setRect] = useState<DOMRect | null>(null);
const isPanelTarget = targetElement?.getAttribute('data-tour-target') &&
['entity-agents', 'entity-tools', 'entity-prompts', 'copilot', 'playground'].includes(
targetElement.getAttribute('data-tour-target')!
);
// Use smaller padding for panels to prevent overlap
const padding = isPanelTarget ? 12 : 8;
useEffect(() => {
if (targetElement) {
const updateRect = () => {
const newRect = targetElement.getBoundingClientRect();
setRect(newRect);
};
updateRect();
window.addEventListener('resize', updateRect);
window.addEventListener('scroll', updateRect);
return () => {
window.removeEventListener('resize', updateRect);
window.removeEventListener('scroll', updateRect);
};
}
}, [targetElement]);
if (!rect) return null;
return (
<>
{/* Top */}
<div className="fixed z-[100] backdrop-blur-sm bg-black/30" style={{
top: 0,
left: 0,
right: 0,
height: Math.max(0, rect.top - padding)
}} />
{/* Left */}
<div className="fixed z-[100] backdrop-blur-sm bg-black/30" style={{
top: Math.max(0, rect.top - padding),
left: 0,
width: Math.max(0, rect.left - padding),
height: rect.height + padding * 2
}} />
{/* Right */}
<div className="fixed z-[100] backdrop-blur-sm bg-black/30" style={{
top: Math.max(0, rect.top - padding),
left: rect.right + padding,
right: 0,
height: rect.height + padding * 2
}} />
{/* Bottom */}
<div className="fixed z-[100] backdrop-blur-sm bg-black/30" style={{
top: rect.bottom + padding,
left: 0,
right: 0,
bottom: 0
}} />
{/* Highlight border around target */}
<div
className="fixed z-[100] border-2 border-white/50 rounded-lg pointer-events-none"
style={{
top: rect.top - padding,
left: rect.left - padding,
width: rect.width + padding * 2,
height: rect.height + padding * 2,
}}
/>
</>
);
}
export function ProductTour({
projectId,
onComplete
}: {
projectId: string;
onComplete: () => void;
}) {
const [currentStep, setCurrentStep] = useState(0);
const [shouldShow, setShouldShow] = useState(true);
const arrowRef = useRef(null);
// Check if tour has been completed by the user
useEffect(() => {
const tourCompleted = localStorage.getItem('user_product_tour_completed');
if (tourCompleted) {
setShouldShow(false);
}
}, []);
const currentTarget = TOUR_STEPS[currentStep].target;
const targetElement = document.querySelector(`[data-tour-target="${currentTarget}"]`);
// Determine if the target is a panel that should have the hint on the side
const isPanelTarget = ['entity-agents', 'entity-tools', 'entity-prompts', 'copilot', 'playground'].includes(currentTarget);
const { x, y, strategy, refs, context, middlewareData } = useFloating({
placement: isPanelTarget ? 'right' : 'top',
middleware: [
offset(16),
flip({
fallbackPlacements: isPanelTarget ? ['left', 'top', 'bottom'] : ['bottom', 'left', 'right'],
padding: 16
}),
shift({
padding: 16,
crossAxis: true,
mainAxis: true
}),
arrow({ element: arrowRef })
],
whileElementsMounted: autoUpdate
});
// Update reference element when step changes
useEffect(() => {
if (targetElement) {
refs.setReference(targetElement);
}
}, [currentStep, targetElement, refs]);
const handleNext = useCallback(() => {
if (currentStep < TOUR_STEPS.length - 1) {
setCurrentStep(prev => prev + 1);
} else {
// Mark tour as completed for the user
localStorage.setItem('user_product_tour_completed', 'true');
// Clean up any old project-specific tour flags
localStorage.removeItem(`project_tour_${projectId}`);
setShouldShow(false);
onComplete();
}
}, [currentStep, projectId, onComplete]);
const handleSkip = useCallback(() => {
// Mark tour as completed for the user
localStorage.setItem('user_product_tour_completed', 'true');
// Clean up any old project-specific tour flags
localStorage.removeItem(`project_tour_${projectId}`);
setShouldShow(false);
onComplete();
}, [projectId, onComplete]);
if (!shouldShow) return null;
// Get the actual placement after middleware calculations
const actualPlacement = middlewareData.flip?.overflows?.length ?
middlewareData.flip?.overflows[0].placement :
isPanelTarget ? 'right' : 'top';
return (
<FloatingPortal>
<TourBackdrop targetElement={targetElement} />
<div
ref={refs.setFloating}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
width: 'max-content',
maxWidth: '90vw',
zIndex: 101,
}}
className="bg-white dark:bg-zinc-800 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-700 p-4 animate-in fade-in duration-200"
>
<button
onClick={handleSkip}
className="absolute right-2 top-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<XIcon size={16} />
</button>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{TOUR_STEPS[currentStep].title}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3 whitespace-pre-line">
{TOUR_STEPS[currentStep].content}
</div>
<div className="flex justify-between items-center">
<button
onClick={handleSkip}
className="text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
Skip tour
</button>
<button
onClick={handleNext}
className="px-4 py-1.5 bg-indigo-600 text-white rounded-md text-sm font-medium hover:bg-indigo-700"
>
{currentStep === TOUR_STEPS.length - 1 ? 'Finish' : 'Next'}
</button>
</div>
<FloatingArrow
ref={arrowRef}
context={context}
fill="white"
className="dark:fill-zinc-800"
/>
</div>
</FloatingPortal>
);
}

View file

@ -12,6 +12,7 @@
"@auth0/nextjs-auth0": "^3.5.0",
"@aws-sdk/client-s3": "^3.743.0",
"@aws-sdk/s3-request-presigner": "^3.743.0",
"@floating-ui/react": "^0.27.7",
"@google/generative-ai": "^0.21.0",
"@heroicons/react": "^2.2.0",
"@heroui/react": "2.7.4",
@ -2255,6 +2256,59 @@
"node": ">=14"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.7.tgz",
"integrity": "sha512-5V9pwFeiv+95Jlowq/7oiGISSrdXMTs2jfoSy8k+WM6oI/Skm1WWjPdJWeporN2O4UGcsaCJdirKffKayMoPgw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.9",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@formatjs/ecma402-abstract": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.0.0.tgz",
@ -21679,6 +21733,12 @@
"vue": ">=3.2.26 < 4"
}
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.5.tgz",

View file

@ -19,6 +19,7 @@
"@auth0/nextjs-auth0": "^3.5.0",
"@aws-sdk/client-s3": "^3.743.0",
"@aws-sdk/s3-request-presigner": "^3.743.0",
"@floating-ui/react": "^0.27.7",
"@google/generative-ai": "^0.21.0",
"@heroicons/react": "^2.2.0",
"@heroui/react": "2.7.4",

View file

@ -282,6 +282,54 @@ async def run_turn_streamed(
print('-'*50)
print(f"Found usage information. Updated cumulative tokens: {tokens_used}")
print('-'*50)
# Handle ResponseFunctionWebSearch specifically
if hasattr(event, 'data') and hasattr(event.data, 'raw_item'):
raw_item = event.data.raw_item
# Check if it's a web search call
if (hasattr(raw_item, 'type') and raw_item.type == 'web_search_call') or (
isinstance(raw_item, dict) and raw_item.get('type') == 'web_search_call'
):
# Get call_id safely, regardless of structure
call_id = None
if hasattr(raw_item, 'id'):
call_id = raw_item.id
elif isinstance(raw_item, dict) and 'id' in raw_item:
call_id = raw_item['id']
else:
call_id = str(uuid.uuid4())
# Get status safely
status = 'unknown'
if hasattr(raw_item, 'status'):
status = raw_item.status
elif isinstance(raw_item, dict) and 'status' in raw_item:
status = raw_item['status']
# Emit a tool call for web search
message = {
'content': None,
'role': 'assistant',
'sender': current_agent.name if current_agent else None,
'tool_calls': [{
'function': {
'name': 'web_search',
'arguments': json.dumps({
'search_id': call_id,
'status': status
})
},
'id': call_id,
'type': 'function'
}],
'tool_call_id': None,
'tool_name': None,
'response_type': 'internal'
}
print("Yielding web search raw response message: ", message)
yield ('message', message)
continue
# Update current agent when it changes
@ -334,45 +382,128 @@ async def run_turn_streamed(
elif event.type == "run_item_stream_event":
current_agent = event.item.agent
if event.item.type == "tool_call_item":
message = {
'content': None,
'role': 'assistant',
'sender': current_agent.name if current_agent else None,
'tool_calls': [{
'function': {
'name': event.item.raw_item.name,
'arguments': event.item.raw_item.arguments
},
'id': event.item.raw_item.call_id,
'type': 'function'
}],
'tool_call_id': None,
'tool_name': None,
'response_type': 'internal'
}
print("Yielding message: ", message)
yield ('message', message)
# Check if it's a ResponseFunctionWebSearch object
if hasattr(event.item.raw_item, 'type') and event.item.raw_item.type == 'web_search_call':
call_id = event.item.raw_item.id if hasattr(event.item.raw_item, 'id') else str(uuid.uuid4())
message = {
'content': None,
'role': 'assistant',
'sender': current_agent.name if current_agent else None,
'tool_calls': [{
'function': {
'name': 'web_search',
'arguments': json.dumps({
'search_id': call_id
})
},
'id': call_id,
'type': 'function'
}],
'tool_call_id': None,
'tool_name': None,
'response_type': 'internal'
}
print("Yielding message: ", message)
yield ('message', message)
elif event.item.type == "tool_call_output_item":
message = {
'content': str(event.item.output),
result_message = {
'content': "Web search done",
'role': 'tool',
'sender': None,
'tool_calls': None,
'tool_call_id': event.item.raw_item['call_id'],
'tool_name': event.item.raw_item.get('name', None),
'tool_call_id': call_id,
'tool_name': 'web_search',
'response_type': 'internal'
}
}
print("Yielding web search results: ", result_message)
yield ('message', result_message)
else:
# Handle normal tool calls
message = {
'content': None,
'role': 'assistant',
'sender': current_agent.name if current_agent else None,
'tool_calls': [{
'function': {
'name': event.item.raw_item.name,
'arguments': event.item.raw_item.arguments
},
'id': event.item.raw_item.call_id,
'type': 'function'
}],
'tool_call_id': None,
'tool_name': None,
'response_type': 'internal'
}
print("Yielding message: ", message)
yield ('message', message)
elif event.item.type == "tool_call_output_item":
# Check if it's a web search result
if isinstance(event.item.raw_item, dict) and event.item.raw_item.get('type') == 'web_search_results':
call_id = event.item.raw_item.get('search_id', event.item.raw_item.get('id', str(uuid.uuid4())))
message = {
'content': str(event.item.output),
'role': 'tool',
'sender': None,
'tool_calls': None,
'tool_call_id': call_id,
'tool_name': 'web_search',
'response_type': 'internal'
}
else:
# Safe extraction of call_id and name
call_id = None
tool_name = None
# Handle different types of raw_item
if isinstance(event.item.raw_item, dict):
call_id = event.item.raw_item.get('call_id')
tool_name = event.item.raw_item.get('name')
elif hasattr(event.item.raw_item, 'call_id'):
call_id = event.item.raw_item.call_id
if hasattr(event.item.raw_item, 'name'):
tool_name = event.item.raw_item.name
message = {
'content': str(event.item.output),
'role': 'tool',
'sender': None,
'tool_calls': None,
'tool_call_id': call_id,
'tool_name': tool_name,
'response_type': 'internal'
}
print("Yielding message: ", message)
yield ('message', message)
elif event.item.type == "message_output_item":
content = ""
url_citations = []
# Extract text content and any URL citations
if hasattr(event.item.raw_item, 'content'):
for content_item in event.item.raw_item.content:
# Handle text content
if hasattr(content_item, 'text'):
content += content_item.text
# Extract URL citations if present
if hasattr(content_item, 'annotations'):
for annotation in content_item.annotations:
if hasattr(annotation, 'type') and annotation.type == 'url_citation':
citation = {
'url': annotation.url if hasattr(annotation, 'url') else '',
'title': annotation.title if hasattr(annotation, 'title') else '',
'start_index': annotation.start_index if hasattr(annotation, 'start_index') else 0,
'end_index': annotation.end_index if hasattr(annotation, 'end_index') else 0
}
url_citations.append(citation)
# Create message with URL citations if they exist
message = {
'content': content,
'role': 'assistant',
@ -382,9 +513,97 @@ async def run_turn_streamed(
'tool_name': None,
'response_type': 'external'
}
# Add citations if any were found
if url_citations:
message['citations'] = url_citations
print("Yielding message: ", message)
yield ('message', message)
# Handle web search function call events
elif event.item.type == "web_search_call_item" or (hasattr(event.item, 'raw_item') and hasattr(event.item.raw_item, 'type') and event.item.raw_item.type == 'web_search_call'):
# Extract web search call ID if available
call_id = None
if hasattr(event.item.raw_item, 'id'):
call_id = event.item.raw_item.id
message = {
'content': None,
'role': 'assistant',
'sender': current_agent.name if current_agent else None,
'tool_calls': [{
'function': {
'name': 'web_search',
'arguments': json.dumps({
'search_id': call_id
})
},
'id': call_id or str(uuid.uuid4()),
'type': 'function'
}],
'tool_call_id': None,
'tool_name': None,
'response_type': 'internal'
}
print("Yielding web search message: ", message)
yield ('message', message)
# Handle web search results
elif event.item.type == "web_search_results_item" or (
hasattr(event.item, 'raw_item') and (
(hasattr(event.item.raw_item, 'type') and event.item.raw_item.type == 'web_search_results') or
(isinstance(event.item.raw_item, dict) and event.item.raw_item.get('type') == 'web_search_results')
)
):
# Extract call_id safely
call_id = None
raw_item = event.item.raw_item
# Try several ways to get the search_id or id
if hasattr(raw_item, 'search_id'):
call_id = raw_item.search_id
elif isinstance(raw_item, dict) and 'search_id' in raw_item:
call_id = raw_item['search_id']
elif hasattr(raw_item, 'id'):
call_id = raw_item.id
elif isinstance(raw_item, dict) and 'id' in raw_item:
call_id = raw_item['id']
else:
call_id = str(uuid.uuid4())
# Extract results content safely
results = {}
# Try event.item.output first
if hasattr(event.item, 'output'):
results = event.item.output
# Then try raw_item.results
elif hasattr(raw_item, 'results'):
results = raw_item.results
elif isinstance(raw_item, dict) and 'results' in raw_item:
results = raw_item['results']
# Format the results for output
results_str = ""
try:
results_str = json.dumps(results) if results else ""
except Exception as e:
print(f"Error serializing results: {str(e)}")
results_str = str(results)
message = {
'content': results_str,
'role': 'tool',
'sender': None,
'tool_calls': None,
'tool_call_id': call_id,
'tool_name': 'web_search',
'response_type': 'internal'
}
print("Yielding web search results: ", message)
yield ('message', message)
print(f"\n{'='*50}\n")
# After all events are processed, set final state

View file

@ -13,7 +13,7 @@ from .helpers.instructions import (
add_rag_instructions_to_agent
)
from agents import Agent as NewAgent, Runner, FunctionTool, RunContextWrapper, ModelSettings
from agents import Agent as NewAgent, Runner, FunctionTool, RunContextWrapper, ModelSettings, WebSearchTool
# Add import for OpenAI functionality
from src.utils.common import common_logger as logger, generate_openai_output
from typing import Any
@ -221,14 +221,17 @@ def get_agents(agent_configs, tool_configs, complete_request):
"type": "function",
"function": tool_config
})
tool = FunctionTool(
name=tool_name,
description=tool_config["description"],
params_json_schema=tool_config["parameters"],
strict_json_schema=False,
if tool_name == "web_search":
tool = WebSearchTool()
else:
tool = FunctionTool(
name=tool_name,
description=tool_config["description"],
params_json_schema=tool_config["parameters"],
strict_json_schema=False,
on_invoke_tool=lambda ctx, args, _tool_name=tool_name, _tool_config=tool_config, _complete_request=complete_request:
catch_all(ctx, args, _tool_name, _tool_config, _complete_request)
)
)
new_tools.append(tool)
logger.debug(f"Added tool {tool_name} to agent {agent_config['name']}")
print(f"Added tool {tool_name} to agent {agent_config['name']}")