mirror of
https://github.com/Zippland/NanoComic.git
synced 2026-02-07 00:22:29 +08:00
471 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|