feat(frontend): Chat UI Frontend (#11290)

This commit is contained in:
Swifty
2025-11-06 11:37:36 +01:00
committed by GitHub
parent 5b52ca9227
commit 42b643579f
41 changed files with 3486 additions and 10 deletions

View File

@@ -5,6 +5,8 @@ const nextConfig = {
productionBrowserSourceMaps: true,
images: {
domains: [
// We dont need to maintain alphabetical order here
// as we are doing logical grouping of domains
"images.unsplash.com",
"ddz4ak4pa3d19.cloudfront.net",
"upload.wikimedia.org",
@@ -12,6 +14,7 @@ const nextConfig = {
"ideogram.ai", // for generated images
"picsum.photos", // for placeholder images
"example.com", // for local test data images
],
remotePatterns: [
{

View File

@@ -0,0 +1,115 @@
import React from "react";
import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
import { Card } from "@/components/atoms/Card/Card";
import { List, Robot, ArrowRight } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
export interface Agent {
id: string;
name: string;
description: string;
version?: number;
}
export interface AgentCarouselMessageProps {
agents: Agent[];
totalCount?: number;
onSelectAgent?: (agentId: string) => void;
className?: string;
}
export function AgentCarouselMessage({
agents,
totalCount,
onSelectAgent,
className,
}: AgentCarouselMessageProps) {
const displayCount = totalCount ?? agents.length;
return (
<div
className={cn(
"mx-4 my-2 flex flex-col gap-4 rounded-lg border border-purple-200 bg-purple-50 p-6 dark:border-purple-900 dark:bg-purple-950",
className,
)}
>
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-purple-500">
<List size={24} weight="bold" className="text-white" />
</div>
<div>
<Text variant="h3" className="text-purple-900 dark:text-purple-100">
Found {displayCount} {displayCount === 1 ? "Agent" : "Agents"}
</Text>
<Text
variant="small"
className="text-purple-700 dark:text-purple-300"
>
Select an agent to view details or run it
</Text>
</div>
</div>
{/* Agent Cards */}
<div className="grid gap-3 sm:grid-cols-2">
{agents.map((agent) => (
<Card
key={agent.id}
className="border border-purple-200 bg-white p-4 dark:border-purple-800 dark:bg-purple-900"
>
<div className="flex gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-800">
<Robot size={20} weight="bold" className="text-purple-600" />
</div>
<div className="flex-1 space-y-2">
<div>
<Text
variant="body"
className="font-semibold text-purple-900 dark:text-purple-100"
>
{agent.name}
</Text>
{agent.version && (
<Text
variant="small"
className="text-purple-600 dark:text-purple-400"
>
v{agent.version}
</Text>
)}
</div>
<Text
variant="small"
className="line-clamp-2 text-purple-700 dark:text-purple-300"
>
{agent.description}
</Text>
{onSelectAgent && (
<Button
onClick={() => onSelectAgent(agent.id)}
variant="ghost"
className="mt-2 flex items-center gap-1 p-0 text-sm text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-200"
>
View details
<ArrowRight size={16} weight="bold" />
</Button>
)}
</div>
</div>
</Card>
))}
</div>
{totalCount && totalCount > agents.length && (
<Text
variant="small"
className="text-center text-purple-600 dark:text-purple-400"
>
Showing {agents.length} of {totalCount} results
</Text>
)}
</div>
);
}

View File

@@ -0,0 +1,123 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/atoms/Button/Button";
import { SignInIcon, UserPlusIcon, ShieldIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
export interface AuthPromptWidgetProps {
message: string;
sessionId: string;
agentInfo?: {
graph_id: string;
name: string;
trigger_type: string;
};
returnUrl?: string;
className?: string;
}
export function AuthPromptWidget({
message,
sessionId,
agentInfo,
returnUrl = "/chat",
className,
}: AuthPromptWidgetProps) {
const router = useRouter();
function handleSignIn() {
if (typeof window !== "undefined") {
localStorage.setItem("pending_chat_session", sessionId);
if (agentInfo) {
localStorage.setItem("pending_agent_setup", JSON.stringify(agentInfo));
}
}
const returnUrlWithSession = `${returnUrl}?session_id=${sessionId}`;
const encodedReturnUrl = encodeURIComponent(returnUrlWithSession);
router.push(`/login?returnUrl=${encodedReturnUrl}`);
}
function handleSignUp() {
if (typeof window !== "undefined") {
localStorage.setItem("pending_chat_session", sessionId);
if (agentInfo) {
localStorage.setItem("pending_agent_setup", JSON.stringify(agentInfo));
}
}
const returnUrlWithSession = `${returnUrl}?session_id=${sessionId}`;
const encodedReturnUrl = encodeURIComponent(returnUrlWithSession);
router.push(`/signup?returnUrl=${encodedReturnUrl}`);
}
return (
<div
className={cn(
"my-4 overflow-hidden rounded-lg border border-violet-200 dark:border-violet-800",
"bg-gradient-to-br from-violet-50 to-purple-50 dark:from-violet-950/30 dark:to-purple-950/30",
"duration-500 animate-in fade-in-50 slide-in-from-bottom-2",
className,
)}
>
<div className="px-6 py-5">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-600">
<ShieldIcon size={20} weight="fill" className="text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Authentication Required
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Sign in to set up and manage agents
</p>
</div>
</div>
<div className="mb-5 rounded-md bg-white/50 p-4 dark:bg-neutral-900/50">
<p className="text-sm text-neutral-700 dark:text-neutral-300">
{message}
</p>
{agentInfo && (
<div className="mt-3 text-xs text-neutral-600 dark:text-neutral-400">
<p>
Ready to set up:{" "}
<span className="font-medium">{agentInfo.name}</span>
</p>
<p>
Type:{" "}
<span className="font-medium">{agentInfo.trigger_type}</span>
</p>
</div>
)}
</div>
<div className="flex gap-3">
<Button
onClick={handleSignIn}
variant="primary"
size="small"
className="flex-1"
>
<SignInIcon size={16} weight="bold" className="mr-2" />
Sign In
</Button>
<Button
onClick={handleSignUp}
variant="secondary"
size="small"
className="flex-1"
>
<UserPlusIcon size={16} weight="bold" className="mr-2" />
Create Account
</Button>
</div>
<div className="mt-4 text-center text-xs text-neutral-500 dark:text-neutral-500">
Your chat session will be preserved after signing in
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { cn } from "@/lib/utils";
import { ChatInput } from "@/app/(platform)/chat/components/ChatInput/ChatInput";
import { MessageList } from "@/app/(platform)/chat/components/MessageList/MessageList";
import { QuickActionsWelcome } from "@/app/(platform)/chat/components/QuickActionsWelcome/QuickActionsWelcome";
import { useChatContainer } from "./useChatContainer";
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
export interface ChatContainerProps {
sessionId: string | null;
initialMessages: SessionDetailResponse["messages"];
onRefreshSession: () => Promise<void>;
className?: string;
}
export function ChatContainer({
sessionId,
initialMessages,
onRefreshSession,
className,
}: ChatContainerProps) {
const { messages, streamingChunks, isStreaming, sendMessage } =
useChatContainer({
sessionId,
initialMessages,
onRefreshSession,
});
const quickActions = [
"Find agents for data analysis",
"Show me automation agents",
"Help me build a workflow",
"What can you help me with?",
];
return (
<div className={cn("flex h-full flex-col", className)}>
{/* Messages or Welcome Screen */}
{messages.length === 0 ? (
<QuickActionsWelcome
title="Welcome to AutoGPT Chat"
description="Start a conversation to discover and run AI agents."
actions={quickActions}
onActionClick={sendMessage}
disabled={isStreaming || !sessionId}
/>
) : (
<MessageList
messages={messages}
streamingChunks={streamingChunks}
isStreaming={isStreaming}
onSendMessage={sendMessage}
className="flex-1"
/>
)}
{/* Input - Always visible */}
<div className="border-t border-zinc-200 p-4 dark:border-zinc-800">
<ChatInput
onSend={sendMessage}
disabled={isStreaming || !sessionId}
placeholder={
sessionId ? "Type your message..." : "Creating session..."
}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { toast } from "sonner";
import type { StreamChunk } from "@/app/(platform)/chat/useChatStream";
import type { HandlerDependencies } from "./useChatContainer.handlers";
import {
handleTextChunk,
handleTextEnded,
handleToolCallStart,
handleToolResponse,
handleLoginNeeded,
handleStreamEnd,
handleError,
} from "./useChatContainer.handlers";
export function createStreamEventDispatcher(
deps: HandlerDependencies,
): (chunk: StreamChunk) => void {
return function dispatchStreamEvent(chunk: StreamChunk): void {
switch (chunk.type) {
case "text_chunk":
handleTextChunk(chunk, deps);
break;
case "text_ended":
handleTextEnded(chunk, deps);
break;
case "tool_call_start":
handleToolCallStart(chunk, deps);
break;
case "tool_response":
handleToolResponse(chunk, deps);
break;
case "login_needed":
case "need_login":
handleLoginNeeded(chunk, deps);
break;
case "stream_end":
handleStreamEnd(chunk, deps);
break;
case "error":
handleError(chunk, deps);
// Show toast at dispatcher level to avoid circular dependencies
toast.error("Chat Error", {
description: chunk.message || chunk.content || "An error occurred",
});
break;
case "usage":
// TODO: Handle usage for display
break;
default:
console.warn("Unknown stream chunk type:", chunk);
}
};
}

View File

@@ -0,0 +1,274 @@
import type { ChatMessageData } from "@/app/(platform)/chat/components/ChatMessage/useChatMessage";
import type { ToolResult } from "@/types/chat";
export function createUserMessage(content: string): ChatMessageData {
return {
type: "message",
role: "user",
content,
timestamp: new Date(),
};
}
export function filterAuthMessages(
messages: ChatMessageData[],
): ChatMessageData[] {
return messages.filter(
(msg) => msg.type !== "credentials_needed" && msg.type !== "login_needed",
);
}
export function isValidMessage(msg: unknown): msg is Record<string, unknown> {
if (typeof msg !== "object" || msg === null) {
return false;
}
const m = msg as Record<string, unknown>;
if (typeof m.role !== "string") {
return false;
}
if (m.content !== undefined && typeof m.content !== "string") {
return false;
}
return true;
}
export function isToolCallArray(value: unknown): value is Array<{
id: string;
type: string;
function: { name: string; arguments: string };
}> {
if (!Array.isArray(value)) {
return false;
}
return value.every(
(item) =>
typeof item === "object" &&
item !== null &&
"id" in item &&
typeof item.id === "string" &&
"type" in item &&
typeof item.type === "string" &&
"function" in item &&
typeof item.function === "object" &&
item.function !== null &&
"name" in item.function &&
typeof item.function.name === "string" &&
"arguments" in item.function &&
typeof item.function.arguments === "string",
);
}
export function isAgentArray(value: unknown): value is Array<{
id: string;
name: string;
description: string;
version?: number;
}> {
if (!Array.isArray(value)) {
return false;
}
return value.every(
(item) =>
typeof item === "object" &&
item !== null &&
"id" in item &&
typeof item.id === "string" &&
"name" in item &&
typeof item.name === "string" &&
"description" in item &&
typeof item.description === "string" &&
(!("version" in item) || typeof item.version === "number"),
);
}
export function extractJsonFromErrorMessage(
message: string,
): Record<string, unknown> | null {
try {
const start = message.indexOf("{");
if (start === -1) {
return null;
}
let depth = 0;
let end = -1;
for (let i = start; i < message.length; i++) {
const ch = message[i];
if (ch === "{") {
depth++;
} else if (ch === "}") {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
if (end === -1) {
return null;
}
const jsonStr = message.slice(start, end + 1);
return JSON.parse(jsonStr) as Record<string, unknown>;
} catch {
return null;
}
}
export function parseToolResponse(
result: ToolResult,
toolId: string,
toolName: string,
timestamp?: Date,
): ChatMessageData | null {
let parsedResult: Record<string, unknown> | null = null;
try {
parsedResult =
typeof result === "string"
? JSON.parse(result)
: (result as Record<string, unknown>);
} catch {
parsedResult = null;
}
if (parsedResult && typeof parsedResult === "object") {
const responseType = parsedResult.type as string | undefined;
if (responseType === "no_results") {
return {
type: "tool_response",
toolId,
toolName,
result: (parsedResult.message as string) || "No results found",
success: true,
timestamp: timestamp || new Date(),
};
}
if (responseType === "agent_carousel") {
const agentsData = parsedResult.agents;
if (isAgentArray(agentsData)) {
return {
type: "agent_carousel",
agents: agentsData,
totalCount: parsedResult.total_count as number | undefined,
timestamp: timestamp || new Date(),
};
} else {
console.warn("Invalid agents array in agent_carousel response");
}
}
if (responseType === "execution_started") {
return {
type: "execution_started",
executionId: (parsedResult.execution_id as string) || "",
agentName: parsedResult.agent_name as string | undefined,
message: parsedResult.message as string | undefined,
timestamp: timestamp || new Date(),
};
}
if (responseType === "need_login") {
return {
type: "login_needed",
message:
(parsedResult.message as string) ||
"Please sign in to use chat and agent features",
sessionId: (parsedResult.session_id as string) || "",
agentInfo: parsedResult.agent_info as
| {
graph_id: string;
name: string;
trigger_type: string;
}
| undefined,
timestamp: timestamp || new Date(),
};
}
if (responseType === "setup_requirements") {
return null;
}
}
return {
type: "tool_response",
toolId,
toolName,
result,
success: true,
timestamp: timestamp || new Date(),
};
}
export function isUserReadiness(
value: unknown,
): value is { missing_credentials?: Record<string, unknown> } {
return (
typeof value === "object" &&
value !== null &&
(!("missing_credentials" in value) ||
typeof (value as any).missing_credentials === "object")
);
}
export function isMissingCredentials(
value: unknown,
): value is Record<string, Record<string, unknown>> {
if (typeof value !== "object" || value === null) {
return false;
}
return Object.values(value).every((v) => typeof v === "object" && v !== null);
}
export function isSetupInfo(value: unknown): value is {
user_readiness?: Record<string, unknown>;
agent_name?: string;
} {
return (
typeof value === "object" &&
value !== null &&
(!("user_readiness" in value) ||
typeof (value as any).user_readiness === "object") &&
(!("agent_name" in value) || typeof (value as any).agent_name === "string")
);
}
export function extractCredentialsNeeded(
parsedResult: Record<string, unknown>,
): ChatMessageData | null {
try {
const setupInfo = parsedResult?.setup_info as
| Record<string, unknown>
| undefined;
const userReadiness = setupInfo?.user_readiness as
| Record<string, unknown>
| undefined;
const missingCreds = userReadiness?.missing_credentials as
| Record<string, Record<string, unknown>>
| undefined;
if (missingCreds && Object.keys(missingCreds).length > 0) {
const agentName = (setupInfo?.agent_name as string) || "this agent";
const credentials = Object.values(missingCreds).map((credInfo) => ({
provider: (credInfo.provider as string) || "unknown",
providerName:
(credInfo.provider_name as string) ||
(credInfo.provider as string) ||
"Unknown Provider",
credentialType:
(credInfo.type as
| "api_key"
| "oauth2"
| "user_password"
| "host_scoped") || "api_key",
title:
(credInfo.title as string) ||
`${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials`,
scopes: credInfo.scopes as string[] | undefined,
}));
return {
type: "credentials_needed",
credentials,
message: `To run ${agentName}, you need to add ${credentials.length === 1 ? "credentials" : `${credentials.length} credentials`}.`,
agentName,
timestamp: new Date(),
};
}
return null;
} catch (err) {
console.error("Failed to extract credentials from setup info:", err);
return null;
}
}

View File

@@ -0,0 +1,198 @@
import type { Dispatch, SetStateAction, MutableRefObject } from "react";
import type { StreamChunk } from "@/app/(platform)/chat/useChatStream";
import type { ChatMessageData } from "@/app/(platform)/chat/components/ChatMessage/useChatMessage";
import { parseToolResponse, extractCredentialsNeeded } from "./helpers";
export interface HandlerDependencies {
setHasTextChunks: Dispatch<SetStateAction<boolean>>;
setStreamingChunks: Dispatch<SetStateAction<string[]>>;
streamingChunksRef: MutableRefObject<string[]>;
setMessages: Dispatch<SetStateAction<ChatMessageData[]>>;
sessionId: string;
}
export function handleTextChunk(chunk: StreamChunk, deps: HandlerDependencies) {
if (!chunk.content) return;
deps.setHasTextChunks(true);
deps.setStreamingChunks((prev) => {
const updated = [...prev, chunk.content!];
deps.streamingChunksRef.current = updated;
return updated;
});
}
export function handleTextEnded(
_chunk: StreamChunk,
deps: HandlerDependencies,
) {
console.log("[Text Ended] Saving streamed text as assistant message");
const completedText = deps.streamingChunksRef.current.join("");
if (completedText.trim()) {
const assistantMessage: ChatMessageData = {
type: "message",
role: "assistant",
content: completedText,
timestamp: new Date(),
};
deps.setMessages((prev) => [...prev, assistantMessage]);
}
deps.setStreamingChunks([]);
deps.streamingChunksRef.current = [];
deps.setHasTextChunks(false);
}
export function handleToolCallStart(
chunk: StreamChunk,
deps: HandlerDependencies,
) {
const toolCallMessage: ChatMessageData = {
type: "tool_call",
toolId: chunk.tool_id || `tool-${Date.now()}-${chunk.idx || 0}`,
toolName: chunk.tool_name || "Executing...",
arguments: chunk.arguments || {},
timestamp: new Date(),
};
deps.setMessages((prev) => [...prev, toolCallMessage]);
console.log("[Tool Call Start]", {
toolId: toolCallMessage.toolId,
toolName: toolCallMessage.toolName,
timestamp: new Date().toISOString(),
});
}
export function handleToolResponse(
chunk: StreamChunk,
deps: HandlerDependencies,
) {
console.log("[Tool Response] Received:", {
toolId: chunk.tool_id,
toolName: chunk.tool_name,
timestamp: new Date().toISOString(),
});
let toolName = chunk.tool_name || "unknown";
if (!chunk.tool_name || chunk.tool_name === "unknown") {
deps.setMessages((prev) => {
const matchingToolCall = [...prev]
.reverse()
.find(
(msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id,
);
if (matchingToolCall && matchingToolCall.type === "tool_call") {
toolName = matchingToolCall.toolName;
}
return prev;
});
}
const responseMessage = parseToolResponse(
chunk.result!,
chunk.tool_id!,
toolName,
new Date(),
);
if (!responseMessage) {
let parsedResult: Record<string, unknown> | null = null;
try {
parsedResult =
typeof chunk.result === "string"
? JSON.parse(chunk.result)
: (chunk.result as Record<string, unknown>);
} catch {
parsedResult = null;
}
if (
chunk.tool_name === "get_required_setup_info" &&
chunk.success &&
parsedResult
) {
const credentialsMessage = extractCredentialsNeeded(parsedResult);
if (credentialsMessage) {
deps.setMessages((prev) => [...prev, credentialsMessage]);
}
}
return;
}
deps.setMessages((prev) => {
const toolCallIndex = prev.findIndex(
(msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id,
);
if (toolCallIndex !== -1) {
const newMessages = [...prev];
newMessages[toolCallIndex] = responseMessage;
console.log(
"[Tool Response] Replaced tool_call with matching tool_id:",
chunk.tool_id,
"at index:",
toolCallIndex,
);
return newMessages;
}
console.warn(
"[Tool Response] No tool_call found with tool_id:",
chunk.tool_id,
"appending instead",
);
return [...prev, responseMessage];
});
}
export function handleLoginNeeded(
chunk: StreamChunk,
deps: HandlerDependencies,
) {
const loginNeededMessage: ChatMessageData = {
type: "login_needed",
message: chunk.message || "Please sign in to use chat and agent features",
sessionId: chunk.session_id || deps.sessionId,
agentInfo: chunk.agent_info,
timestamp: new Date(),
};
deps.setMessages((prev) => [...prev, loginNeededMessage]);
}
export function handleStreamEnd(
_chunk: StreamChunk,
deps: HandlerDependencies,
) {
const completedContent = deps.streamingChunksRef.current.join("");
if (completedContent) {
const assistantMessage: ChatMessageData = {
type: "message",
role: "assistant",
content: completedContent,
timestamp: new Date(),
};
deps.setMessages((prev) => {
const updated = [...prev, assistantMessage];
console.log("[Stream End] Final state:", {
localMessages: updated.map((m) => ({
type: m.type,
...(m.type === "message" && {
role: m.role,
contentLength: m.content.length,
}),
...(m.type === "tool_call" && {
toolId: m.toolId,
toolName: m.toolName,
}),
...(m.type === "tool_response" && {
toolId: m.toolId,
toolName: m.toolName,
success: m.success,
}),
})),
streamingChunks: deps.streamingChunksRef.current,
timestamp: new Date().toISOString(),
});
return updated;
});
}
deps.setStreamingChunks([]);
deps.streamingChunksRef.current = [];
deps.setHasTextChunks(false);
console.log("[Stream End] Stream complete, messages in local state");
}
export function handleError(chunk: StreamChunk, _deps: HandlerDependencies) {
const errorMessage = chunk.message || chunk.content || "An error occurred";
console.error("Stream error:", errorMessage);
}

View File

@@ -0,0 +1,130 @@
import { useState, useCallback, useRef, useMemo } from "react";
import { toast } from "sonner";
import { useChatStream } from "@/app/(platform)/chat/useChatStream";
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
import type { ChatMessageData } from "@/app/(platform)/chat/components/ChatMessage/useChatMessage";
import {
parseToolResponse,
isValidMessage,
isToolCallArray,
createUserMessage,
filterAuthMessages,
} from "./helpers";
import { createStreamEventDispatcher } from "./createStreamEventDispatcher";
interface UseChatContainerArgs {
sessionId: string | null;
initialMessages: SessionDetailResponse["messages"];
onRefreshSession: () => Promise<void>;
}
export function useChatContainer({
sessionId,
initialMessages,
}: UseChatContainerArgs) {
const [messages, setMessages] = useState<ChatMessageData[]>([]);
const [streamingChunks, setStreamingChunks] = useState<string[]>([]);
const [hasTextChunks, setHasTextChunks] = useState(false);
const streamingChunksRef = useRef<string[]>([]);
const { error, sendMessage: sendStreamMessage } = useChatStream();
const isStreaming = hasTextChunks;
const allMessages = useMemo(() => {
const processedInitialMessages = initialMessages
.filter((msg: Record<string, unknown>) => {
if (!isValidMessage(msg)) {
console.warn("Invalid message structure from backend:", msg);
return false;
}
const content = String(msg.content || "").trim();
const toolCalls = msg.tool_calls;
return (
content.length > 0 ||
(toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0)
);
})
.map((msg: Record<string, unknown>) => {
const content = String(msg.content || "");
const role = String(msg.role || "assistant").toLowerCase();
const toolCalls = msg.tool_calls;
if (
role === "assistant" &&
toolCalls &&
isToolCallArray(toolCalls) &&
toolCalls.length > 0
) {
return null;
}
if (role === "tool") {
const timestamp = msg.timestamp
? new Date(msg.timestamp as string)
: undefined;
const toolResponse = parseToolResponse(
content,
(msg.tool_call_id as string) || "",
"unknown",
timestamp,
);
if (!toolResponse) {
return null;
}
return toolResponse;
}
return {
type: "message",
role: role as "user" | "assistant" | "system",
content,
timestamp: msg.timestamp
? new Date(msg.timestamp as string)
: undefined,
};
})
.filter((msg): msg is ChatMessageData => msg !== null);
return [...processedInitialMessages, ...messages];
}, [initialMessages, messages]);
const sendMessage = useCallback(
async function sendMessage(content: string, isUserMessage: boolean = true) {
if (!sessionId) {
console.error("Cannot send message: no session ID");
return;
}
if (isUserMessage) {
const userMessage = createUserMessage(content);
setMessages((prev) => [...filterAuthMessages(prev), userMessage]);
} else {
setMessages((prev) => filterAuthMessages(prev));
}
setStreamingChunks([]);
streamingChunksRef.current = [];
setHasTextChunks(false);
const dispatcher = createStreamEventDispatcher({
setHasTextChunks,
setStreamingChunks,
streamingChunksRef,
setMessages,
sessionId,
});
try {
await sendStreamMessage(sessionId, content, dispatcher, isUserMessage);
} catch (err) {
console.error("Failed to send message:", err);
const errorMessage =
err instanceof Error ? err.message : "Failed to send message";
toast.error("Failed to send message", {
description: errorMessage,
});
}
},
[sessionId, sendStreamMessage],
);
return {
messages: allMessages,
streamingChunks,
isStreaming,
error,
sendMessage,
};
}

View File

@@ -0,0 +1,154 @@
import { useEffect, useRef } from "react";
import { Card } from "@/components/atoms/Card/Card";
import { Text } from "@/components/atoms/Text/Text";
import { KeyIcon, CheckIcon, WarningIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { useChatCredentialsSetup } from "./useChatCredentialsSetup";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/CredentialsInputs/CredentialsInputs";
import type { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
export interface CredentialInfo {
provider: string;
providerName: string;
credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped";
title: string;
scopes?: string[];
}
interface Props {
credentials: CredentialInfo[];
agentName?: string;
message: string;
onAllCredentialsComplete: () => void;
onCancel: () => void;
className?: string;
}
function createSchemaFromCredentialInfo(
credential: CredentialInfo,
): BlockIOCredentialsSubSchema {
return {
type: "object",
properties: {},
credentials_provider: [credential.provider],
credentials_types: [credential.credentialType],
credentials_scopes: credential.scopes,
discriminator: undefined,
discriminator_mapping: undefined,
discriminator_values: undefined,
};
}
export function ChatCredentialsSetup({
credentials,
agentName: _agentName,
message,
onAllCredentialsComplete,
onCancel: _onCancel,
className,
}: Props) {
const { selectedCredentials, isAllComplete, handleCredentialSelect } =
useChatCredentialsSetup(credentials);
// Track if we've already called completion to prevent double calls
const hasCalledCompleteRef = useRef(false);
// Reset the completion flag when credentials change (new credential setup flow)
useEffect(
function resetCompletionFlag() {
hasCalledCompleteRef.current = false;
},
[credentials],
);
// Auto-call completion when all credentials are configured
useEffect(
function autoCompleteWhenReady() {
if (isAllComplete && !hasCalledCompleteRef.current) {
hasCalledCompleteRef.current = true;
onAllCredentialsComplete();
}
},
[isAllComplete, onAllCredentialsComplete],
);
return (
<Card
className={cn(
"mx-4 my-2 overflow-hidden border-orange-200 bg-orange-50 dark:border-orange-900 dark:bg-orange-950",
className,
)}
>
<div className="flex items-start gap-4 p-6">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-500">
<KeyIcon size={24} weight="bold" className="text-white" />
</div>
<div className="flex-1">
<Text
variant="h3"
className="mb-2 text-orange-900 dark:text-orange-100"
>
Credentials Required
</Text>
<Text
variant="body"
className="mb-4 text-orange-700 dark:text-orange-300"
>
{message}
</Text>
<div className="space-y-3">
{credentials.map((cred, index) => {
const schema = createSchemaFromCredentialInfo(cred);
const isSelected = !!selectedCredentials[cred.provider];
return (
<div
key={`${cred.provider}-${index}`}
className={cn(
"relative rounded-lg border border-orange-200 bg-white p-4 dark:border-orange-800 dark:bg-orange-900/20",
isSelected &&
"border-green-500 bg-green-50 dark:border-green-700 dark:bg-green-950/30",
)}
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
{isSelected ? (
<CheckIcon
size={20}
className="text-green-500"
weight="bold"
/>
) : (
<WarningIcon
size={20}
className="text-orange-500"
weight="bold"
/>
)}
<Text
variant="body"
className="font-semibold text-orange-900 dark:text-orange-100"
>
{cred.providerName}
</Text>
</div>
</div>
<CredentialsInput
schema={schema}
selectedCredentials={selectedCredentials[cred.provider]}
onSelectCredentials={(credMeta) =>
handleCredentialSelect(cred.provider, credMeta)
}
hideIfSingleCredentialAvailable={false}
/>
</div>
);
})}
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,36 @@
import { useState, useMemo } from "react";
import type { CredentialInfo } from "./ChatCredentialsSetup";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api";
export function useChatCredentialsSetup(credentials: CredentialInfo[]) {
const [selectedCredentials, setSelectedCredentials] = useState<
Record<string, CredentialsMetaInput>
>({});
// Check if all credentials are configured
const isAllComplete = useMemo(
function checkAllComplete() {
if (credentials.length === 0) return false;
return credentials.every((cred) => selectedCredentials[cred.provider]);
},
[credentials, selectedCredentials],
);
function handleCredentialSelect(
provider: string,
credential?: CredentialsMetaInput,
) {
if (credential) {
setSelectedCredentials((prev) => ({
...prev,
[provider]: credential,
}));
}
}
return {
selectedCredentials,
isAllComplete,
handleCredentialSelect,
};
}

View File

@@ -0,0 +1,30 @@
import React from "react";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { cn } from "@/lib/utils";
export interface ChatErrorStateProps {
error: Error;
onRetry?: () => void;
className?: string;
}
export function ChatErrorState({
error,
onRetry,
className,
}: ChatErrorStateProps) {
return (
<div
className={cn("flex flex-1 items-center justify-center p-6", className)}
>
<ErrorCard
responseError={{
message: error.message,
}}
context="chat session"
onRetry={onRetry}
className="max-w-md"
/>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { cn } from "@/lib/utils";
import { PaperPlaneRightIcon } from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import { useChatInput } from "./useChatInput";
export interface ChatInputProps {
onSend: (message: string) => void;
disabled?: boolean;
placeholder?: string;
className?: string;
}
export function ChatInput({
onSend,
disabled = false,
placeholder = "Type your message...",
className,
}: ChatInputProps) {
const { value, setValue, handleKeyDown, handleSend, textareaRef } =
useChatInput({
onSend,
disabled,
maxRows: 5,
});
return (
<div className={cn("flex gap-2", className)}>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
autoComplete="off"
aria-label="Chat message input"
aria-describedby="chat-input-hint"
className={cn(
"flex-1 resize-none rounded-lg border border-neutral-200 bg-white px-4 py-2 text-sm",
"placeholder:text-neutral-400",
"focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-600/20",
"dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-500",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
/>
<span id="chat-input-hint" className="sr-only">
Press Enter to send, Shift+Enter for new line
</span>
<Button
variant="primary"
size="small"
onClick={handleSend}
disabled={disabled || !value.trim()}
className="self-end"
aria-label="Send message"
>
<PaperPlaneRightIcon className="h-4 w-4" weight="fill" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { KeyboardEvent, useCallback, useState, useRef, useEffect } from "react";
interface UseChatInputArgs {
onSend: (message: string) => void;
disabled?: boolean;
maxRows?: number;
}
export function useChatInput({
onSend,
disabled = false,
maxRows = 5,
}: UseChatInputArgs) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = "auto";
const lineHeight = parseInt(
window.getComputedStyle(textarea).lineHeight,
10,
);
const maxHeight = lineHeight * maxRows;
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
textarea.style.overflowY =
textarea.scrollHeight > maxHeight ? "auto" : "hidden";
}, [value, maxRows]);
const handleSend = useCallback(() => {
if (disabled || !value.trim()) return;
onSend(value.trim());
setValue("");
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
}, [value, onSend, disabled]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
},
[handleSend],
);
return {
value,
setValue,
handleKeyDown,
handleSend,
textareaRef,
};
}

View File

@@ -0,0 +1,31 @@
import React from "react";
import { Text } from "@/components/atoms/Text/Text";
import { ArrowClockwiseIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
export interface ChatLoadingStateProps {
message?: string;
className?: string;
}
export function ChatLoadingState({
message = "Loading...",
className,
}: ChatLoadingStateProps) {
return (
<div
className={cn("flex flex-1 items-center justify-center p-6", className)}
>
<div className="flex flex-col items-center gap-4 text-center">
<ArrowClockwiseIcon
size={32}
weight="bold"
className="animate-spin text-purple-500"
/>
<Text variant="body" className="text-zinc-600 dark:text-zinc-400">
{message}
</Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,238 @@
"use client";
import { cn } from "@/lib/utils";
import { RobotIcon, UserIcon, CheckCircleIcon } from "@phosphor-icons/react";
import { useCallback } from "react";
import { MessageBubble } from "@/app/(platform)/chat/components/MessageBubble/MessageBubble";
import { MarkdownContent } from "@/app/(platform)/chat/components/MarkdownContent/MarkdownContent";
import { ToolCallMessage } from "@/app/(platform)/chat/components/ToolCallMessage/ToolCallMessage";
import { ToolResponseMessage } from "@/app/(platform)/chat/components/ToolResponseMessage/ToolResponseMessage";
import { AuthPromptWidget } from "@/app/(platform)/chat/components/AuthPromptWidget/AuthPromptWidget";
import { ChatCredentialsSetup } from "@/app/(platform)/chat/components/ChatCredentialsSetup/ChatCredentialsSetup";
import { NoResultsMessage } from "@/app/(platform)/chat/components/NoResultsMessage/NoResultsMessage";
import { AgentCarouselMessage } from "@/app/(platform)/chat/components/AgentCarouselMessage/AgentCarouselMessage";
import { ExecutionStartedMessage } from "@/app/(platform)/chat/components/ExecutionStartedMessage/ExecutionStartedMessage";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useChatMessage, type ChatMessageData } from "./useChatMessage";
export interface ChatMessageProps {
message: ChatMessageData;
className?: string;
onDismissLogin?: () => void;
onDismissCredentials?: () => void;
onSendMessage?: (content: string, isUserMessage?: boolean) => void;
}
export function ChatMessage({
message,
className,
onDismissCredentials,
onSendMessage,
}: ChatMessageProps) {
const { user } = useSupabase();
const {
formattedTimestamp,
isUser,
isAssistant,
isToolCall,
isToolResponse,
isLoginNeeded,
isCredentialsNeeded,
isNoResults,
isAgentCarousel,
isExecutionStarted,
} = useChatMessage(message);
const handleAllCredentialsComplete = useCallback(
function handleAllCredentialsComplete() {
// Send a user message that explicitly asks to retry the setup
// This ensures the LLM calls get_required_setup_info again and proceeds with execution
if (onSendMessage) {
onSendMessage(
"I've configured the required credentials. Please check if everything is ready and proceed with setting up the agent.",
);
}
// Optionally dismiss the credentials prompt
if (onDismissCredentials) {
onDismissCredentials();
}
},
[onSendMessage, onDismissCredentials],
);
function handleCancelCredentials() {
// Dismiss the credentials prompt
if (onDismissCredentials) {
onDismissCredentials();
}
}
// Render credentials needed messages
if (isCredentialsNeeded && message.type === "credentials_needed") {
return (
<ChatCredentialsSetup
credentials={message.credentials}
agentName={message.agentName}
message={message.message}
onAllCredentialsComplete={handleAllCredentialsComplete}
onCancel={handleCancelCredentials}
className={className}
/>
);
}
// Render login needed messages
if (isLoginNeeded && message.type === "login_needed") {
// If user is already logged in, show success message instead of auth prompt
if (user) {
return (
<div className={cn("px-4 py-2", className)}>
<div className="my-4 overflow-hidden rounded-lg border border-green-200 bg-gradient-to-br from-green-50 to-emerald-50 dark:border-green-800 dark:from-green-950/30 dark:to-emerald-950/30">
<div className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-600">
<CheckCircleIcon
size={20}
weight="fill"
className="text-white"
/>
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Successfully Authenticated
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
You&apos;re now signed in and ready to continue
</p>
</div>
</div>
</div>
</div>
</div>
);
}
// Show auth prompt if not logged in
return (
<div className={cn("px-4 py-2", className)}>
<AuthPromptWidget
message={message.message}
sessionId={message.sessionId}
agentInfo={message.agentInfo}
returnUrl="/chat"
/>
</div>
);
}
// Render tool call messages
if (isToolCall && message.type === "tool_call") {
return (
<div className={cn("px-4 py-2", className)}>
<ToolCallMessage
toolId={message.toolId}
toolName={message.toolName}
arguments={message.arguments}
/>
</div>
);
}
// Render tool response messages
if (isToolResponse && message.type === "tool_response") {
return (
<div className={cn("px-4 py-2", className)}>
<ToolResponseMessage
toolId={message.toolId}
toolName={message.toolName}
result={message.result}
success={message.success}
/>
</div>
);
}
// Render no results messages
if (isNoResults && message.type === "no_results") {
return (
<NoResultsMessage
message={message.message}
suggestions={message.suggestions}
className={className}
/>
);
}
// Render agent carousel messages
if (isAgentCarousel && message.type === "agent_carousel") {
return (
<AgentCarouselMessage
agents={message.agents}
totalCount={message.totalCount}
className={className}
/>
);
}
// Render execution started messages
if (isExecutionStarted && message.type === "execution_started") {
return (
<ExecutionStartedMessage
executionId={message.executionId}
agentName={message.agentName}
message={message.message}
className={className}
/>
);
}
// Render regular chat messages
if (message.type === "message") {
return (
<div
className={cn(
"flex gap-3 px-4 py-4",
isUser && "flex-row-reverse",
className,
)}
>
{/* Avatar */}
<div className="flex-shrink-0">
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full",
isUser && "bg-zinc-200 dark:bg-zinc-700",
isAssistant && "bg-purple-600 dark:bg-purple-500",
)}
>
{isUser ? (
<UserIcon className="h-5 w-5 text-zinc-700 dark:text-zinc-200" />
) : (
<RobotIcon className="h-5 w-5 text-white" />
)}
</div>
</div>
{/* Message Content */}
<div className={cn("flex max-w-[70%] flex-col", isUser && "items-end")}>
<MessageBubble variant={isUser ? "user" : "assistant"}>
<MarkdownContent content={message.content} />
</MessageBubble>
{/* Timestamp */}
<span
className={cn(
"mt-1 text-xs text-zinc-500 dark:text-zinc-400",
isUser && "text-right",
)}
>
{formattedTimestamp}
</span>
</div>
</div>
);
}
// Fallback for unknown message types
return null;
}

View File

@@ -0,0 +1,94 @@
import { formatDistanceToNow } from "date-fns";
import type { ToolArguments, ToolResult } from "@/types/chat";
export type ChatMessageData =
| {
type: "message";
role: "user" | "assistant" | "system";
content: string;
timestamp?: string | Date;
}
| {
type: "tool_call";
toolId: string;
toolName: string;
arguments?: ToolArguments;
timestamp?: string | Date;
}
| {
type: "tool_response";
toolId: string;
toolName: string;
result: ToolResult;
success?: boolean;
timestamp?: string | Date;
}
| {
type: "login_needed";
message: string;
sessionId: string;
agentInfo?: {
graph_id: string;
name: string;
trigger_type: string;
};
timestamp?: string | Date;
}
| {
type: "credentials_needed";
credentials: Array<{
provider: string;
providerName: string;
credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped";
title: string;
scopes?: string[];
}>;
message: string;
agentName?: string;
timestamp?: string | Date;
}
| {
type: "no_results";
message: string;
suggestions?: string[];
sessionId?: string;
timestamp?: string | Date;
}
| {
type: "agent_carousel";
agents: Array<{
id: string;
name: string;
description: string;
version?: number;
}>;
totalCount?: number;
timestamp?: string | Date;
}
| {
type: "execution_started";
executionId: string;
agentName?: string;
message?: string;
timestamp?: string | Date;
};
export function useChatMessage(message: ChatMessageData) {
const formattedTimestamp = message.timestamp
? formatDistanceToNow(new Date(message.timestamp), { addSuffix: true })
: "Just now";
return {
formattedTimestamp,
isUser: message.type === "message" && message.role === "user",
isAssistant: message.type === "message" && message.role === "assistant",
isSystem: message.type === "message" && message.role === "system",
isToolCall: message.type === "tool_call",
isToolResponse: message.type === "tool_response",
isLoginNeeded: message.type === "login_needed",
isCredentialsNeeded: message.type === "credentials_needed",
isNoResults: message.type === "no_results",
isAgentCarousel: message.type === "agent_carousel",
isExecutionStarted: message.type === "execution_started",
};
}

View File

@@ -0,0 +1,106 @@
import React from "react";
import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
import { CheckCircle, Play, ArrowSquareOut } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
export interface ExecutionStartedMessageProps {
executionId: string;
agentName?: string;
message?: string;
onViewExecution?: () => void;
className?: string;
}
export function ExecutionStartedMessage({
executionId,
agentName,
message = "Agent execution started successfully",
onViewExecution,
className,
}: ExecutionStartedMessageProps) {
return (
<div
className={cn(
"mx-4 my-2 flex flex-col gap-4 rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-900 dark:bg-green-950",
className,
)}
>
{/* Icon & Header */}
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-green-500">
<CheckCircle size={24} weight="bold" className="text-white" />
</div>
<div className="flex-1">
<Text
variant="h3"
className="mb-1 text-green-900 dark:text-green-100"
>
Execution Started
</Text>
<Text variant="body" className="text-green-700 dark:text-green-300">
{message}
</Text>
</div>
</div>
{/* Details */}
<div className="rounded-md bg-green-100 p-4 dark:bg-green-900">
<div className="space-y-2">
{agentName && (
<div className="flex items-center justify-between">
<Text
variant="small"
className="font-semibold text-green-900 dark:text-green-100"
>
Agent:
</Text>
<Text
variant="body"
className="text-green-800 dark:text-green-200"
>
{agentName}
</Text>
</div>
)}
<div className="flex items-center justify-between">
<Text
variant="small"
className="font-semibold text-green-900 dark:text-green-100"
>
Execution ID:
</Text>
<Text
variant="small"
className="font-mono text-green-800 dark:text-green-200"
>
{executionId.slice(0, 16)}...
</Text>
</div>
</div>
</div>
{/* Action Buttons */}
{onViewExecution && (
<div className="flex gap-3">
<Button
onClick={onViewExecution}
variant="primary"
className="flex flex-1 items-center justify-center gap-2"
>
<ArrowSquareOut size={20} weight="bold" />
View Execution
</Button>
</div>
)}
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<Play size={16} weight="fill" />
<Text variant="small">
Your agent is now running. You can monitor its progress in the monitor
page.
</Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,236 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
interface MarkdownContentProps {
content: string;
className?: string;
}
interface CodeProps extends React.HTMLAttributes<HTMLElement> {
children?: React.ReactNode;
className?: string;
}
interface ListProps extends React.HTMLAttributes<HTMLUListElement> {
children?: React.ReactNode;
className?: string;
}
interface ListItemProps extends React.HTMLAttributes<HTMLLIElement> {
children?: React.ReactNode;
className?: string;
}
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
type?: string;
}
export function MarkdownContent({ content, className }: MarkdownContentProps) {
return (
<div className={cn("markdown-content", className)}>
<ReactMarkdown
skipHtml={true}
remarkPlugins={[remarkGfm]}
components={{
code: ({ children, className, ...props }: CodeProps) => {
const isInline = !className?.includes("language-");
if (isInline) {
return (
<code
className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-sm text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200"
{...props}
>
{children}
</code>
);
}
return (
<code
className="font-mono text-sm text-zinc-100 dark:text-zinc-200"
{...props}
>
{children}
</code>
);
},
pre: ({ children, ...props }) => (
<pre
className="my-2 overflow-x-auto rounded-md bg-zinc-900 p-3 dark:bg-zinc-950"
{...props}
>
{children}
</pre>
),
a: ({ children, href, ...props }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-purple-600 underline decoration-1 underline-offset-2 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
{...props}
>
{children}
</a>
),
strong: ({ children, ...props }) => (
<strong className="font-semibold" {...props}>
{children}
</strong>
),
em: ({ children, ...props }) => (
<em className="italic" {...props}>
{children}
</em>
),
del: ({ children, ...props }) => (
<del className="line-through opacity-70" {...props}>
{children}
</del>
),
ul: ({ children, ...props }: ListProps) => (
<ul
className={cn(
"my-2 space-y-1 pl-6",
props.className?.includes("contains-task-list")
? "list-none pl-0"
: "list-disc",
)}
{...props}
>
{children}
</ul>
),
ol: ({ children, ...props }) => (
<ol className="my-2 list-decimal space-y-1 pl-6" {...props}>
{children}
</ol>
),
li: ({ children, ...props }: ListItemProps) => (
<li
className={cn(
props.className?.includes("task-list-item")
? "flex items-start"
: "",
)}
{...props}
>
{children}
</li>
),
input: ({ ...props }: InputProps) => {
if (props.type === "checkbox") {
return (
<input
type="checkbox"
className="mr-2 h-4 w-4 rounded border-zinc-300 text-purple-600 focus:ring-purple-500 disabled:cursor-not-allowed disabled:opacity-70 dark:border-zinc-600"
disabled
{...props}
/>
);
}
return <input {...props} />;
},
blockquote: ({ children, ...props }) => (
<blockquote
className="my-2 border-l-4 border-zinc-300 pl-3 italic text-zinc-700 dark:border-zinc-600 dark:text-zinc-300"
{...props}
>
{children}
</blockquote>
),
h1: ({ children, ...props }) => (
<h1
className="my-2 text-xl font-bold text-zinc-900 dark:text-zinc-100"
{...props}
>
{children}
</h1>
),
h2: ({ children, ...props }) => (
<h2
className="my-2 text-lg font-semibold text-zinc-800 dark:text-zinc-200"
{...props}
>
{children}
</h2>
),
h3: ({ children, ...props }) => (
<h3
className="my-1 text-base font-semibold text-zinc-800 dark:text-zinc-200"
{...props}
>
{children}
</h3>
),
h4: ({ children, ...props }) => (
<h4
className="my-1 text-sm font-medium text-zinc-700 dark:text-zinc-300"
{...props}
>
{children}
</h4>
),
h5: ({ children, ...props }) => (
<h5
className="my-1 text-sm font-medium text-zinc-700 dark:text-zinc-300"
{...props}
>
{children}
</h5>
),
h6: ({ children, ...props }) => (
<h6
className="my-1 text-xs font-medium text-zinc-600 dark:text-zinc-400"
{...props}
>
{children}
</h6>
),
p: ({ children, ...props }) => (
<p className="my-2 leading-relaxed" {...props}>
{children}
</p>
),
hr: ({ ...props }) => (
<hr
className="my-3 border-zinc-300 dark:border-zinc-700"
{...props}
/>
),
table: ({ children, ...props }) => (
<div className="my-2 overflow-x-auto">
<table
className="min-w-full divide-y divide-zinc-200 rounded border border-zinc-200 dark:divide-zinc-700 dark:border-zinc-700"
{...props}
>
{children}
</table>
</div>
),
th: ({ children, ...props }) => (
<th
className="bg-zinc-50 px-3 py-2 text-left text-xs font-semibold text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
{...props}
>
{children}
</th>
),
td: ({ children, ...props }) => (
<td
className="border-t border-zinc-200 px-3 py-2 text-sm dark:border-zinc-700"
{...props}
>
{children}
</td>
),
}}
>
{content}
</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils";
import { ReactNode } from "react";
export interface MessageBubbleProps {
children: ReactNode;
variant: "user" | "assistant";
className?: string;
}
export function MessageBubble({
children,
variant,
className,
}: MessageBubbleProps) {
return (
<div
className={cn(
"rounded-lg px-4 py-3 text-sm",
variant === "user" && "bg-violet-600 text-white dark:bg-violet-500",
variant === "assistant" &&
"border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100",
className,
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { cn } from "@/lib/utils";
import { ChatMessage } from "../ChatMessage/ChatMessage";
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
import { StreamingMessage } from "../StreamingMessage/StreamingMessage";
import { useMessageList } from "./useMessageList";
export interface MessageListProps {
messages: ChatMessageData[];
streamingChunks?: string[];
isStreaming?: boolean;
className?: string;
onStreamComplete?: () => void;
onSendMessage?: (content: string) => void;
}
export function MessageList({
messages,
streamingChunks = [],
isStreaming = false,
className,
onStreamComplete,
onSendMessage,
}: MessageListProps) {
const { messagesEndRef, messagesContainerRef } = useMessageList({
messageCount: messages.length,
isStreaming,
});
return (
<div
ref={messagesContainerRef}
className={cn(
"flex-1 overflow-y-auto",
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700",
className,
)}
>
<div className="space-y-0">
{/* Render all persisted messages */}
{messages.map((message, index) => (
<ChatMessage
key={index}
message={message}
onSendMessage={onSendMessage}
/>
))}
{/* Render streaming message if active */}
{isStreaming && streamingChunks.length > 0 && (
<StreamingMessage
chunks={streamingChunks}
onComplete={onStreamComplete}
/>
)}
{/* Invisible div to scroll to */}
<div ref={messagesEndRef} />
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { useEffect, useRef, useCallback } from "react";
interface UseMessageListArgs {
messageCount: number;
isStreaming: boolean;
}
export function useMessageList({
messageCount,
isStreaming,
}: UseMessageListArgs) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messageCount, isStreaming, scrollToBottom]);
return {
messagesEndRef,
messagesContainerRef,
scrollToBottom,
};
}

View File

@@ -0,0 +1,68 @@
import React from "react";
import { Text } from "@/components/atoms/Text/Text";
import { MagnifyingGlass, X } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
export interface NoResultsMessageProps {
message: string;
suggestions?: string[];
className?: string;
}
export function NoResultsMessage({
message,
suggestions = [],
className,
}: NoResultsMessageProps) {
return (
<div
className={cn(
"mx-4 my-2 flex flex-col items-center gap-4 rounded-lg border border-gray-200 bg-gray-50 p-6 dark:border-gray-800 dark:bg-gray-900",
className,
)}
>
{/* Icon */}
<div className="relative flex h-16 w-16 items-center justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-200 dark:bg-gray-700">
<MagnifyingGlass size={32} weight="bold" className="text-gray-500" />
</div>
<div className="absolute -right-1 -top-1 flex h-8 w-8 items-center justify-center rounded-full bg-gray-400 dark:bg-gray-600">
<X size={20} weight="bold" className="text-white" />
</div>
</div>
{/* Content */}
<div className="text-center">
<Text variant="h3" className="mb-2 text-gray-900 dark:text-gray-100">
No Results Found
</Text>
<Text variant="body" className="text-gray-700 dark:text-gray-300">
{message}
</Text>
</div>
{/* Suggestions */}
{suggestions.length > 0 && (
<div className="w-full space-y-2">
<Text
variant="small"
className="font-semibold text-gray-900 dark:text-gray-100"
>
Try these suggestions:
</Text>
<ul className="space-y-1 rounded-md bg-gray-100 p-4 dark:bg-gray-800">
{suggestions.map((suggestion, index) => (
<li
key={index}
className="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300"
>
<span className="mt-1 text-gray-500"></span>
<span>{suggestion}</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,51 @@
import React from "react";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
export interface QuickActionsWelcomeProps {
title: string;
description: string;
actions: string[];
onActionClick: (action: string) => void;
disabled?: boolean;
className?: string;
}
export function QuickActionsWelcome({
title,
description,
actions,
onActionClick,
disabled = false,
className,
}: QuickActionsWelcomeProps) {
return (
<div
className={cn("flex flex-1 items-center justify-center p-4", className)}
>
<div className="max-w-2xl text-center">
<Text
variant="h2"
className="mb-4 text-3xl font-bold text-zinc-900 dark:text-zinc-100"
>
{title}
</Text>
<Text variant="body" className="mb-8 text-zinc-600 dark:text-zinc-400">
{description}
</Text>
<div className="grid gap-2 sm:grid-cols-2">
{actions.map((action) => (
<button
key={action}
onClick={() => onActionClick(action)}
disabled={disabled}
className="rounded-lg border border-zinc-200 bg-white p-4 text-left text-sm hover:bg-zinc-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:bg-zinc-800"
>
{action}
</button>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { cn } from "@/lib/utils";
import { Robot } from "@phosphor-icons/react";
import { MessageBubble } from "@/app/(platform)/chat/components/MessageBubble/MessageBubble";
import { MarkdownContent } from "@/app/(platform)/chat/components/MarkdownContent/MarkdownContent";
import { useStreamingMessage } from "./useStreamingMessage";
export interface StreamingMessageProps {
chunks: string[];
className?: string;
onComplete?: () => void;
}
export function StreamingMessage({
chunks,
className,
onComplete,
}: StreamingMessageProps) {
const { displayText } = useStreamingMessage({ chunks, onComplete });
return (
<div className={cn("flex gap-3 px-4 py-4", className)}>
{/* Avatar */}
<div className="flex-shrink-0">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-600 dark:bg-purple-500">
<Robot className="h-5 w-5 text-white" />
</div>
</div>
{/* Message Content */}
<div className="flex max-w-[70%] flex-col">
<MessageBubble variant="assistant">
<MarkdownContent content={displayText} />
</MessageBubble>
{/* Timestamp */}
<span className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Typing...
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
interface UseStreamingMessageArgs {
chunks: string[];
onComplete?: () => void;
}
export function useStreamingMessage({
chunks,
onComplete,
}: UseStreamingMessageArgs) {
const [isComplete, _setIsComplete] = useState(false);
const displayText = chunks.join("");
useEffect(() => {
if (isComplete && onComplete) {
onComplete();
}
}, [isComplete, onComplete]);
return {
displayText,
isComplete,
};
}

View File

@@ -0,0 +1,108 @@
import React, { useState } from "react";
import { Text } from "@/components/atoms/Text/Text";
import { Wrench, Spinner, CaretDown, CaretUp } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { getToolDisplayName } from "@/app/(platform)/chat/helpers";
import type { ToolArguments } from "@/types/chat";
export interface ToolCallMessageProps {
toolId: string;
toolName: string;
arguments?: ToolArguments;
className?: string;
}
export function ToolCallMessage({
toolId,
toolName,
arguments: args,
className,
}: ToolCallMessageProps) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div
className={cn(
"overflow-hidden rounded-lg border transition-all duration-200",
"border-neutral-200 dark:border-neutral-700",
"bg-white dark:bg-neutral-900",
"animate-in fade-in-50 slide-in-from-top-1",
className,
)}
>
{/* Header */}
<div
className={cn(
"flex items-center justify-between px-3 py-2",
"bg-gradient-to-r from-neutral-50 to-neutral-100 dark:from-neutral-800/20 dark:to-neutral-700/20",
)}
>
<div className="flex items-center gap-2">
<Wrench
size={16}
weight="bold"
className="text-neutral-500 dark:text-neutral-400"
/>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{getToolDisplayName(toolName)}
</span>
<div className="ml-2 flex items-center gap-1.5">
<Spinner
size={16}
weight="bold"
className="animate-spin text-blue-500"
/>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
Executing...
</span>
</div>
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded p-1 hover:bg-neutral-200/50 dark:hover:bg-neutral-700/50"
aria-label={isExpanded ? "Collapse details" : "Expand details"}
>
{isExpanded ? (
<CaretUp
size={16}
weight="bold"
className="text-neutral-600 dark:text-neutral-400"
/>
) : (
<CaretDown
size={16}
weight="bold"
className="text-neutral-600 dark:text-neutral-400"
/>
)}
</button>
</div>
{/* Expandable Content */}
{isExpanded && (
<div className="px-4 py-3">
{args && Object.keys(args).length > 0 && (
<div className="mb-3">
<div className="mb-2 text-xs font-medium text-neutral-600 dark:text-neutral-400">
Parameters:
</div>
<div className="rounded-md bg-neutral-50 p-3 dark:bg-neutral-800">
<pre className="overflow-x-auto text-xs text-neutral-700 dark:text-neutral-300">
{JSON.stringify(args, null, 2)}
</pre>
</div>
</div>
)}
<Text
variant="small"
className="text-neutral-500 dark:text-neutral-400"
>
Tool ID: {toolId.slice(0, 8)}...
</Text>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,200 @@
import React, { useState } from "react";
import { Text } from "@/components/atoms/Text/Text";
import {
CheckCircle,
XCircle,
CaretDown,
CaretUp,
Wrench,
} from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { getToolDisplayName } from "@/app/(platform)/chat/helpers";
import type { ToolResult } from "@/types/chat";
export interface ToolResponseMessageProps {
toolId: string;
toolName: string;
result: ToolResult;
success?: boolean;
className?: string;
}
// Check if result should be hidden (special response types)
function shouldHideResult(result: ToolResult): boolean {
try {
const resultString =
typeof result === "string" ? result : JSON.stringify(result);
const parsed = JSON.parse(resultString);
// Hide raw JSON for these special types
if (parsed.type === "agent_carousel") return true;
if (parsed.type === "execution_started") return true;
if (parsed.type === "setup_requirements") return true;
if (parsed.type === "no_results") return true;
return false;
} catch {
return false;
}
}
// Get a friendly summary for special response types
function getResultSummary(result: ToolResult): string | null {
try {
const resultString =
typeof result === "string" ? result : JSON.stringify(result);
const parsed = JSON.parse(resultString);
if (parsed.type === "agent_carousel") {
return `Found ${parsed.agents?.length || parsed.count || 0} agents${parsed.query ? ` matching "${parsed.query}"` : ""}`;
}
if (parsed.type === "execution_started") {
return `Started execution${parsed.execution_id ? ` (ID: ${parsed.execution_id.slice(0, 8)}...)` : ""}`;
}
if (parsed.type === "setup_requirements") {
return "Retrieved setup requirements";
}
if (parsed.type === "no_results") {
return parsed.message || "No results found";
}
return null;
} catch {
return null;
}
}
export function ToolResponseMessage({
toolId,
toolName,
result,
success = true,
className,
}: ToolResponseMessageProps) {
const [isExpanded, setIsExpanded] = useState(false);
const hideResult = shouldHideResult(result);
const resultSummary = getResultSummary(result);
const resultString =
typeof result === "object"
? JSON.stringify(result, null, 2)
: String(result);
const shouldTruncate = resultString.length > 200;
return (
<div
className={cn(
"overflow-hidden rounded-lg border transition-all duration-200",
success
? "border-neutral-200 dark:border-neutral-700"
: "border-red-200 dark:border-red-800",
"bg-white dark:bg-neutral-900",
"animate-in fade-in-50 slide-in-from-top-1",
className,
)}
>
{/* Header */}
<div
className={cn(
"flex items-center justify-between px-3 py-2",
"bg-gradient-to-r",
success
? "from-neutral-50 to-neutral-100 dark:from-neutral-800/20 dark:to-neutral-700/20"
: "from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20",
)}
>
<div className="flex items-center gap-2">
<Wrench
size={16}
weight="bold"
className="text-neutral-500 dark:text-neutral-400"
/>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{getToolDisplayName(toolName)}
</span>
<div className="ml-2 flex items-center gap-1.5">
{success ? (
<CheckCircle size={16} weight="fill" className="text-green-500" />
) : (
<XCircle size={16} weight="fill" className="text-red-500" />
)}
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{success ? "Completed" : "Error"}
</span>
</div>
</div>
{!hideResult && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded p-1 hover:bg-neutral-200/50 dark:hover:bg-neutral-700/50"
aria-label={isExpanded ? "Collapse details" : "Expand details"}
>
{isExpanded ? (
<CaretUp
size={16}
weight="bold"
className="text-neutral-600 dark:text-neutral-400"
/>
) : (
<CaretDown
size={16}
weight="bold"
className="text-neutral-600 dark:text-neutral-400"
/>
)}
</button>
)}
</div>
{/* Expandable Content */}
{isExpanded && !hideResult && (
<div className="px-4 py-3">
<div className="mb-2 text-xs font-medium text-neutral-600 dark:text-neutral-400">
Result:
</div>
<div
className={cn(
"rounded-md p-3",
success
? "bg-green-50 dark:bg-green-900/20"
: "bg-red-50 dark:bg-red-900/20",
)}
>
<pre
className={cn(
"whitespace-pre-wrap text-xs",
success
? "text-green-800 dark:text-green-200"
: "text-red-800 dark:text-red-200",
)}
>
{shouldTruncate && !isExpanded
? `${resultString.slice(0, 200)}...`
: resultString}
</pre>
</div>
<Text
variant="small"
className="mt-2 text-neutral-500 dark:text-neutral-400"
>
Tool ID: {toolId.slice(0, 8)}...
</Text>
</div>
)}
{/* Summary for special response types */}
{hideResult && resultSummary && (
<div className="px-4 py-2">
<Text
variant="small"
className="text-neutral-600 dark:text-neutral-400"
>
{resultSummary}
</Text>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
/**
* Maps internal tool names to user-friendly display names with emojis.
*
* @param toolName - The internal tool name from the backend
* @returns A user-friendly display name with an emoji prefix
*/
export function getToolDisplayName(toolName: string): string {
const toolDisplayNames: Record<string, string> = {
find_agent: "🔍 Search Marketplace",
get_agent_details: "📋 Get Agent Details",
check_credentials: "🔑 Check Credentials",
setup_agent: "⚙️ Setup Agent",
run_agent: "▶️ Run Agent",
get_required_setup_info: "📝 Get Setup Requirements",
};
return toolDisplayNames[toolName] || toolName;
}
/** Validate UUID v4 format */
export function isValidUUID(value: string): boolean {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(value);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { useChatPage } from "./useChatPage";
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState";
import { ChatLoadingState } from "./components/ChatLoadingState/ChatLoadingState";
import { useGetFlag, Flag } from "@/services/feature-flags/use-get-flag";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function ChatPage() {
const isChatEnabled = useGetFlag(Flag.CHAT);
const router = useRouter();
const {
messages,
isLoading,
isCreating,
error,
sessionId,
createSession,
clearSession,
refreshSession,
} = useChatPage();
useEffect(() => {
if (isChatEnabled === false) {
router.push("/404");
}
}, [isChatEnabled, router]);
if (isChatEnabled === null || isChatEnabled === false) {
return null;
}
return (
<div className="flex h-full flex-col">
{/* Header */}
<header className="border-b border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
<div className="container mx-auto flex items-center justify-between">
<h1 className="text-xl font-semibold">Chat</h1>
{sessionId && (
<div className="flex items-center gap-4">
<span className="text-sm text-zinc-600 dark:text-zinc-400">
Session: {sessionId.slice(0, 8)}...
</span>
<button
onClick={clearSession}
className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
New Chat
</button>
</div>
)}
</div>
</header>
{/* Main Content */}
<main className="container mx-auto flex flex-1 flex-col overflow-hidden">
{/* Loading State - show when explicitly loading/creating OR when we don't have a session yet and no error */}
{(isLoading || isCreating || (!sessionId && !error)) && (
<ChatLoadingState
message={isCreating ? "Creating session..." : "Loading..."}
/>
)}
{/* Error State */}
{error && !isLoading && (
<ChatErrorState error={error} onRetry={createSession} />
)}
{/* Session Content */}
{sessionId && !isLoading && !error && (
<ChatContainer
sessionId={sessionId}
initialMessages={messages}
onRefreshSession={refreshSession}
className="flex-1"
/>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { useChatSession } from "@/app/(platform)/chat/useChatSession";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useChatStream } from "@/app/(platform)/chat/useChatStream";
export function useChatPage() {
const router = useRouter();
const searchParams = useSearchParams();
const urlSessionId =
searchParams.get("session_id") || searchParams.get("session");
const hasCreatedSessionRef = useRef(false);
const hasClaimedSessionRef = useRef(false);
const { user } = useSupabase();
const { sendMessage: sendStreamMessage } = useChatStream();
const {
session,
sessionId: sessionIdFromHook,
messages,
isLoading,
isCreating,
error,
createSession,
refreshSession,
claimSession,
clearSession: clearSessionBase,
} = useChatSession({
urlSessionId,
autoCreate: false,
});
useEffect(
function autoCreateSession() {
if (
!urlSessionId &&
!hasCreatedSessionRef.current &&
!isCreating &&
!sessionIdFromHook
) {
hasCreatedSessionRef.current = true;
createSession().catch((_err) => {
hasCreatedSessionRef.current = false;
});
}
},
[urlSessionId, isCreating, sessionIdFromHook, createSession],
);
useEffect(
function autoClaimSession() {
if (
session &&
!session.user_id &&
user &&
!hasClaimedSessionRef.current &&
!isLoading &&
sessionIdFromHook
) {
hasClaimedSessionRef.current = true;
claimSession(sessionIdFromHook)
.then(() => {
sendStreamMessage(
sessionIdFromHook,
"User has successfully logged in.",
() => {},
false,
).catch(() => {});
})
.catch(() => {
hasClaimedSessionRef.current = false;
});
}
},
[
session,
user,
isLoading,
sessionIdFromHook,
claimSession,
sendStreamMessage,
],
);
useEffect(function monitorNetworkStatus() {
function handleOnline() {
toast.success("Connection restored", {
description: "You're back online",
});
}
function handleOffline() {
toast.error("You're offline", {
description: "Check your internet connection",
});
}
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
function clearSession() {
clearSessionBase();
hasCreatedSessionRef.current = false;
hasClaimedSessionRef.current = false;
router.push("/chat");
}
return {
session,
messages,
isLoading,
isCreating,
error,
createSession,
refreshSession,
clearSession,
sessionId: sessionIdFromHook,
};
}

View File

@@ -0,0 +1,254 @@
import { useCallback, useEffect, useState, useRef, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
usePostV2CreateSession,
postV2CreateSession,
useGetV2GetSession,
usePatchV2SessionAssignUser,
getGetV2GetSessionQueryKey,
} from "@/app/api/__generated__/endpoints/chat/chat";
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
import { storage, Key } from "@/services/storage/local-storage";
import { isValidUUID } from "@/app/(platform)/chat/helpers";
interface UseChatSessionArgs {
urlSessionId?: string | null;
autoCreate?: boolean;
}
export function useChatSession({
urlSessionId,
autoCreate = false,
}: UseChatSessionArgs = {}) {
const queryClient = useQueryClient();
const [sessionId, setSessionId] = useState<string | null>(null);
const [error, setError] = useState<Error | null>(null);
const justCreatedSessionIdRef = useRef<string | null>(null);
useEffect(() => {
if (urlSessionId) {
if (!isValidUUID(urlSessionId)) {
console.error("Invalid session ID format:", urlSessionId);
toast.error("Invalid session ID", {
description:
"The session ID in the URL is not valid. Starting a new session...",
});
setSessionId(null);
storage.clean(Key.CHAT_SESSION_ID);
return;
}
setSessionId(urlSessionId);
storage.set(Key.CHAT_SESSION_ID, urlSessionId);
} else {
const storedSessionId = storage.get(Key.CHAT_SESSION_ID);
if (storedSessionId) {
if (!isValidUUID(storedSessionId)) {
console.error("Invalid stored session ID:", storedSessionId);
storage.clean(Key.CHAT_SESSION_ID);
setSessionId(null);
} else {
setSessionId(storedSessionId);
}
} else if (autoCreate) {
setSessionId(null);
}
}
}, [urlSessionId, autoCreate]);
const {
mutateAsync: createSessionMutation,
isPending: isCreating,
error: createError,
} = usePostV2CreateSession();
const {
data: sessionData,
isLoading: isLoadingSession,
error: loadError,
refetch,
} = useGetV2GetSession(sessionId || "", {
query: {
enabled: !!sessionId,
staleTime: 30000,
retry: 1,
},
});
const { mutateAsync: claimSessionMutation } = usePatchV2SessionAssignUser();
const session = useMemo(() => {
if (sessionData?.status === 200) {
return sessionData.data;
}
if (sessionId && justCreatedSessionIdRef.current === sessionId) {
return {
id: sessionId,
user_id: null,
messages: [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as SessionDetailResponse;
}
return null;
}, [sessionData, sessionId]);
const messages = session?.messages || [];
const isLoading = isCreating || isLoadingSession;
useEffect(() => {
if (createError) {
setError(
createError instanceof Error
? createError
: new Error("Failed to create session"),
);
} else if (loadError) {
setError(
loadError instanceof Error
? loadError
: new Error("Failed to load session"),
);
} else {
setError(null);
}
}, [createError, loadError]);
const createSession = useCallback(
async function createSession() {
try {
setError(null);
const response = await postV2CreateSession({
body: JSON.stringify({}),
});
if (response.status !== 200) {
throw new Error("Failed to create session");
}
const newSessionId = response.data.id;
setSessionId(newSessionId);
storage.set(Key.CHAT_SESSION_ID, newSessionId);
justCreatedSessionIdRef.current = newSessionId;
setTimeout(() => {
if (justCreatedSessionIdRef.current === newSessionId) {
justCreatedSessionIdRef.current = null;
}
}, 10000);
return newSessionId;
} catch (err) {
const error =
err instanceof Error ? err : new Error("Failed to create session");
setError(error);
toast.error("Failed to create chat session", {
description: error.message,
});
throw error;
}
},
[createSessionMutation],
);
const loadSession = useCallback(
async function loadSession(id: string) {
try {
setError(null);
setSessionId(id);
storage.set(Key.CHAT_SESSION_ID, id);
const result = await refetch();
if (!result.data || result.isError) {
console.warn("Session not found on server, clearing local state");
storage.clean(Key.CHAT_SESSION_ID);
setSessionId(null);
throw new Error("Session not found");
}
} catch (err) {
const error =
err instanceof Error ? err : new Error("Failed to load session");
setError(error);
throw error;
}
},
[refetch],
);
const refreshSession = useCallback(
async function refreshSession() {
if (!sessionId) {
console.log("[refreshSession] Skipping - no session ID");
return;
}
try {
setError(null);
await refetch();
} catch (err) {
const error =
err instanceof Error ? err : new Error("Failed to refresh session");
setError(error);
throw error;
}
},
[sessionId, refetch],
);
const claimSession = useCallback(
async function claimSession(id: string) {
try {
setError(null);
await claimSessionMutation({ sessionId: id });
if (justCreatedSessionIdRef.current === id) {
justCreatedSessionIdRef.current = null;
}
await queryClient.invalidateQueries({
queryKey: getGetV2GetSessionQueryKey(id),
});
await refetch();
toast.success("Session claimed successfully", {
description: "Your chat history has been saved to your account",
});
} catch (err: unknown) {
const error =
err instanceof Error ? err : new Error("Failed to claim session");
const is404 =
(typeof err === "object" &&
err !== null &&
"status" in err &&
err.status === 404) ||
(typeof err === "object" &&
err !== null &&
"response" in err &&
typeof err.response === "object" &&
err.response !== null &&
"status" in err.response &&
err.response.status === 404);
if (!is404) {
setError(error);
toast.error("Failed to claim session", {
description: error.message || "Unable to claim session",
});
}
throw error;
}
},
[claimSessionMutation, queryClient, refetch],
);
const clearSession = useCallback(function clearSession() {
setSessionId(null);
setError(null);
storage.clean(Key.CHAT_SESSION_ID);
justCreatedSessionIdRef.current = null;
}, []);
return {
session,
sessionId,
messages,
isLoading,
isCreating,
error,
createSession,
loadSession,
refreshSession,
claimSession,
clearSession,
};
}

View File

@@ -0,0 +1,211 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { toast } from "sonner";
import type { ToolArguments, ToolResult } from "@/types/chat";
const MAX_RETRIES = 3;
const INITIAL_RETRY_DELAY = 1000;
export interface StreamChunk {
type:
| "text_chunk"
| "text_ended"
| "tool_call"
| "tool_call_start"
| "tool_response"
| "login_needed"
| "need_login"
| "credentials_needed"
| "error"
| "usage"
| "stream_end";
timestamp?: string;
content?: string;
message?: string;
tool_id?: string;
tool_name?: string;
arguments?: ToolArguments;
result?: ToolResult;
success?: boolean;
idx?: number;
session_id?: string;
agent_info?: {
graph_id: string;
name: string;
trigger_type: string;
};
provider?: string;
provider_name?: string;
credential_type?: string;
scopes?: string[];
title?: string;
[key: string]: unknown;
}
export function useChatStream() {
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<Error | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const retryCountRef = useRef<number>(0);
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const stopStreaming = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
retryCountRef.current = 0;
setIsStreaming(false);
}, []);
useEffect(() => {
return () => {
stopStreaming();
};
}, [stopStreaming]);
const sendMessage = useCallback(
async (
sessionId: string,
message: string,
onChunk: (chunk: StreamChunk) => void,
isUserMessage: boolean = true,
) => {
stopStreaming();
const abortController = new AbortController();
abortControllerRef.current = abortController;
if (abortController.signal.aborted) {
return Promise.reject(new Error("Request aborted"));
}
retryCountRef.current = 0;
setIsStreaming(true);
setError(null);
try {
const url = `/api/chat/sessions/${sessionId}/stream?message=${encodeURIComponent(
message,
)}&is_user_message=${isUserMessage}`;
const eventSource = new EventSource(url);
eventSourceRef.current = eventSource;
abortController.signal.addEventListener("abort", () => {
eventSource.close();
eventSourceRef.current = null;
});
eventSource.onmessage = function (event) {
try {
const chunk = JSON.parse(event.data) as StreamChunk;
if (retryCountRef.current > 0) {
retryCountRef.current = 0;
}
onChunk(chunk);
if (chunk.type === "stream_end") {
stopStreaming();
}
} catch (err) {
const parseError =
err instanceof Error
? err
: new Error("Failed to parse stream chunk");
setError(parseError);
}
};
eventSource.onerror = function (_event) {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (retryCountRef.current < MAX_RETRIES) {
retryCountRef.current += 1;
const retryDelay =
INITIAL_RETRY_DELAY * Math.pow(2, retryCountRef.current - 1);
toast.info("Connection interrupted", {
description: `Retrying in ${retryDelay / 1000} seconds...`,
});
retryTimeoutRef.current = setTimeout(() => {
sendMessage(sessionId, message, onChunk, isUserMessage).catch(
(_err) => {
// Retry failed
},
);
}, retryDelay);
} else {
const streamError = new Error(
"Stream connection failed after multiple retries",
);
setError(streamError);
toast.error("Connection Failed", {
description:
"Unable to connect to chat service. Please try again.",
});
stopStreaming();
}
};
return new Promise<void>((resolve, reject) => {
const cleanup = () => {
eventSource.removeEventListener("message", messageHandler);
eventSource.removeEventListener("error", errorHandler);
};
const messageHandler = (event: MessageEvent) => {
try {
const chunk = JSON.parse(event.data) as StreamChunk;
if (chunk.type === "stream_end") {
cleanup();
resolve();
} else if (chunk.type === "error") {
cleanup();
reject(
new Error(chunk.message || chunk.content || "Stream error"),
);
}
} catch {}
};
const errorHandler = () => {
cleanup();
reject(new Error("Stream connection error"));
};
eventSource.addEventListener("message", messageHandler);
eventSource.addEventListener("error", errorHandler);
});
} catch (err) {
const streamError =
err instanceof Error ? err : new Error("Failed to start stream");
setError(streamError);
setIsStreaming(false);
throw streamError;
}
},
[stopStreaming],
);
return {
isStreaming,
error,
sendMessage,
stopStreaming,
};
}

View File

@@ -4,7 +4,7 @@ import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { environment } from "@/services/environment";
import { loginFormSchema, LoginProvider } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
@@ -14,6 +14,8 @@ export function useLoginPage() {
const [feedback, setFeedback] = useState<string | null>(null);
const [captchaKey, setCaptchaKey] = useState(0);
const router = useRouter();
const searchParams = useSearchParams();
const returnUrl = searchParams.get("returnUrl");
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
@@ -140,8 +142,11 @@ export function useLoginPage() {
setIsLoading(false);
setFeedback(null);
const next =
(result?.next as string) || (result?.onboarding ? "/onboarding" : "/");
// Prioritize returnUrl from query params over backend's onboarding logic
const next = returnUrl
? returnUrl
: (result?.next as string) ||
(result?.onboarding ? "/onboarding" : "/");
if (next) router.push(next);
} catch (error) {
toast({

View File

@@ -0,0 +1,86 @@
import { environment } from "@/services/environment";
import { getServerAuthToken } from "@/lib/autogpt-server-api/helpers";
import { NextRequest } from "next/server";
/**
* SSE Proxy for chat streaming.
* EventSource doesn't support custom headers, so we need a server-side proxy
* that adds authentication and forwards the SSE stream to the client.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> },
) {
const { sessionId } = await params;
const searchParams = request.nextUrl.searchParams;
const message = searchParams.get("message");
const isUserMessage = searchParams.get("is_user_message");
if (!message) {
return new Response("Missing message parameter", { status: 400 });
}
try {
// Get auth token from server-side session
const token = await getServerAuthToken();
// Build backend URL
const backendUrl = environment.getAGPTServerBaseUrl();
const streamUrl = new URL(
`/api/chat/sessions/${sessionId}/stream`,
backendUrl,
);
streamUrl.searchParams.set("message", message);
// Pass is_user_message parameter if provided
if (isUserMessage !== null) {
streamUrl.searchParams.set("is_user_message", isUserMessage);
}
// Forward request to backend with auth header
const headers: Record<string, string> = {
Accept: "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(streamUrl.toString(), {
method: "GET",
headers,
});
if (!response.ok) {
const error = await response.text();
return new Response(error, {
status: response.status,
headers: { "Content-Type": "application/json" },
});
}
// Return the SSE stream directly
return new Response(response.body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
} catch (error) {
console.error("SSE proxy error:", error);
return new Response(
JSON.stringify({
error: "Failed to connect to chat service",
detail: error instanceof Error ? error.message : String(error),
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
}

View File

@@ -1837,6 +1837,7 @@ export enum IconType {
LogOut,
AutoGPTLogo,
Sliders,
Chat,
}
export function getIconForSocial(

View File

@@ -3,6 +3,7 @@
import { IconLaptop } from "@/components/__legacy__/ui/icons";
import { cn } from "@/lib/utils";
import {
ChatsIcon,
CubeIcon,
HouseIcon,
StorefrontIcon,
@@ -10,6 +11,7 @@ import {
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Text } from "../../../atoms/Text/Text";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
const iconWidthClass = "h-5 w-5";
@@ -21,6 +23,7 @@ interface Props {
export function NavbarLink({ name, href }: Props) {
const pathname = usePathname();
const isActive = pathname.includes(href);
const chat_enabled = useGetFlag(Flag.CHAT);
return (
<Link href={href} data-testid={`navbar-link-${name.toLowerCase()}`}>
@@ -63,6 +66,14 @@ export function NavbarLink({ name, href }: Props) {
)}
/>
)}
{chat_enabled && href === "/chat" && (
<ChatsIcon
className={cn(
iconWidthClass,
isActive && "text-white dark:text-black",
)}
/>
)}
<Text
variant="h4"
className={cn(

View File

@@ -1,5 +1,6 @@
"use client";
import { useMemo } from "react";
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
import { IconAutoGPTLogo, IconType } from "@/components/__legacy__/ui/icons";
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
@@ -11,7 +12,7 @@ import { LoginButton } from "./LoginButton";
import { MobileNavBar } from "./MobileNavbar/MobileNavBar";
import { NavbarLink } from "./NavbarLink";
import { Wallet } from "./Wallet/Wallet";
import { useGetFlag, Flag } from "@/services/feature-flags/use-get-flag";
interface NavbarViewProps {
isLoggedIn: boolean;
}
@@ -21,6 +22,7 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
const breakpoint = useBreakpoint();
const isSmallScreen = breakpoint === "sm" || breakpoint === "base";
const dynamicMenuItems = getAccountMenuItems(user?.role);
const isChatEnabled = useGetFlag(Flag.CHAT);
const { data: profile } = useGetV2GetUserProfile({
query: {
@@ -29,6 +31,11 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
},
});
const linksWithChat = useMemo(() => {
const chatLink = { name: "Chat", href: "/chat" };
return isChatEnabled ? [...loggedInLinks, chatLink] : loggedInLinks;
}, [isChatEnabled]);
return (
<>
<nav className="sticky top-0 z-40 inline-flex h-[60px] w-full items-center border border-white/50 bg-[#f3f4f6]/20 p-3 backdrop-blur-[26px]">
@@ -36,7 +43,7 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
{!isSmallScreen ? (
<div className="flex flex-1 items-center gap-3 gap-5">
{isLoggedIn
? loggedInLinks.map((link) => (
? linksWithChat.map((link) => (
<NavbarLink
key={link.name}
name={link.name}
@@ -89,7 +96,7 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
menuItemGroups={[
{
groupName: "Navigation",
items: loggedInLinks.map((link) => ({
items: linksWithChat.map((link) => ({
icon:
link.name === "Marketplace"
? IconType.Marketplace
@@ -97,9 +104,11 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
? IconType.Library
: link.name === "Build"
? IconType.Builder
: link.name === "Monitor"
? IconType.Library
: IconType.LayoutDashboard,
: link.name === "Chat"
? IconType.Chat
: link.name === "Monitor"
? IconType.Library
: IconType.LayoutDashboard,
text: link.name,
href: link.href,
})),

View File

@@ -10,7 +10,7 @@ import {
IconType,
IconUploadCloud,
} from "@/components/__legacy__/ui/icons";
import { StorefrontIcon } from "@phosphor-icons/react";
import { ChatsIcon, StorefrontIcon } from "@phosphor-icons/react";
type Link = {
name: string;
@@ -175,6 +175,8 @@ export function getAccountMenuOptionIcon(icon: IconType) {
return <IconBuilder className={iconClass} />;
case IconType.Sliders:
return <IconSliders className={iconClass} />;
case IconType.Chat:
return <ChatsIcon className={iconClass} />;
default:
return <IconRefresh className={iconClass} />;
}

View File

@@ -16,6 +16,7 @@ export enum Flag {
AGENT_FAVORITING = "agent-favoriting",
MARKETPLACE_SEARCH_TERMS = "marketplace-search-terms",
ENABLE_PLATFORM_PAYMENT = "enable-platform-payment",
CHAT = "chat",
}
export type FlagValues = {
@@ -30,6 +31,7 @@ export type FlagValues = {
[Flag.AGENT_FAVORITING]: boolean;
[Flag.MARKETPLACE_SEARCH_TERMS]: string[];
[Flag.ENABLE_PLATFORM_PAYMENT]: boolean;
[Flag.CHAT]: boolean;
};
const isPwMockEnabled = process.env.NEXT_PUBLIC_PW_TEST === "true";
@@ -46,6 +48,7 @@ const mockFlags = {
[Flag.AGENT_FAVORITING]: false,
[Flag.MARKETPLACE_SEARCH_TERMS]: DEFAULT_SEARCH_TERMS,
[Flag.ENABLE_PLATFORM_PAYMENT]: false,
[Flag.CHAT]: true,
};
export function useGetFlag<T extends Flag>(flag: T): FlagValues[T] | null {

View File

@@ -8,6 +8,7 @@ export enum Key {
SHEPHERD_TOUR = "shepherd-tour",
WALLET_LAST_SEEN_CREDITS = "wallet-last-seen-credits",
LIBRARY_AGENTS_CACHE = "library-agents-cache",
CHAT_SESSION_ID = "chat_session_id",
COOKIE_CONSENT = "autogpt_cookie_consent",
}

View File

@@ -0,0 +1,30 @@
/**
* Shared type definitions for chat-related data structures.
* These types provide type-safe alternatives to Record<string, any>.
*/
/**
* Represents a valid JSON value that can be used in tool arguments or results.
* This is a recursive type that allows for nested objects and arrays.
*/
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
/**
* Represents tool arguments passed to a tool call.
* Can be a simple object with string keys and JSON values.
*/
export interface ToolArguments {
[key: string]: JsonValue;
}
/**
* Represents the result returned from a tool execution.
* Can be either a string or a structured object with JSON values.
*/
export type ToolResult = string | { [key: string]: JsonValue };