diff --git a/apps/browser/app/page.tsx b/apps/browser/app/page.tsx index 9b31459e..a49659d1 100644 --- a/apps/browser/app/page.tsx +++ b/apps/browser/app/page.tsx @@ -47,9 +47,9 @@ function Dojo({ params }: { params: { id?: string } }) { const { mutateAsync: createChat } = useCreateChat(); const { mutateAsync: updateChatTitle } = useUpdateChatTitle(); - const { logs, isConnected, isStarting, isRunning, handleStart } = useEvoService( + const { logs, isConnected, isStarting, isRunning, handleStart, status } = useEvoService( chatId, - isAuthenticated + isAuthenticated, ); const workspaceUploadUpdate = useWorkspaceUploadUpdate(); @@ -67,7 +67,7 @@ function Dojo({ params }: { params: { id?: string } }) { // reset workspace and user files on chatId change setWorkspace(undefined); setWorkspaceFiles([]); - } + }; const handleCreateNewChat = async () => { const id = uuid(); @@ -110,7 +110,7 @@ function Dojo({ params }: { params: { id?: string } }) { setNewGoalSubmitted({ goal, - chatId: goalChatId + chatId: goalChatId, }); }; @@ -139,7 +139,8 @@ function Dojo({ params }: { params: { id?: string } }) { // Set isChatLoading to true when evoService is connected // and the current chatId matches the current goal (if present) useEffect(() => { - const chatIdMatches = !newGoalSubmitted || chatId === newGoalSubmitted.chatId; + const chatIdMatches = + !newGoalSubmitted || chatId === newGoalSubmitted.chatId; if (isChatLoading && isConnected && chatIdMatches) { setIsChatLoading(false); } @@ -186,6 +187,7 @@ function Dojo({ params }: { params: { id?: string } }) { workspaceUploadUpdate(workspace, uploads); } }} + status={status} /> ) : (
diff --git a/apps/browser/components/Chat.tsx b/apps/browser/components/Chat.tsx index 03130697..747465dc 100644 --- a/apps/browser/components/Chat.tsx +++ b/apps/browser/components/Chat.tsx @@ -26,6 +26,7 @@ export interface ChatLog { content?: string; user: string; color?: string; + created_at?: string } export interface ChatProps { @@ -34,6 +35,7 @@ export interface ChatProps { isRunning: boolean; onGoalSubmit: (goal: string) => Promise; onUpload: (upload: InMemoryFile[]) => void; + status: string | undefined; } const Chat: React.FC = ({ @@ -42,6 +44,7 @@ const Chat: React.FC = ({ isRunning, onGoalSubmit, onUpload, + status }: ChatProps) => { const [{ id: chatId, name: chatName }] = useAtom(chatInfoAtom); const [showDisclaimer, setShowDisclaimer] = useAtom(showDisclaimerAtom); @@ -87,7 +90,7 @@ const Chat: React.FC = ({ {shouldShowExamplePrompts ? ( ) : ( - + )}
(null); const [isAtBottom, setIsAtBottom] = useState(true); const { data: session } = useSession(); @@ -24,6 +37,15 @@ export default function ChatLogs(props: ChatLogsProps) { }); }; + const sanitizedLogs = useMemo(() => { + return sanitizeLogs(logs); + }, [logs]); + + const [logsDetails, setLogsDetails] = useState<{ + open: boolean; + index: number; + }>({ open: false, index: 0 }); + const handleScroll = useCallback(() => { // Detect if the user is at the bottom of the list const container = listContainerRef.current; @@ -56,10 +78,6 @@ export default function ChatLogs(props: ChatLogsProps) { }; }, [handleScroll]); - if (isAtBottom) { - scrollToBottom(); - } - return ( <>
@@ -70,58 +88,108 @@ export default function ChatLogs(props: ChatLogsProps) { onScroll={handleScroll} className="w-full flex-1 items-center space-y-6 overflow-y-auto overflow-x-clip px-2 py-3 text-left [scrollbar-gutter:stable]" > - {logs.map((msg, index, logs) => { - const isEvo = msg.user === "evo"; - const previousMessage = logs[index - 1]; + {sanitizedLogs.map((msg, index) => { return (
-
-
- {isEvo && previousMessage?.user === "user" ? ( - - ) : !isEvo ? ( - <> - {session?.user.image && session?.user.email ? ( - - ) : !session?.user.email ? ( - - ) : ( -
- )} - - ) : null} -
-
- {index === 0 || previousMessage?.user !== msg.user ? ( -
- {session?.user.name && !msg.user.includes("evo") - ? session?.user.name - : msg.user.charAt(0).toUpperCase() + msg.user.slice(1)} +
+ <> + {session?.user.image && session?.user.email ? ( + + ) : ( +
+ )} + +
+ <> +
+ + {session?.user.name ? session?.user.name : "User"} +
- ) : null} +
- {msg.title.toString()} - - {msg.content?.toString() ?? ""} - + {msg.userMessage} +
+
+
+
+
+ +
+ <> +
+ Evo + {!isRunning && ( + + )} +
+ + {msg.evoMessage && ( + + {msg.evoMessage} + + )} + {!msg.evoMessage && + isRunning && + sanitizedLogs.length - 1 === index && ( + <> +
+ +
+
+ setLogsDetails({ + open: true, + index, + }) + } + > + {status} +
+
+
+ + )} + {!msg.evoMessage && !isRunning && ( + + It seems I have been interrupted, please try again. + + )}
- {isEvo && isRunning && index === logs.length - 1 && ( -
- -
- )}
); })}
+ setLogsDetails({ ...logsDetails, open: false })} + logs={sanitizedLogs[logsDetails.index]} + /> ); } diff --git a/apps/browser/components/modals/ChatDetails.tsx b/apps/browser/components/modals/ChatDetails.tsx new file mode 100644 index 00000000..6b92d649 --- /dev/null +++ b/apps/browser/components/modals/ChatDetails.tsx @@ -0,0 +1,100 @@ +import { useRef, useState } from "react"; +import Modal from "./ModalBase"; +import ReactMarkdown from "react-markdown"; +import { CaretUp } from "@phosphor-icons/react"; +import { MessageSet } from "@/lib/utils/sanitizeLogsDetails"; +import clsx from "clsx"; + +interface ChatDetailsProps { + isOpen: boolean; + onClose: () => void; + logs: MessageSet; +} + +export default function ChatDetails({ + isOpen, + onClose, + logs, +}: ChatDetailsProps) { + const [expandedStep, setExpandedStep] = useState(null); + const contentRefs = useRef<{ [index: number]: HTMLDivElement | null }>({}); + + const toggleStep = (step: string, index: number) => { + const currentEl = contentRefs.current[index]; + if (!currentEl) return; + + if (expandedStep !== null && expandedStep !== step) { + const currentIndex = Object.keys(logs.details).indexOf(expandedStep); + const currentExpandedEl = contentRefs.current[currentIndex]; + if (currentExpandedEl) currentExpandedEl.style.height = "0px"; + } + + if (expandedStep === step) { + currentEl.style.height = "0px"; + setExpandedStep(null); + } else { + currentEl.style.height = `${currentEl.scrollHeight}px`; + setExpandedStep(step); + } + }; + + return ( + { + setExpandedStep(null); + onClose(); + }} + title="Details" + > +
+ {logs && + Object.entries(logs.details).map( + ([stepTitle, stepDetails], index) => ( +
+ +
{ + contentRefs.current[index] = el; + }} + className={clsx( + "step h-0 overflow-hidden transition-[height] duration-500 ease-in-out" + )} + > + {stepDetails.map((detail, detailIndex) => ( +
+ {detail} +
+ ))} +
+
+ ) + )} +
+
+ ); +} diff --git a/apps/browser/lib/hooks/useEvoService.ts b/apps/browser/lib/hooks/useEvoService.ts index 0aa82c0c..b002be88 100644 --- a/apps/browser/lib/hooks/useEvoService.ts +++ b/apps/browser/lib/hooks/useEvoService.ts @@ -30,6 +30,7 @@ export const useEvoService = ( isConnected: boolean; isStarting: boolean; isRunning: boolean; + status: string | undefined; handleStart: (goal: string) => Promise; } => { const supabase = useSupabaseClient(); @@ -48,6 +49,7 @@ export const useEvoService = ( const [isStarting, setIsStarting] = useState(false); const [isRunning, setIsRunning] = useState(false); const [chatLog, setChatLogState] = useState([]); + const [status, setStatus] = useState(undefined); // Mutations const { mutateAsync: addChatLog } = useAddChatLog(); @@ -101,6 +103,7 @@ export const useEvoService = ( setIsRunning, setChatLog, setWorkspace, + setStatus, onGoalCapReached: () => { setCapReached(true); setSettingsModalOpen(true); @@ -226,6 +229,7 @@ export const useEvoService = ( isConnected, isStarting, isRunning, - handleStart + handleStart, + status }; }; diff --git a/apps/browser/lib/services/evo/EvoThread.ts b/apps/browser/lib/services/evo/EvoThread.ts index a14190db..f1f19730 100644 --- a/apps/browser/lib/services/evo/EvoThread.ts +++ b/apps/browser/lib/services/evo/EvoThread.ts @@ -6,7 +6,7 @@ import { ChatLogType, ChatMessage, Workspace, - InMemoryWorkspace + InMemoryWorkspace, } from "@evo-ninja/agents"; export interface EvoThreadConfig { @@ -14,12 +14,16 @@ export interface EvoThreadConfig { loadChatLog: (chatId: string) => Promise; loadWorkspace: (chatId: string) => Promise; onChatLogAdded: (chatLog: ChatLog) => Promise; - onMessagesAdded: (type: ChatLogType, messages: ChatMessage[]) => Promise; + onMessagesAdded: ( + type: ChatLogType, + messages: ChatMessage[] + ) => Promise; onVariableSet: (key: string, value: string) => Promise; } export interface EvoThreadState { - goal?: string; + goal: string | undefined; + status: string | undefined; isRunning: boolean; isLoading: boolean; logs: ChatLog[]; @@ -27,6 +31,7 @@ export interface EvoThreadState { } export interface EvoThreadCallbacks { + setStatus: (status?: string) => void; setIsRunning: (value: boolean) => void; setChatLog: (chatLog: ChatLog[]) => void; setWorkspace: (workspace: Workspace) => void; @@ -41,6 +46,8 @@ export interface EvoThreadStartOptions { } const INIT_STATE: EvoThreadState = { + goal: undefined, + status: undefined, isRunning: false, isLoading: false, logs: [], @@ -72,6 +79,7 @@ export class EvoThread { } // Dispatch init values + this._callbacks.setStatus(INIT_STATE.status); this._callbacks.setIsRunning(INIT_STATE.isRunning); this._callbacks.setChatLog(INIT_STATE.logs); this._callbacks.setWorkspace(INIT_STATE.workspace); @@ -92,6 +100,7 @@ export class EvoThread { } // Send current state to newly connected callbacks + this._callbacks.setStatus(this._state.status); this._callbacks.setIsRunning(this._state.isRunning); this._callbacks.setChatLog(this._state.logs); this._callbacks.setWorkspace(this._state.workspace); @@ -139,12 +148,11 @@ export class EvoThread { options.openAiApiKey, this._config.onMessagesAdded, this._config.onVariableSet, - (chatLog) => - this.onChatLog(chatLog), - () => - this._callbacks?.onGoalCapReached(), - (error) => - this._callbacks?.onError(error) + (chatLog) => this.onChatLog(chatLog), + (status) => this.onStatusUpdate(status), + () => this._callbacks?.onGoalCapReached(), + // onError + (error) => this._callbacks?.onError(error) ); if (!evo) { @@ -202,6 +210,11 @@ export class EvoThread { await this._config.onChatLogAdded(chatLog); } + private onStatusUpdate(status: string): void { + this._state.status = status; + this._callbacks?.setStatus(status); + } + private async runEvo(evo: Evo, goal: string): Promise { const iterator = evo.run({ goal }); @@ -213,32 +226,29 @@ export class EvoThread { let stepCounter = 1; while (this._state.isRunning) { + await this.onChatLog({ + title: `## Step ${stepCounter}`, + user: "evo", + }); const response = await iterator.next(); this._callbacks?.setWorkspace(this._state.workspace); if (response.done) { - const actionTitle = response.value.value.title; - if ( - actionTitle.includes("onGoalAchieved") || - actionTitle === "SUCCESS" - ) { - await this.onChatLog({ - title: "## Goal Achieved", + // If value is not present is because an unhandled error has happened in Evo + if ("value" in response.value) { + const isSuccess = response.value.value.type === "success"; + const message = { + title: `## Goal has ${isSuccess ? "" : "not"} been achieved`, user: "evo", - }); + }; + await this.onChatLog(message); } - this.setIsRunning(false); evo?.reset(); break; } - await this.onChatLog({ - title: `## Step ${stepCounter}`, - user: "evo", - }); - if (!response.done) { const evoMessage = { title: `### Action executed:\n${response.value.title}`, diff --git a/apps/browser/lib/services/evo/createEvoInstance.ts b/apps/browser/lib/services/evo/createEvoInstance.ts index c1dd2c0a..8c648a87 100644 --- a/apps/browser/lib/services/evo/createEvoInstance.ts +++ b/apps/browser/lib/services/evo/createEvoInstance.ts @@ -30,6 +30,7 @@ export function createEvoInstance( onMessagesAdded: (type: ChatLogType, messages: ChatMessage[]) => Promise, onVariableSet: (key: string, value: string) => Promise, onChatLog: (chatLog: ChatLog) => Promise, + onStatusUpdate: (status: string) => void, onGoalCapReached: () => void, onError: (error: string) => void ): Evo | undefined { @@ -41,6 +42,15 @@ export function createEvoInstance( title: message, }); }, + onNotice: (msg: string) => { + onStatusUpdate(msg); + return Promise.resolve(); + }, + onSuccess: (msg: string) => + onChatLog({ + user: "evo", + title: msg, + }) }); const logger = new Logger([browserLogger, new ConsoleLogger()], { promptUser: () => Promise.resolve("N/A"), diff --git a/apps/browser/lib/sys/logger/BrowserLogger.ts b/apps/browser/lib/sys/logger/BrowserLogger.ts index e78ba681..d99c35c1 100644 --- a/apps/browser/lib/sys/logger/BrowserLogger.ts +++ b/apps/browser/lib/sys/logger/BrowserLogger.ts @@ -1,29 +1,51 @@ import { ILogger } from "@evo-ninja/agent-utils"; +type LogLevel = "info" | "warning" | "error"; + export interface BrowserLoggerConfig { onLog(markdown: string): Promise; + onLogLevel?: Partial Promise>>; + onNotice: (markdown: string) => Promise; + onSuccess: (markdown: string) => Promise; } export class BrowserLogger implements ILogger { constructor(private _config: BrowserLoggerConfig) {} - async info(info: string): Promise { - await this._config.onLog(info); - } - async notice(msg: string): Promise { - await this._config.onLog(msg); + await this._config.onNotice(msg); } async success(msg: string): Promise { - await this._config.onLog(msg); + await this._config.onSuccess(msg); + } + + async info(info: string): Promise { + await Promise.all([ + this._config.onLog(info), + this.getOnLogLevel("info")(info) + ]); } async warning(msg: string): Promise { - await this._config.onLog(msg); + await Promise.all([ + this._config.onLog(msg), + this.getOnLogLevel("warning")(msg), + ]); } async error(msg: string): Promise { - await this._config.onLog(msg); + await Promise.all([ + this._config.onLog(msg), + this.getOnLogLevel("error")(msg) + ]); + } + + private getOnLogLevel(level: LogLevel): (msg: string) => Promise { + return ( + this._config.onLogLevel && this._config.onLogLevel[level] + ) || ( + () => Promise.resolve() + ); } } diff --git a/apps/browser/lib/utils/sanitizeLogsDetails.ts b/apps/browser/lib/utils/sanitizeLogsDetails.ts new file mode 100644 index 00000000..cf84743d --- /dev/null +++ b/apps/browser/lib/utils/sanitizeLogsDetails.ts @@ -0,0 +1,63 @@ +import { ChatLog } from "@/components/Chat"; + +export type MessageSet = { + userMessage: string; + evoMessage?: string; + details: Record; +}; + +export function sanitizeLogs(messages: ChatLog[]): MessageSet[] { + if (!messages || !messages.length) return []; + + // First, sort the messages by their creation date + messages.sort( + (a, b) => + new Date(a.created_at as string).getTime() - + new Date(b.created_at as string).getTime() + ); + + return messages.reduce((sanitizedLogs, currentMessage) => { + const sanitizedLogsLength = sanitizedLogs.length; + const currentSet = + sanitizedLogsLength > 0 ? sanitizedLogs[sanitizedLogsLength - 1] : null; + + // If the message is from user, it means its the start of a new set of messages + if (currentMessage.user === "user") { + sanitizedLogs.push({ + userMessage: currentMessage.title, + details: {}, + evoMessage: undefined, + }); + return sanitizedLogs; + } + + // If there's no initialized set, don't try to fill details + if (!currentSet) { + return sanitizedLogs; + } + + // Only user message (goal) and evo's answer does not start with # + // Since user message is handled above, we now for sure that its evo's answer + if (!currentMessage.title.startsWith("#")) { + currentSet.evoMessage = currentMessage.title; + } else { + // Steps or onGoal{Status} are the one that starts with two # + if (currentMessage.title.startsWith("## ")) { + currentSet.details[currentMessage.title] = []; + } else { + // Get the title and/or content and attach to section + const detailKeys = Object.keys(currentSet.details); + const currentKey = detailKeys[detailKeys.length - 1]; + const detailContent = currentMessage.content + ? currentMessage.title.concat(`\n${currentMessage.content}`) + : currentMessage.title; + const currentStep = currentSet.details[currentKey]; + // To avoid errors in runtime, we guarantee that current step indeed exists + if (currentStep) { + currentStep.push(detailContent); + } + } + } + return sanitizedLogs; + }, []); +} diff --git a/apps/browser/styles/globals.css b/apps/browser/styles/globals.css index ab1c3454..7ceb2e68 100644 --- a/apps/browser/styles/globals.css +++ b/apps/browser/styles/globals.css @@ -37,6 +37,10 @@ code { background-position: 56% 58%; } +.prose-condensed blockquote, dl, dd, h1, h2, h3, h4, h5, h6, hr, figure, p, pre { + @apply !m-0; +} + .prose-file * { @apply my-0; } diff --git a/packages/agent-utils/src/scripts/agent-plugin/index.ts b/packages/agent-utils/src/scripts/agent-plugin/index.ts index 72d36f95..a220e7a8 100644 --- a/packages/agent-utils/src/scripts/agent-plugin/index.ts +++ b/packages/agent-utils/src/scripts/agent-plugin/index.ts @@ -33,12 +33,12 @@ export class AgentPlugin extends Module { } public async onGoalAchieved(args: Args_onGoalAchieved): Promise { - await this._logger.success(`Goal has been achieved: ${args.message}`); + await this._logger.success(args.message); return true; } public async onGoalFailed(args: Args_onGoalFailed): Promise { - await this._logger.error(`Goal could not be achieved: ${args.message}`); + await this._logger.error(args.message); return true; } } diff --git a/packages/agents/src/agent-core/agent/basicFunctionCallLoop.ts b/packages/agents/src/agent-core/agent/basicFunctionCallLoop.ts index b70f2c73..17acbdb2 100644 --- a/packages/agents/src/agent-core/agent/basicFunctionCallLoop.ts +++ b/packages/agents/src/agent-core/agent/basicFunctionCallLoop.ts @@ -27,6 +27,7 @@ export async function* basicFunctionCallLoop( return ResultOk(finalOutput); } + await context.logger.notice("Analyzing which function should be called...") const response = await llm.getResponse(logs, agentFunctions); if (!response) { @@ -45,6 +46,7 @@ export async function* basicFunctionCallLoop( continue; } + await context.logger.notice("Executing function: " + sanitizedFunctionAndArgs.value[1].definition.name) const { result, functionCalled } = await executeAgentFunction(sanitizedFunctionAndArgs.value, fnArgs, context) // Save large results as variables diff --git a/packages/agents/src/agents/Evo/index.ts b/packages/agents/src/agents/Evo/index.ts index 553c0894..6aa689a2 100644 --- a/packages/agents/src/agents/Evo/index.ts +++ b/packages/agents/src/agents/Evo/index.ts @@ -76,6 +76,7 @@ export class Evo extends Agent { const { chat } = this.context; const { messages } = chat.chatLogs; + await this.context.logger.notice("Predicting best next step...") const prediction = !this.previousPrediction || this.loopCounter % 2 === 0 ? await this.predictBestNextStep( @@ -110,7 +111,7 @@ export class Evo extends Agent { const predictionVector = await this.createEmbeddingVector(prediction); await this.context.logger.info("### Prediction:\n-> " + prediction); - + await this.context.logger.notice("Finding best agent to execute step...") const [agent, agentFunctions, persona, allFunctions] = await findBestAgent( predictionVector, this.context diff --git a/packages/agents/src/functions/OnGoalAchieved.ts b/packages/agents/src/functions/OnGoalAchieved.ts index 827f5490..680e88bb 100644 --- a/packages/agents/src/functions/OnGoalAchieved.ts +++ b/packages/agents/src/functions/OnGoalAchieved.ts @@ -8,13 +8,13 @@ interface OnGoalAchievedFuncParameters { export class OnGoalAchievedFunction extends ScriptFunction { name: string = "agent_onGoalAchieved"; - description: string = "Informs the user that the goal has been achieved."; + description: string = "Informs the user that the goal has been achieved. Returns as message a complete and explicit answer for user's question"; parameters: any = { type: "object", properties: { message: { type: "string", - description: "information about how the goal was achieved", + description: "Complete and explicit answer for user's question", }, }, required: ["message"], diff --git a/packages/agents/src/functions/OnGoalFailed.ts b/packages/agents/src/functions/OnGoalFailed.ts index f9fbd0c9..af98cdb5 100644 --- a/packages/agents/src/functions/OnGoalFailed.ts +++ b/packages/agents/src/functions/OnGoalFailed.ts @@ -8,13 +8,13 @@ interface OnGoalFailedFuncParameters { export class OnGoalFailedFunction extends ScriptFunction<{}> { name: string = "agent_onGoalFailed"; - description: string = `Informs the user that the agent could not achieve the goal.`; + description: string = `Informs the user that the agent could not achieve the goal. Returns an explanation of why the goal could not be achieved`; parameters: any = { type: "object", properties: { message: { type: "string", - description: "information about how the goal was achieved", + description: "Explanation of why the goal could not be achieved", }, }, required: ["message"],