mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-21 04:57:58 -05:00
feat(frontend): refactor chat UX
This commit is contained in:
@@ -1,121 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatMessage } from "../ChatMessage/ChatMessage";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
import { StreamingMessage } from "../StreamingMessage/StreamingMessage";
|
||||
import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage";
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl flex-col py-4">
|
||||
{/* Render all persisted messages */}
|
||||
{messages.map((message, index) => {
|
||||
// Check if current message is an agent_output tool_response
|
||||
// and if previous message is an assistant message
|
||||
let agentOutput: ChatMessageData | undefined;
|
||||
|
||||
if (message.type === "tool_response" && message.result) {
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof message.result === "string"
|
||||
? JSON.parse(message.result)
|
||||
: (message.result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
if (parsedResult?.type === "agent_output") {
|
||||
const prevMessage = messages[index - 1];
|
||||
if (
|
||||
prevMessage &&
|
||||
prevMessage.type === "message" &&
|
||||
prevMessage.role === "assistant"
|
||||
) {
|
||||
// This agent output will be rendered inside the previous assistant message
|
||||
// Skip rendering this message separately
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if next message is an agent_output tool_response to include in current assistant message
|
||||
if (message.type === "message" && message.role === "assistant") {
|
||||
const nextMessage = messages[index + 1];
|
||||
if (
|
||||
nextMessage &&
|
||||
nextMessage.type === "tool_response" &&
|
||||
nextMessage.result
|
||||
) {
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof nextMessage.result === "string"
|
||||
? JSON.parse(nextMessage.result)
|
||||
: (nextMessage.result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
if (parsedResult?.type === "agent_output") {
|
||||
agentOutput = nextMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
key={index}
|
||||
message={message}
|
||||
onSendMessage={onSendMessage}
|
||||
agentOutput={agentOutput}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Render thinking message when streaming but no chunks yet */}
|
||||
{isStreaming && streamingChunks.length === 0 && <ThinkingMessage />}
|
||||
|
||||
{/* Render streaming message if active */}
|
||||
{isStreaming && streamingChunks.length > 0 && (
|
||||
<StreamingMessage
|
||||
chunks={streamingChunks}
|
||||
onComplete={onStreamComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invisible div to scroll to */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WrenchIcon } from "@phosphor-icons/react";
|
||||
import { getToolActionPhrase } from "../../helpers";
|
||||
|
||||
export interface ToolCallMessageProps {
|
||||
toolName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolCallMessage({ toolName, className }: ToolCallMessageProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import "@/components/contextual/OutputRenderers";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputItem,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolResult } from "@/types/chat";
|
||||
import { WrenchIcon } from "@phosphor-icons/react";
|
||||
import { getToolActionPhrase } from "../../helpers";
|
||||
|
||||
export interface ToolResponseMessageProps {
|
||||
toolName: string;
|
||||
result?: ToolResult;
|
||||
success?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolResponseMessage({
|
||||
toolName,
|
||||
result,
|
||||
success: _success = true,
|
||||
className,
|
||||
}: ToolResponseMessageProps) {
|
||||
if (!result) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 === "agent_output") {
|
||||
const execution = parsedResult.execution as
|
||||
| {
|
||||
outputs?: Record<string, unknown[]>;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const outputs = execution?.outputs || {};
|
||||
const message = parsedResult.message as string | undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4 px-4 py-2", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}
|
||||
</Text>
|
||||
</div>
|
||||
{message && (
|
||||
<div className="rounded border p-4">
|
||||
<Text variant="small" className="text-neutral-600">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(outputs).length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(outputs).map(([outputName, values]) =>
|
||||
values.map((value, index) => {
|
||||
const renderer = globalRegistry.getRenderer(value);
|
||||
if (renderer) {
|
||||
return (
|
||||
<OutputItem
|
||||
key={`${outputName}-${index}`}
|
||||
value={value}
|
||||
renderer={renderer}
|
||||
label={outputName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`${outputName}-${index}`}
|
||||
className="rounded border p-4"
|
||||
>
|
||||
<Text variant="large-medium" className="mb-2 capitalize">
|
||||
{outputName}
|
||||
</Text>
|
||||
<pre className="overflow-auto text-sm">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (responseType === "block_output" && parsedResult.outputs) {
|
||||
const outputs = parsedResult.outputs as Record<string, unknown[]>;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4 px-4 py-2", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{Object.entries(outputs).map(([outputName, values]) =>
|
||||
values.map((value, index) => {
|
||||
const renderer = globalRegistry.getRenderer(value);
|
||||
if (renderer) {
|
||||
return (
|
||||
<OutputItem
|
||||
key={`${outputName}-${index}`}
|
||||
value={value}
|
||||
renderer={renderer}
|
||||
label={outputName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`${outputName}-${index}`}
|
||||
className="rounded border p-4"
|
||||
>
|
||||
<Text variant="large-medium" className="mb-2 capitalize">
|
||||
{outputName}
|
||||
</Text>
|
||||
<pre className="overflow-auto text-sm">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle other response types with a message field (e.g., understanding_updated)
|
||||
if (parsedResult.message && typeof parsedResult.message === "string") {
|
||||
// Format tool name from snake_case to Title Case
|
||||
const formattedToolName = toolName
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
|
||||
// Clean up message - remove incomplete user_name references
|
||||
let cleanedMessage = parsedResult.message;
|
||||
// Remove "Updated understanding with: user_name" pattern if user_name is just a placeholder
|
||||
cleanedMessage = cleanedMessage.replace(
|
||||
/Updated understanding with:\s*user_name\.?\s*/gi,
|
||||
"",
|
||||
);
|
||||
// Remove standalone user_name references
|
||||
cleanedMessage = cleanedMessage.replace(/\buser_name\b\.?\s*/gi, "");
|
||||
cleanedMessage = cleanedMessage.trim();
|
||||
|
||||
// Only show message if it has content after cleaning
|
||||
if (!cleanedMessage) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 px-4 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{formattedToolName}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2 px-4 py-2", className)}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{formattedToolName}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="rounded border p-4">
|
||||
<Text variant="small" className="text-neutral-600">
|
||||
{cleanedMessage}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const renderer = globalRegistry.getRenderer(result);
|
||||
if (renderer) {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}
|
||||
</Text>
|
||||
</div>
|
||||
<OutputItem value={result} renderer={renderer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader";
|
||||
import { InfiniteList } from "@/components/molecules/InfiniteList/InfiniteList";
|
||||
import { scrollbarStyles } from "@/components/styles/scrollbars";
|
||||
import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
|
||||
@@ -35,15 +36,15 @@ export function CopilotShell({ children }: CopilotShellProps) {
|
||||
isReadyToShowContent,
|
||||
} = useCopilotShell();
|
||||
|
||||
console.log(sessions)
|
||||
|
||||
function renderSessionsList() {
|
||||
if (isLoading && sessions.length === 0) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
Loading sessions...
|
||||
</Text>
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg px-3 py-2.5">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +100,7 @@ export function CopilotShell({ children }: CopilotShellProps) {
|
||||
style={{ height: `calc(100vh - ${NAVBAR_HEIGHT_PX}px)` }}
|
||||
>
|
||||
{!isMobile ? (
|
||||
<aside className="flex h-full w-80 flex-col border-r border-zinc-200 bg-white">
|
||||
<aside className="flex h-full w-80 flex-col border-r border-zinc-100 bg-white">
|
||||
<div className="shrink-0 px-6 py-4">
|
||||
<Text variant="h3" size="body-medium">
|
||||
Your chats
|
||||
@@ -146,7 +147,7 @@ export function CopilotShell({ children }: CopilotShellProps) {
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LoadingSpinner size="large" />
|
||||
<ChatLoader />
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
Loading your chats...
|
||||
</Text>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useGetV2GetSession, useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { postV2CreateSession, useGetV2GetSession, useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||
import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { filterVisibleSessions } from "./helpers";
|
||||
|
||||
function convertSessionDetailToSummary(
|
||||
@@ -32,6 +32,8 @@ export function useCopilotShell() {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [accumulatedSessions, setAccumulatedSessions] = useState<SessionSummaryResponse[]>([]);
|
||||
const [totalCount, setTotalCount] = useState<number | null>(null);
|
||||
const [hasAutoSelectedSession, setHasAutoSelectedSession] = useState(false);
|
||||
const hasCreatedSessionRef = useRef(false);
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const { data, isLoading, isFetching } = useGetV2ListSessions(
|
||||
@@ -63,6 +65,17 @@ export function useCopilotShell() {
|
||||
return accumulatedSessions.length < totalCount;
|
||||
}, [accumulatedSessions.length, totalCount]);
|
||||
|
||||
const areAllSessionsLoaded = useMemo(() => {
|
||||
if (totalCount === null) return false;
|
||||
return accumulatedSessions.length >= totalCount && !isFetching && !isLoading;
|
||||
}, [accumulatedSessions.length, totalCount, isFetching, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage && !isFetching && !isLoading && totalCount !== null) {
|
||||
setOffset((prev) => prev + PAGE_SIZE);
|
||||
}
|
||||
}, [hasNextPage, isFetching, isLoading, totalCount]);
|
||||
|
||||
const fetchNextPage = () => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
setOffset((prev) => prev + PAGE_SIZE);
|
||||
@@ -76,6 +89,8 @@ export function useCopilotShell() {
|
||||
setOffset(0);
|
||||
setAccumulatedSessions([]);
|
||||
setTotalCount(null);
|
||||
setHasAutoSelectedSession(false);
|
||||
hasCreatedSessionRef.current = false;
|
||||
}
|
||||
}, [isMobile, isDrawerOpen]);
|
||||
|
||||
@@ -155,39 +170,76 @@ export function useCopilotShell() {
|
||||
|
||||
function handleNewChat() {
|
||||
storage.clean(Key.CHAT_SESSION_ID);
|
||||
router.push("/copilot");
|
||||
setHasAutoSelectedSession(false);
|
||||
hasCreatedSessionRef.current = false;
|
||||
postV2CreateSession({ body: JSON.stringify({}) })
|
||||
.then((response) => {
|
||||
if (response.status === 200 && response.data) {
|
||||
router.push(`/copilot/chat?sessionId=${response.data.id}`);
|
||||
setHasAutoSelectedSession(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
hasCreatedSessionRef.current = false;
|
||||
});
|
||||
if (isMobile) setIsDrawerOpen(false);
|
||||
}
|
||||
|
||||
// Determine if we're ready to show the main content
|
||||
// We need to wait for:
|
||||
// 1. Sessions to load (at least first page)
|
||||
// 2. If there's a sessionId query param, wait for that session to be found/loaded
|
||||
const isReadyToShowContent = useMemo(() => {
|
||||
// If still loading initial sessions, not ready
|
||||
if (isLoading && accumulatedSessions.length === 0) {
|
||||
return false;
|
||||
const paramSessionId = searchParams.get("sessionId");
|
||||
|
||||
useEffect(() => {
|
||||
if (!areAllSessionsLoaded || hasAutoSelectedSession) return;
|
||||
|
||||
const visibleSessions = filterVisibleSessions(accumulatedSessions);
|
||||
|
||||
if (paramSessionId) {
|
||||
setHasAutoSelectedSession(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's a sessionId query param, wait for it to be found/loaded
|
||||
const paramSessionId = searchParams.get("sessionId");
|
||||
if (visibleSessions.length > 0) {
|
||||
const lastSession = visibleSessions[0];
|
||||
setHasAutoSelectedSession(true);
|
||||
router.push(`/copilot/chat?sessionId=${lastSession.id}`);
|
||||
} else if (accumulatedSessions.length === 0 && !isLoading && totalCount === 0 && !hasCreatedSessionRef.current) {
|
||||
hasCreatedSessionRef.current = true;
|
||||
postV2CreateSession({ body: JSON.stringify({}) })
|
||||
.then((response) => {
|
||||
if (response.status === 200 && response.data) {
|
||||
router.push(`/copilot/chat?sessionId=${response.data.id}`);
|
||||
setHasAutoSelectedSession(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
hasCreatedSessionRef.current = false;
|
||||
});
|
||||
} else if (totalCount === 0) {
|
||||
setHasAutoSelectedSession(true);
|
||||
}
|
||||
}, [areAllSessionsLoaded, accumulatedSessions, paramSessionId, hasAutoSelectedSession, router, isLoading, totalCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paramSessionId) {
|
||||
setHasAutoSelectedSession(true);
|
||||
}
|
||||
}, [paramSessionId]);
|
||||
|
||||
const isReadyToShowContent = useMemo(() => {
|
||||
if (!areAllSessionsLoaded) return false;
|
||||
|
||||
if (paramSessionId) {
|
||||
// Check if session is in accumulated sessions or if we're still loading it
|
||||
const sessionFound = accumulatedSessions.some((s) => s.id === paramSessionId);
|
||||
const sessionLoading = isCurrentSessionLoading;
|
||||
|
||||
// Ready if session is found OR if we've finished loading it (even if not in list)
|
||||
return sessionFound || (!sessionLoading && currentSessionData !== undefined);
|
||||
}
|
||||
|
||||
// No sessionId param, ready once sessions have loaded
|
||||
return !isLoading || accumulatedSessions.length > 0;
|
||||
}, [isLoading, accumulatedSessions, searchParams, isCurrentSessionLoading, currentSessionData]);
|
||||
return hasAutoSelectedSession;
|
||||
}, [areAllSessionsLoaded, accumulatedSessions, paramSessionId, isCurrentSessionLoading, currentSessionData, hasAutoSelectedSession]);
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
isDrawerOpen,
|
||||
isLoading,
|
||||
isLoading: isLoading || !areAllSessionsLoaded,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
handleSelectSession,
|
||||
@@ -199,5 +251,6 @@ export function useCopilotShell() {
|
||||
isFetchingNextPage: isFetching,
|
||||
fetchNextPage,
|
||||
isReadyToShowContent,
|
||||
areAllSessionsLoaded,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
|
||||
import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState";
|
||||
import { ChatLoader } from "./components/ChatLoader/ChatLoader";
|
||||
import { useChat } from "./useChat";
|
||||
|
||||
export interface ChatProps {
|
||||
@@ -38,6 +38,7 @@ export function Chat({
|
||||
sessionId,
|
||||
createSession,
|
||||
clearSession,
|
||||
showLoader,
|
||||
} = useChat({ urlSessionId });
|
||||
|
||||
function handleNewChat() {
|
||||
@@ -73,11 +74,11 @@ export function Chat({
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Loading State - show spinner when explicitly loading/creating OR when we don't have a session yet and no error */}
|
||||
{(isLoading || isCreating || (!sessionId && !error)) && (
|
||||
{/* Loading State - show loader when loading or creating a session (with 300ms delay) */}
|
||||
{showLoader && (isLoading || isCreating) && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LoadingSpinner size="large" />
|
||||
<ChatLoader />
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
Loading your chats...
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface AIChatBubbleProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AIChatBubble({ children, className }: AIChatBubbleProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-left text-sm leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ShieldIcon, SignInIcon, UserPlusIcon } from "@phosphor-icons/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
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 = "/copilot/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",
|
||||
"bg-gradient-to-br from-violet-50 to-purple-50",
|
||||
"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">
|
||||
Authentication Required
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Sign in to set up and manage agents
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-md bg-white/50 p-4">
|
||||
<p className="text-sm text-neutral-700">{message}</p>
|
||||
{agentInfo && (
|
||||
<div className="mt-3 text-xs text-neutral-600">
|
||||
<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">
|
||||
Your chat session will be preserved after signing in
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,11 +50,11 @@ export function ChatContainer({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex h-full min-h-0 flex-col bg-[#f8f8f9]", className)}
|
||||
className={cn("flex h-full min-h-0 flex-col max-w-3xl mx-auto bg-[#f8f8f9]", className)}
|
||||
>
|
||||
{/* Messages or Welcome Screen - Scrollable */}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div className="flex min-h-full flex-col justify-end pb-4">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto relative">
|
||||
<div className="flex min-h-full flex-col justify-end">
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streamingChunks={streamingChunks}
|
||||
@@ -66,13 +66,12 @@ export function ChatContainer({
|
||||
</div>
|
||||
|
||||
{/* Input - Fixed at bottom */}
|
||||
<div className="shrink-0 border-t border-zinc-200 bg-white p-4">
|
||||
<div className="relative pb-4 pt-2">
|
||||
<div className="absolute w-full top-[-18px] h-6 pointer-events-none bg-gradient-to-b from-transparent to-[#f8f8f9] z-10" />
|
||||
<ChatInput
|
||||
onSend={sendMessageWithContext}
|
||||
disabled={isStreaming || !sessionId}
|
||||
placeholder={
|
||||
sessionId ? "Type your message..." : "Creating session..."
|
||||
}
|
||||
placeholder="You can search or just ask — e.g. “create a blog post outline”"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,13 +33,22 @@ export function handleTextEnded(
|
||||
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.setMessages((prev) => {
|
||||
const lastMessage = prev[prev.length - 1];
|
||||
console.log("[Text Ended] Previous message:", {
|
||||
type: lastMessage?.type,
|
||||
toolName: lastMessage?.type === "tool_call" ? lastMessage.toolName : undefined,
|
||||
content: completedText.substring(0, 200),
|
||||
});
|
||||
|
||||
const assistantMessage: ChatMessageData = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: completedText,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
return [...prev, assistantMessage];
|
||||
});
|
||||
}
|
||||
deps.setStreamingChunks([]);
|
||||
deps.streamingChunksRef.current = [];
|
||||
@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils";
|
||||
import { ArrowUpIcon } from "@phosphor-icons/react";
|
||||
import { useChatInput } from "./useChatInput";
|
||||
|
||||
export interface ChatInputProps {
|
||||
export interface Props {
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
@@ -15,7 +15,7 @@ export function ChatInput({
|
||||
disabled = false,
|
||||
placeholder = "Type your message...",
|
||||
className,
|
||||
}: ChatInputProps) {
|
||||
}: Props) {
|
||||
const inputId = "chat-input";
|
||||
const { value, setValue, handleKeyDown, handleSend } = useChatInput({
|
||||
onSend,
|
||||
@@ -0,0 +1,11 @@
|
||||
.loader {
|
||||
width: 20px;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: #000;
|
||||
box-shadow: 0 0 0 0 #0004;
|
||||
animation: l1 1s infinite;
|
||||
}
|
||||
@keyframes l1 {
|
||||
100% {box-shadow: 0 0 0 30px #0000}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import styles from "./ChatLoader.module.css";
|
||||
|
||||
export function ChatLoader() {
|
||||
return <div className={styles.loader} />;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowClockwise,
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
import { AgentCarouselMessage } from "../AgentCarouselMessage/AgentCarouselMessage";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
import { AuthPromptWidget } from "../AuthPromptWidget/AuthPromptWidget";
|
||||
import { ChatCredentialsSetup } from "../ChatCredentialsSetup/ChatCredentialsSetup";
|
||||
import { ExecutionStartedMessage } from "../ExecutionStartedMessage/ExecutionStartedMessage";
|
||||
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
||||
import { NoResultsMessage } from "../NoResultsMessage/NoResultsMessage";
|
||||
import { ToolCallMessage } from "../ToolCallMessage/ToolCallMessage";
|
||||
import { ToolResponseMessage } from "../ToolResponseMessage/ToolResponseMessage";
|
||||
import { UserChatBubble } from "../UserChatBubble/UserChatBubble";
|
||||
import { useChatMessage, type ChatMessageData } from "./useChatMessage";
|
||||
export interface ChatMessageProps {
|
||||
message: ChatMessageData;
|
||||
className?: string;
|
||||
onDismissLogin?: () => void;
|
||||
onDismissCredentials?: () => void;
|
||||
onSendMessage?: (content: string, isUserMessage?: boolean) => void;
|
||||
agentOutput?: ChatMessageData;
|
||||
isFinalMessage?: boolean;
|
||||
}
|
||||
|
||||
export function ChatMessage({
|
||||
message,
|
||||
className,
|
||||
onDismissCredentials,
|
||||
onSendMessage,
|
||||
agentOutput,
|
||||
isFinalMessage = true,
|
||||
}: ChatMessageProps) {
|
||||
const { user } = useSupabase();
|
||||
const router = useRouter();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const {
|
||||
isUser,
|
||||
isToolCall,
|
||||
isToolResponse,
|
||||
isLoginNeeded,
|
||||
isCredentialsNeeded,
|
||||
} = 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();
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (message.type !== "message") return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
const handleTryAgain = useCallback(() => {
|
||||
if (message.type !== "message" || !onSendMessage) return;
|
||||
onSendMessage(message.content, message.role === "user");
|
||||
}, [message, onSendMessage]);
|
||||
|
||||
const handleViewExecution = useCallback(() => {
|
||||
if (message.type === "execution_started" && message.libraryAgentLink) {
|
||||
router.push(message.libraryAgentLink);
|
||||
}
|
||||
}, [message, router]);
|
||||
|
||||
// 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">
|
||||
<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">
|
||||
Successfully Authenticated
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600">
|
||||
You'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}
|
||||
/>
|
||||
</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 no_results messages - use dedicated component, not ToolResponseMessage
|
||||
if (message.type === "no_results") {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<NoResultsMessage
|
||||
message={message.message}
|
||||
suggestions={message.suggestions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render agent_carousel messages - use dedicated component, not ToolResponseMessage
|
||||
if (message.type === "agent_carousel") {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<AgentCarouselMessage
|
||||
agents={message.agents}
|
||||
totalCount={message.totalCount}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render execution_started messages - use dedicated component, not ToolResponseMessage
|
||||
if (message.type === "execution_started") {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<ExecutionStartedMessage
|
||||
executionId={message.executionId}
|
||||
agentName={message.agentName}
|
||||
message={message.message}
|
||||
onViewExecution={
|
||||
message.libraryAgentLink ? handleViewExecution : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render tool response messages (but skip agent_output if it's being rendered inside assistant message)
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render regular chat messages
|
||||
if (message.type === "message") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex w-full gap-3 px-4 py-3",
|
||||
isUser ? "justify-end" : "justify-start",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full max-w-3xl gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col",
|
||||
isUser && "items-end",
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<UserChatBubble>
|
||||
<MarkdownContent content={message.content} />
|
||||
</UserChatBubble>
|
||||
) : (
|
||||
<AIChatBubble>
|
||||
<MarkdownContent content={message.content} />
|
||||
{agentOutput &&
|
||||
agentOutput.type === "tool_response" && (
|
||||
<div className="mt-4">
|
||||
<ToolResponseMessage
|
||||
toolId={agentOutput.toolId}
|
||||
toolName={agentOutput.toolName || "Agent Output"}
|
||||
result={agentOutput.result}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AIChatBubble>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-1",
|
||||
isUser ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{isUser && onSendMessage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleTryAgain}
|
||||
aria-label="Try again"
|
||||
>
|
||||
<ArrowClockwise className="size-3 text-neutral-500" />
|
||||
</Button>
|
||||
)}
|
||||
{(isUser || isFinalMessage) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy message"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-3 text-green-600" />
|
||||
) : (
|
||||
<CopyIcon className="size-3 text-neutral-500" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for unknown message types
|
||||
return null;
|
||||
}
|
||||
@@ -13,10 +13,9 @@ export function MessageBubble({
|
||||
className,
|
||||
}: MessageBubbleProps) {
|
||||
const userTheme = {
|
||||
bg: "bg-slate-900",
|
||||
border: "border-slate-800",
|
||||
gradient: "from-slate-900/30 via-slate-800/20 to-transparent",
|
||||
text: "text-slate-50",
|
||||
bg: "bg-purple-100",
|
||||
border: "border-purple-100",
|
||||
text: "text-slate-900",
|
||||
};
|
||||
|
||||
const assistantTheme = {
|
||||
@@ -41,7 +40,7 @@ export function MessageBubble({
|
||||
>
|
||||
{/* Gradient flare background */}
|
||||
<div
|
||||
className={cn("absolute inset-0 bg-gradient-to-br", theme.gradient)}
|
||||
className={cn("absolute inset-0 bg-gradient-to-br")}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
import { ChatMessage } from "../ChatMessage/ChatMessage";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
||||
import { StreamingMessage } from "../StreamingMessage/StreamingMessage";
|
||||
import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage";
|
||||
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 overflow-x-hidden",
|
||||
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex flex-col py-4 min-w-0 break-words hyphens-auto">
|
||||
{/* Render all persisted messages */}
|
||||
{(() => {
|
||||
let lastAssistantMessageIndex = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.type === "message" && msg.role === "assistant") {
|
||||
lastAssistantMessageIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let lastToolResponseIndex = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.type === "tool_response") {
|
||||
lastToolResponseIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
.map((message, index) => {
|
||||
// Log message for debugging
|
||||
if (message.type === "message" && message.role === "assistant") {
|
||||
const prevMessage = messages[index - 1];
|
||||
const prevMessageToolName = prevMessage?.type === "tool_call" ? prevMessage.toolName : undefined;
|
||||
console.log("[MessageList] Assistant message:", {
|
||||
index,
|
||||
content: message.content.substring(0, 200),
|
||||
fullContent: message.content,
|
||||
prevMessageType: prevMessage?.type,
|
||||
prevMessageToolName,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if current message is an agent_output tool_response
|
||||
// and if previous message is an assistant message
|
||||
let agentOutput: ChatMessageData | undefined;
|
||||
let messageToRender: ChatMessageData = message;
|
||||
|
||||
if (message.type === "tool_response" && message.result) {
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof message.result === "string"
|
||||
? JSON.parse(message.result)
|
||||
: (message.result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
if (parsedResult?.type === "agent_output") {
|
||||
const prevMessage = messages[index - 1];
|
||||
if (
|
||||
prevMessage &&
|
||||
prevMessage.type === "message" &&
|
||||
prevMessage.role === "assistant"
|
||||
) {
|
||||
// This agent output will be rendered inside the previous assistant message
|
||||
// Skip rendering this message separately
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if assistant message follows a tool_call and looks like a tool output
|
||||
if (message.type === "message" && message.role === "assistant") {
|
||||
const prevMessage = messages[index - 1];
|
||||
|
||||
// Check if next message is an agent_output tool_response to include in current assistant message
|
||||
const nextMessage = messages[index + 1];
|
||||
if (
|
||||
nextMessage &&
|
||||
nextMessage.type === "tool_response" &&
|
||||
nextMessage.result
|
||||
) {
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof nextMessage.result === "string"
|
||||
? JSON.parse(nextMessage.result)
|
||||
: (nextMessage.result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
if (parsedResult?.type === "agent_output") {
|
||||
agentOutput = nextMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// Only convert to tool_response if it follows a tool_call AND looks like a tool output
|
||||
if (prevMessage && prevMessage.type === "tool_call") {
|
||||
const content = message.content.toLowerCase().trim();
|
||||
// Patterns that indicate this is a tool output result, not an agent response
|
||||
const isToolOutputPattern =
|
||||
content.startsWith("no agents found") ||
|
||||
content.startsWith("no results found") ||
|
||||
content.includes("no agents found matching") ||
|
||||
content.match(/^no \w+ found/i) ||
|
||||
(content.length < 150 && content.includes("try different")) ||
|
||||
(content.length < 200 && !content.includes("i'll") && !content.includes("let me") && !content.includes("i can") && !content.includes("i will"));
|
||||
|
||||
console.log("[MessageList] Checking if assistant message is tool output:", {
|
||||
content: message.content.substring(0, 100),
|
||||
isToolOutputPattern,
|
||||
prevToolName: prevMessage.toolName,
|
||||
});
|
||||
|
||||
if (isToolOutputPattern) {
|
||||
// Convert this message to a tool_response format for rendering
|
||||
messageToRender = {
|
||||
type: "tool_response",
|
||||
toolId: prevMessage.toolId,
|
||||
toolName: prevMessage.toolName,
|
||||
result: message.content,
|
||||
success: true,
|
||||
timestamp: message.timestamp,
|
||||
} as ChatMessageData;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isFinalMessage =
|
||||
messageToRender.type !== "message" ||
|
||||
messageToRender.role !== "assistant" ||
|
||||
index === lastAssistantMessageIndex;
|
||||
|
||||
// Render last tool_response as AIChatBubble (but skip agent_output that's rendered inside assistant message)
|
||||
if (
|
||||
messageToRender.type === "tool_response" &&
|
||||
message.type === "tool_response" &&
|
||||
index === lastToolResponseIndex
|
||||
) {
|
||||
// Check if this is an agent_output that should be rendered inside assistant message
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof messageToRender.result === "string"
|
||||
? JSON.parse(messageToRender.result)
|
||||
: (messageToRender.result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
|
||||
const isAgentOutput = parsedResult?.type === "agent_output";
|
||||
const prevMessage = messages[index - 1];
|
||||
const shouldSkip =
|
||||
isAgentOutput &&
|
||||
prevMessage &&
|
||||
prevMessage.type === "message" &&
|
||||
prevMessage.role === "assistant";
|
||||
|
||||
if (shouldSkip) return null;
|
||||
|
||||
const resultValue =
|
||||
typeof messageToRender.result === "string"
|
||||
? messageToRender.result
|
||||
: messageToRender.result
|
||||
? JSON.stringify(messageToRender.result, null, 2)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div key={index} className="px-4 py-2 min-w-0 overflow-x-hidden break-words hyphens-auto">
|
||||
<AIChatBubble>
|
||||
<MarkdownContent content={resultValue} />
|
||||
</AIChatBubble>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
key={index}
|
||||
message={messageToRender}
|
||||
onSendMessage={onSendMessage}
|
||||
agentOutput={agentOutput}
|
||||
isFinalMessage={isFinalMessage}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{/* Render thinking message when streaming but no chunks yet */}
|
||||
{isStreaming && streamingChunks.length === 0 && <ThinkingMessage />}
|
||||
|
||||
{/* Render streaming message if active */}
|
||||
{isStreaming && streamingChunks.length > 0 && (
|
||||
<StreamingMessage
|
||||
chunks={streamingChunks}
|
||||
onComplete={onStreamComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invisible div to scroll to */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RobotIcon } from "@phosphor-icons/react";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
||||
import { MessageBubble } from "../MessageBubble/MessageBubble";
|
||||
import { useStreamingMessage } from "./useStreamingMessage";
|
||||
|
||||
export interface StreamingMessageProps {
|
||||
@@ -25,16 +24,10 @@ export function StreamingMessage({
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full max-w-3xl gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-600">
|
||||
<RobotIcon className="h-4 w-4 text-indigo-50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<MessageBubble variant="assistant">
|
||||
<AIChatBubble>
|
||||
<MarkdownContent content={displayText} />
|
||||
</MessageBubble>
|
||||
</AIChatBubble>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RobotIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MessageBubble } from "../MessageBubble/MessageBubble";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
|
||||
export interface ThinkingMessageProps {
|
||||
className?: string;
|
||||
@@ -34,14 +33,8 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full max-w-3xl gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500">
|
||||
<RobotIcon className="h-4 w-4 text-indigo-50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<MessageBubble variant="assistant">
|
||||
<AIChatBubble>
|
||||
<div className="transition-all duration-500 ease-in-out">
|
||||
{showSlowLoader ? (
|
||||
<div className="flex flex-col items-center gap-3 py-2">
|
||||
@@ -62,7 +55,7 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</MessageBubble>
|
||||
</AIChatBubble>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import type { ToolArguments } from "@/types/chat";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
|
||||
export interface ToolCallMessageProps {
|
||||
toolId?: string;
|
||||
toolName: string;
|
||||
arguments?: ToolArguments;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolCallMessage({
|
||||
toolId,
|
||||
toolName,
|
||||
arguments: toolArguments,
|
||||
className,
|
||||
}: ToolCallMessageProps) {
|
||||
const displayKey = toolName || toolId;
|
||||
|
||||
const displayData = toolArguments
|
||||
? JSON.stringify(toolArguments)
|
||||
: "No arguments";
|
||||
|
||||
const displayText = `${displayKey}: ${displayData}`;
|
||||
|
||||
return (
|
||||
<AIChatBubble className={className}>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{displayText}
|
||||
</Text>
|
||||
</AIChatBubble>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import type { ToolResult } from "@/types/chat";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
|
||||
export interface ToolResponseMessageProps {
|
||||
toolId?: string;
|
||||
toolName: string;
|
||||
result?: ToolResult;
|
||||
success?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolResponseMessage({
|
||||
toolId,
|
||||
toolName,
|
||||
result: _result,
|
||||
success: _success = true,
|
||||
className,
|
||||
}: ToolResponseMessageProps) {
|
||||
const displayKey = toolId || toolName;
|
||||
const resultValue =
|
||||
typeof _result === "string"
|
||||
? _result
|
||||
: _result
|
||||
? JSON.stringify(_result)
|
||||
: toolName;
|
||||
const displayText = `${displayKey}: ${resultValue}`;
|
||||
|
||||
return (
|
||||
<AIChatBubble className={className}>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{displayText}
|
||||
</Text>
|
||||
</AIChatBubble>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface UserChatBubbleProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UserChatBubble({
|
||||
children,
|
||||
className,
|
||||
}: UserChatBubbleProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative min-w-20 overflow-hidden rounded-xl bg-purple-100 px-3 text-right text-sm leading-relaxed transition-all duration-500 ease-in-out",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
borderBottomRightRadius: 0,
|
||||
}}
|
||||
>
|
||||
<div className="relative z-10 text-slate-900 transition-all duration-500 ease-in-out">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useChatSession } from "./useChatSession";
|
||||
import { useChatStream } from "./useChatStream";
|
||||
|
||||
interface UseChatArgs {
|
||||
urlSessionId?: string | null;
|
||||
}
|
||||
|
||||
export function useChat({ urlSessionId }: UseChatArgs = {}) {
|
||||
const hasClaimedSessionRef = useRef(false);
|
||||
const { user } = useSupabase();
|
||||
const { sendMessage: sendStreamMessage } = useChatStream();
|
||||
const [showLoader, setShowLoader] = useState(false);
|
||||
const {
|
||||
session,
|
||||
sessionId: sessionIdFromHook,
|
||||
messages,
|
||||
isLoading,
|
||||
isCreating,
|
||||
error,
|
||||
createSession,
|
||||
claimSession,
|
||||
clearSession: clearSessionBase,
|
||||
loadSession,
|
||||
} = useChatSession({
|
||||
urlSessionId,
|
||||
autoCreate: false,
|
||||
});
|
||||
|
||||
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(() => {
|
||||
if (isLoading || isCreating) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowLoader(true);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setShowLoader(false);
|
||||
}
|
||||
}, [isLoading, isCreating]);
|
||||
|
||||
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();
|
||||
hasClaimedSessionRef.current = false;
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
messages,
|
||||
isLoading,
|
||||
isCreating,
|
||||
error,
|
||||
createSession,
|
||||
clearSession,
|
||||
loadSession,
|
||||
sessionId: sessionIdFromHook,
|
||||
showLoader,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user