Files
NanoComic/frontend/src/components/ChatMessagesView.tsx

471 lines
14 KiB
TypeScript

import type React from "react";
import type { Message } from "@langchain/langgraph-sdk";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Loader2, Copy, CopyCheck } from "lucide-react";
import { InputForm } from "@/components/InputForm";
import { Button } from "@/components/ui/button";
import { useState, ReactNode, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import {
ActivityTimeline,
ProcessedEvent,
} from "@/components/ActivityTimeline"; // Assuming ActivityTimeline is in the same dir or adjust path
// Markdown component props type from former ReportView
type MdComponentProps = {
className?: string;
children?: ReactNode;
[key: string]: any;
};
// Markdown components (from former ReportView.tsx)
const mdComponents = {
h1: ({ className, children, ...props }: MdComponentProps) => (
<h1 className={cn("text-2xl font-bold mt-4 mb-2", className)} {...props}>
{children}
</h1>
),
h2: ({ className, children, ...props }: MdComponentProps) => (
<h2 className={cn("text-xl font-bold mt-3 mb-2", className)} {...props}>
{children}
</h2>
),
h3: ({ className, children, ...props }: MdComponentProps) => (
<h3 className={cn("text-lg font-bold mt-3 mb-1", className)} {...props}>
{children}
</h3>
),
p: ({ className, children, ...props }: MdComponentProps) => (
<p className={cn("mb-3 leading-7", className)} {...props}>
{children}
</p>
),
a: ({ className, children, href, ...props }: MdComponentProps) => (
<Badge className="text-xs mx-0.5">
<a
className={cn("text-blue-400 hover:text-blue-300 text-xs", className)}
href={href}
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
</Badge>
),
ul: ({ className, children, ...props }: MdComponentProps) => (
<ul className={cn("list-disc pl-6 mb-3", className)} {...props}>
{children}
</ul>
),
ol: ({ className, children, ...props }: MdComponentProps) => (
<ol className={cn("list-decimal pl-6 mb-3", className)} {...props}>
{children}
</ol>
),
li: ({ className, children, ...props }: MdComponentProps) => (
<li className={cn("mb-1", className)} {...props}>
{children}
</li>
),
blockquote: ({ className, children, ...props }: MdComponentProps) => (
<blockquote
className={cn(
"border-l-4 border-neutral-600 pl-4 italic my-3 text-sm",
className
)}
{...props}
>
{children}
</blockquote>
),
code: ({ className, children, ...props }: MdComponentProps) => (
<code
className={cn(
"bg-neutral-900 rounded px-1 py-0.5 font-mono text-xs",
className
)}
{...props}
>
{children}
</code>
),
pre: ({ className, children, ...props }: MdComponentProps) => (
<pre
className={cn(
"bg-neutral-900 p-3 rounded-lg overflow-x-auto font-mono text-xs my-3",
className
)}
{...props}
>
{children}
</pre>
),
hr: ({ className, ...props }: MdComponentProps) => (
<hr className={cn("border-neutral-600 my-4", className)} {...props} />
),
table: ({ className, children, ...props }: MdComponentProps) => (
<div className="my-3 overflow-x-auto">
<table className={cn("border-collapse w-full", className)} {...props}>
{children}
</table>
</div>
),
th: ({ className, children, ...props }: MdComponentProps) => (
<th
className={cn(
"border border-neutral-600 px-3 py-2 text-left font-bold",
className
)}
{...props}
>
{children}
</th>
),
td: ({ className, children, ...props }: MdComponentProps) => (
<td
className={cn("border border-neutral-600 px-3 py-2", className)}
{...props}
>
{children}
</td>
),
};
// Props for HumanMessageBubble
interface HumanMessageBubbleProps {
message: Message;
mdComponents: typeof mdComponents;
}
// HumanMessageBubble Component
const HumanMessageBubble: React.FC<HumanMessageBubbleProps> = ({
message,
mdComponents,
}) => {
return (
<div
className={`text-white rounded-3xl break-words min-h-7 bg-neutral-700 max-w-[100%] sm:max-w-[90%] px-4 pt-3 rounded-br-lg`}
>
<ReactMarkdown components={mdComponents}>
{typeof message.content === "string"
? message.content
: JSON.stringify(message.content)}
</ReactMarkdown>
</div>
);
};
// Props for AiMessageBubble
interface AiMessageBubbleProps {
message: Message;
historicalActivity: ProcessedEvent[] | undefined;
liveActivity: ProcessedEvent[] | undefined;
isLastMessage: boolean;
isOverallLoading: boolean;
mdComponents: typeof mdComponents;
handleCopy: (text: string, messageId: string) => void;
copiedMessageId: string | null;
aspectRatio?: string;
imageSize?: string;
}
// AiMessageBubble Component
const AiMessageBubble: React.FC<AiMessageBubbleProps> = ({
message,
historicalActivity,
liveActivity,
isLastMessage,
isOverallLoading,
mdComponents,
handleCopy,
copiedMessageId,
aspectRatio,
imageSize,
}) => {
const [pageImages, setPageImages] = useState<
Record<
string,
{ status: "pending" | "done" | "error"; url?: string; error?: string }
>
>({});
const parsedPages = (() => {
const raw = message.content;
let data: any = raw;
if (typeof raw === "string") {
try {
data = JSON.parse(raw);
} catch (_e) {
return null;
}
}
if (
Array.isArray(data) &&
data.every(
(p) =>
p &&
typeof p === "object" &&
"id" in p &&
"detail" in p &&
typeof p.id === "number" &&
typeof p.detail === "string"
)
) {
return data as { id: number; detail: string }[];
}
return null;
})();
// Determine which activity events to show and if it's for a live loading message
const activityForThisBubble =
isLastMessage && isOverallLoading ? liveActivity : historicalActivity;
const isLiveActivityForThisBubble = isLastMessage && isOverallLoading;
useEffect(() => {
if (!parsedPages || !message.id) return;
const backendBase = import.meta.env.DEV
? "http://localhost:2024"
: "http://localhost:8123";
parsedPages.forEach((page) => {
const key = `${message.id}-${page.id}`;
if (pageImages[key]) return; // already requested
setPageImages((prev) => ({
...prev,
[key]: { status: "pending" },
}));
fetch(`${backendBase}/generate_image`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: page.detail,
number_of_images: 1,
aspect_ratio: aspectRatio || "16:9",
image_size: imageSize || "1K",
}),
})
.then(async (res) => {
if (!res.ok) throw new Error(await res.text());
return res.json();
})
.then((data) => {
const url =
data?.images && Array.isArray(data.images) ? data.images[0] : null;
if (!url) throw new Error("No image returned");
setPageImages((prev) => ({
...prev,
[key]: { status: "done", url },
}));
})
.catch((err) => {
setPageImages((prev) => ({
...prev,
[key]: { status: "error", error: String(err) },
}));
});
});
}, [parsedPages, message.id, pageImages]);
return (
<div className={`relative break-words flex flex-col`}>
{activityForThisBubble && activityForThisBubble.length > 0 && (
<div className="mb-3 border-b border-neutral-700 pb-3 text-xs">
<ActivityTimeline
processedEvents={activityForThisBubble}
isLoading={isLiveActivityForThisBubble}
/>
</div>
)}
{parsedPages ? (
<div className="space-y-3">
{parsedPages.map((page) => (
<div
key={page.id}
className="rounded-xl border border-neutral-700 bg-neutral-800/80 p-3 shadow-sm"
>
<div className="text-xs uppercase tracking-wide text-neutral-400 mb-1">
Page {page.id}
</div>
<ReactMarkdown components={mdComponents}>
{page.detail}
</ReactMarkdown>
<div className="mt-2">
{(() => {
const key = `${message.id}-${page.id}`;
const img = pageImages[key];
if (!img || img.status === "pending") {
return (
<div className="flex items-center text-xs text-neutral-400 gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Generating image...</span>
</div>
);
}
if (img.status === "error") {
return (
<div className="text-xs text-red-400">
Image generation failed: {img.error}
</div>
);
}
return (
<img
src={img.url}
alt={`Page ${page.id} illustration`}
className="mt-1 rounded-lg border border-neutral-700"
/>
);
})()}
</div>
</div>
))}
</div>
) : (
<ReactMarkdown components={mdComponents}>
{typeof message.content === "string"
? message.content
: JSON.stringify(message.content)}
</ReactMarkdown>
)}
<Button
variant="default"
className={`cursor-pointer bg-neutral-700 border-neutral-600 text-neutral-300 self-end ${
(typeof message.content === "string"
? message.content.length
: JSON.stringify(message.content).length) > 0
? "visible"
: "hidden"
}`}
onClick={() =>
handleCopy(
typeof message.content === "string"
? message.content
: JSON.stringify(message.content),
message.id!
)
}
>
{copiedMessageId === message.id ? "Copied" : "Copy"}
{copiedMessageId === message.id ? <CopyCheck /> : <Copy />}
</Button>
</div>
);
};
interface ChatMessagesViewProps {
messages: Message[];
isLoading: boolean;
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
onSubmit: (
inputValue: string,
effort: string,
model: string,
language: string,
aspectRatio: string,
imageSize: string
) => void;
onCancel: () => void;
liveActivityEvents: ProcessedEvent[];
historicalActivities: Record<string, ProcessedEvent[]>;
aspectRatio?: string;
imageSize?: string;
}
export function ChatMessagesView({
messages,
isLoading,
scrollAreaRef,
onSubmit,
onCancel,
liveActivityEvents,
historicalActivities,
aspectRatio,
imageSize,
}: ChatMessagesViewProps) {
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const handleCopy = async (text: string, messageId: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedMessageId(messageId);
setTimeout(() => setCopiedMessageId(null), 2000); // Reset after 2 seconds
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
return (
<div className="flex flex-col h-full">
<ScrollArea className="flex-1 overflow-y-auto" ref={scrollAreaRef}>
<div className="p-4 md:p-6 space-y-2 max-w-4xl mx-auto pt-16">
{messages.map((message, index) => {
const isLast = index === messages.length - 1;
return (
<div key={message.id || `msg-${index}`} className="space-y-3">
<div
className={`flex items-start gap-3 ${
message.type === "human" ? "justify-end" : ""
}`}
>
{message.type === "human" ? (
<HumanMessageBubble
message={message}
mdComponents={mdComponents}
/>
) : (
<AiMessageBubble
message={message}
historicalActivity={historicalActivities[message.id!]}
liveActivity={liveActivityEvents} // Pass global live events
isLastMessage={isLast}
isOverallLoading={isLoading} // Pass global loading state
mdComponents={mdComponents}
handleCopy={handleCopy}
copiedMessageId={copiedMessageId}
aspectRatio={aspectRatio}
imageSize={imageSize}
/>
)}
</div>
</div>
);
})}
{isLoading &&
(messages.length === 0 ||
messages[messages.length - 1].type === "human") && (
<div className="flex items-start gap-3 mt-3">
{" "}
{/* AI message row structure */}
<div className="relative group max-w-[85%] md:max-w-[80%] rounded-xl p-3 shadow-sm break-words bg-neutral-800 text-neutral-100 rounded-bl-none w-full min-h-[56px]">
{liveActivityEvents.length > 0 ? (
<div className="text-xs">
<ActivityTimeline
processedEvents={liveActivityEvents}
isLoading={true}
/>
</div>
) : (
<div className="flex items-center justify-start h-full">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400 mr-2" />
<span>Processing...</span>
</div>
)}
</div>
</div>
)}
</div>
</ScrollArea>
<InputForm
onSubmit={onSubmit}
isLoading={isLoading}
onCancel={onCancel}
hasHistory={messages.length > 0}
/>
</div>
);
}