Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ It's a single Next.js service: `withEve()` in [`next.config.ts`](next.config.ts)

## Quick Start

> **Eve 1 release prerequisite:** This migration intentionally keeps the
> `@browser_use/eve` browser extension and its normal peer-dependency validation.
> The currently pinned `0.0.4` release declares `eve <1`, so a clean npm install
> remains blocked until Browser Use publishes an Eve 1-compatible extension release.
> Do not remove the plugin or install with peer-dependency bypass flags; upgrade the
> extension when that release is available.

### Deploy to Vercel

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fbrowser-use%2Fbrowser-agent-template&env=BROWSER_USE_API_KEY,AI_GATEWAY_API_KEY&envDescription=BROWSER_USE_API_KEY%20from%20browser-use.com%20%7C%20a%20model%20credential%20for%20the%20AI%20Gateway&envLink=https%3A%2F%2Fgithub.com%2Fbrowser-use%2Fbrowser-agent-template%2Fblob%2Fmain%2Fdocs%2FENVIRONMENT.md&project-name=browser-agent&repository-name=browser-agent)
Expand Down
46 changes: 26 additions & 20 deletions app/_components/agent-chat.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { type EveMessage, useEveAgent } from "eve/react";
import { activeTurn } from "eve/events";
import { AlertCircleIcon } from "lucide-react";
import {
Conversation,
Expand Down Expand Up @@ -43,7 +44,7 @@ function extractLiveUrl(messages: readonly EveMessage[]): string | null {
let found: string | null = null;
for (const message of messages) {
for (const part of message.parts) {
if (part.type !== "dynamic-tool" || part.toolName !== "open_cloud_browser") continue;
if (part.type !== "tool" || part.name !== "open_cloud_browser") continue;
const out = part.output;
let url: unknown;
if (out && typeof out === "object" && "liveUrl" in out) {
Expand All @@ -59,21 +60,30 @@ function extractLiveUrl(messages: readonly EveMessage[]): string | null {

export function AgentChat() {
const agent = useEveAgent();
const isBusy = agent.status === "submitted" || agent.status === "streaming";
const isEmpty = agent.data.messages.length === 0;
const liveUrl = extractLiveUrl(agent.data.messages);
const isBusy = agent.status === "busy";
Comment thread
cmpadden marked this conversation as resolved.
Resolved
const isResuming = agent.status === "resuming";
// Status is idle while a turn waits for human input, but that turn can still be cancelled.
const turnOpen = activeTurn(agent.view) !== undefined;
const isEmpty = agent.messages.length === 0;
const liveUrl = extractLiveUrl(agent.messages);

const handleSubmit = async (message: PromptInputMessage) => {
const text = message.text.trim();
if (!text || isBusy) return;
if (!text || isBusy || isResuming) return;
Comment thread
cmpadden marked this conversation as resolved.
Resolved

await agent.send({ message: text });
await agent.send(text);
};

const composer = (
<PromptInput onSubmit={handleSubmit}>
<PromptInputTextarea placeholder="Send a message…" />
<PromptInputSubmit onStop={agent.stop} status={agent.status} />
<PromptInputTextarea disabled={isResuming} placeholder="Send a message…" />
<PromptInputSubmit
disabled={isResuming && !turnOpen}
onStop={() => {
void agent.cancel();
}}
status={turnOpen ? "streaming" : "ready"}
/>
</PromptInput>
);

Expand Down Expand Up @@ -112,15 +122,17 @@ export function AgentChat() {
{isEmpty ? null : (
<Conversation className="min-h-0 flex-1">
<ConversationContent className="mx-auto w-full max-w-3xl gap-6 px-4 py-6 sm:px-6">
{agent.data.messages.map((message, index) => (
{agent.messages.map((message, index) => (
<AgentMessage
canRespond={!isBusy}
canRespond={!isResuming}
isStreaming={
agent.status === "streaming" && index === agent.data.messages.length - 1
isBusy && index === agent.messages.length - 1
}
key={message.id}
message={message}
onInputResponses={(inputResponses) => agent.send({ inputResponses })}
onAnswers={async (answers) => {
await agent.respond(answers);
}}
/>
))}
</ConversationContent>
Expand Down Expand Up @@ -158,15 +170,9 @@ export function AgentChat() {
}

function StatusDot({ status }: { readonly status: AgentStatus }) {
const isLive = status === "submitted" || status === "streaming";
const isLive = status === "busy";
const tone =
status === "error"
? "bg-destructive"
: isLive
? "bg-emerald-500"
: status === "ready"
? "bg-muted-foreground"
: "bg-muted-foreground/50";
isLive ? "bg-emerald-500" : status === "idle" ? "bg-muted-foreground" : "bg-muted-foreground/50";

return (
<span className="relative flex size-1">
Expand Down
165 changes: 125 additions & 40 deletions app/_components/agent-message.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import type { EveDynamicToolPart, EveMessage, EveMessagePart } from "eve/react";
import type { EveInteractionPart, EveMessage, EveMessagePart, EveToolPart } from "eve/react";
import { signInEnding } from "eve/react";
import { type FormEvent, useState } from "react";
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
import {
Expand All @@ -11,23 +13,24 @@ import {
ToolOutput,
} from "@/components/ai-elements/tool";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

export type AgentInputResponse = {
export type AgentAnswer = {
readonly optionId?: string;
readonly requestId: string;
readonly interactionId: string;
readonly text?: string;
};

export function AgentMessage({
canRespond,
isStreaming,
message,
onInputResponses,
onAnswers,
}: {
readonly canRespond: boolean;
readonly isStreaming: boolean;
readonly message: EveMessage;
readonly onInputResponses: (responses: readonly AgentInputResponse[]) => void | Promise<void>;
readonly onAnswers: (answers: readonly AgentAnswer[]) => void | Promise<void>;
}) {
const lastTextIndex = message.parts.reduce(
(last, part, index) => (part.type === "text" ? index : last),
Expand All @@ -44,7 +47,7 @@ export function AgentMessage({
<AgentMessagePart
canRespond={canRespond}
key={partKey(part, index)}
onInputResponses={onInputResponses}
onAnswers={onAnswers}
part={part}
showCaret={isStreaming && message.role === "assistant" && index === lastTextIndex}
/>
Expand All @@ -56,18 +59,16 @@ export function AgentMessage({

function AgentMessagePart({
canRespond,
onInputResponses,
onAnswers,
part,
showCaret,
}: {
readonly canRespond: boolean;
readonly onInputResponses: (responses: readonly AgentInputResponse[]) => void | Promise<void>;
readonly onAnswers: (answers: readonly AgentAnswer[]) => void | Promise<void>;
readonly part: EveMessagePart;
readonly showCaret: boolean;
}) {
switch (part.type) {
case "step-start":
return null;
case "text":
return (
<MessageResponse caret="block" isAnimating={showCaret}>
Expand All @@ -81,68 +82,76 @@ function AgentMessagePart({
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
case "dynamic-tool":
case "tool":
return (
<Tool
defaultOpen={part.state === "approval-requested" || part.state === "approval-responded"}
>
<Tool defaultOpen={part.state === "approval" || part.state === "blocked"}>
<ToolHeader
state={part.state}
title={part.toolName}
toolName={part.toolName}
type="dynamic-tool"
title={part.label}
toolName={part.name}
type="tool"
/>
<ToolContent>
<ToolInput input={part.input} />
<InputRequestActions
{part.input === undefined ? null : <ToolInput input={part.input} />}
<InteractionActions
canRespond={canRespond}
part={part}
onInputResponses={onInputResponses}
onAnswers={onAnswers}
/>
<ToolOutput errorText={part.errorText} output={part.output} />
<ToolOutput errorText={part.error?.message} output={part.output} />
</ToolContent>
</Tool>
);
case "interaction":
return <InteractionActions canRespond={canRespond} part={part} onAnswers={onAnswers} />;
}
}

function InputRequestActions({
function InteractionActions({
canRespond,
onInputResponses,
onAnswers,
part,
}: {
readonly canRespond: boolean;
readonly onInputResponses: (responses: readonly AgentInputResponse[]) => void | Promise<void>;
readonly part: EveDynamicToolPart;
readonly onAnswers: (answers: readonly AgentAnswer[]) => void | Promise<void>;
readonly part: EveToolPart | EveInteractionPart;
}) {
const inputRequest = part.toolMetadata?.eve?.inputRequest;
if (!inputRequest) {
const interaction = part.type === "tool" ? part.interaction : part;
if (!interaction) {
return null;
}

const inputResponse = part.toolMetadata?.eve?.inputResponse;
const selectedOption = inputRequest.options?.find(
(option) => option.id === inputResponse?.optionId,
if (interaction.request.kind === "sign-in") {
return <SignInPrompt interaction={interaction} />;
}

const selectedOption = interaction.request.options?.find(
(option) => option.id === interaction.answer?.optionId,
);
const open = interaction.status === "open";
const options = interaction.request.options ?? [];
const acceptsFreeform = interaction.request.allowFreeform === true || options.length === 0;

return (
<div className="space-y-3 rounded-md border border-yellow-500/30 bg-yellow-500/5 p-3">
<p className="text-muted-foreground text-sm">{inputRequest.prompt}</p>
{inputResponse ? (
<p className="text-muted-foreground text-sm">{interaction.request.prompt}</p>
{interaction.answer ? (
Comment thread
cmpadden marked this conversation as resolved.
Resolved
<p className="font-medium text-sm">
Responded: {selectedOption?.label ?? inputResponse.text ?? inputResponse.optionId}
{open ? "Response submitted: " : "Responded: "}
{selectedOption?.label ?? interaction.answer.text ?? interaction.answer.optionId}
</p>
) : (
) : null}
{open ? (
<div className="flex flex-wrap gap-2">
{inputRequest.options?.map((option) => (
{options.map((option) => (
<Button
disabled={!canRespond}
key={option.id}
onClick={() => {
void onInputResponses([
void onAnswers([
{
optionId: option.id,
requestId: inputRequest.requestId,
interactionId: interaction.interactionId,
},
]);
}}
Expand All @@ -153,16 +162,92 @@ function InputRequestActions({
{option.label}
</Button>
))}
{acceptsFreeform ? (
<FreeTextAnswer
canRespond={canRespond}
interactionId={interaction.interactionId}
onAnswers={onAnswers}
/>
) : null}
</div>
)}
) : null}
</div>
);
}

function FreeTextAnswer({
canRespond,
interactionId,
onAnswers,
}: {
readonly canRespond: boolean;
readonly interactionId: string;
readonly onAnswers: (answers: readonly AgentAnswer[]) => void | Promise<void>;
}) {
const [text, setText] = useState("");

const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const answer = text.trim();
if (!answer || !canRespond) return;
void onAnswers([{ interactionId, text: answer }]);
};

return (
<form className="flex w-full gap-2" onSubmit={submit}>
<Input
aria-label="Answer"
disabled={!canRespond}
onChange={(event) => setText(event.currentTarget.value)}
placeholder="Type your answer…"
value={text}
/>
<Button disabled={!canRespond || text.trim().length === 0} size="sm" type="submit">
Answer
</Button>
</form>
);
}

function SignInPrompt({
interaction,
}: {
readonly interaction: EveInteractionPart | NonNullable<EveToolPart["interaction"]>;
}) {
const signIn = interaction.request.signIn;
const name = signIn?.displayName ?? signIn?.name ?? "the service";
const settled = interaction.status === "settled";

return (
<div className="space-y-3 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
<p className="font-medium text-sm">
{settled ? `${name} authorization ${signInEnding(interaction.outcome ?? "withdrawn")}` : `Connect ${name}`}
</p>
<p className="text-muted-foreground text-sm">
{settled ? interaction.reason ?? "The sign-in request has finished." : signIn?.instructions ?? interaction.request.prompt}
</p>
{!settled && signIn?.userCode ? (
<p className="text-sm">
Code: <code className="rounded bg-background px-1 py-0.5">{signIn.userCode}</code>
</p>
) : null}
{!settled && signIn?.url ? (
<Button asChild size="sm">
<a href={signIn.url} rel="noreferrer" target="_blank">
Sign in with {name}
</a>
</Button>
) : null}
</div>
);
}

function partKey(part: EveMessagePart, index: number): string {
switch (part.type) {
case "dynamic-tool":
return part.toolCallId;
case "tool":
return part.callId;
case "interaction":
return part.interactionId;
default:
return `${part.type}:${index}`;
}
Expand Down
Loading