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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 146 additions & 20 deletions frontend/src/components/conversation-view/chat-session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,52 @@ import { AttachmentBubble } from "@/components/conversation-view/attachment-bubb
import { MessageError } from "@/components/conversation-view/message-error.tsx";
import { MessageBubbleTyping } from "@/components/conversation-view/message-bubble-typing.tsx";
import { ChatWindow } from "@/components/conversation-view/chat-window.tsx";
import { ProductWidget } from "@/components/conversation-view/product-widget.tsx";

import type { AgentDetails } from "@/lib/types";
import type { Conversation as ConversationSchema } from '@/lib/types.ts';
import type { Message, MessageFile } from "@/components/conversation-view/message-composer.tsx";
import type {AgentDetails, Conversation as ConversationSchema} from '@/lib/types.ts';
import type {Message, MessageFile} from "@/components/conversation-view/message-composer.tsx";
import { Message as ApiMessage } from "src/lib/types.ts"

export type FileMessageProps = {
export type BaseMessageProps = {
id: number | null;
type: "file" | "human" | "ai" | "system" | "widget";
}

export type FileMessageProps = BaseMessageProps & {
type: "file";
file_name: string;
file_type: string;
};
export type WidgetMessageProps = BaseMessageProps & {
type: "widget";
widget_data: WidgetData;
isStreaming: boolean;
};

export type MessageProps = {
export type TextMessage = BaseMessageProps & {
type: "human" | "ai" | "system";
text: string;
id: number | null;
} | FileMessageProps;
isStreaming?: boolean;
}
export type MessageProps = FileMessageProps | WidgetMessageProps | TextMessage;

export interface ProductData {
id: number;
name: string;
sku: string;
slug: string;
description: string;
categories: string;
properties: string;
price?: number;
}

export interface WidgetData {
widget_type: string;
message?: string;
data?: ProductData[];
}


interface ChatSessionProps {
pendingMessage?: Message;
Expand Down Expand Up @@ -155,6 +184,67 @@ export function ChatSession({ pendingMessage }: ChatSessionProps) {
action: () => {
setAgentAction(data.data.output);
},
product_widget_start: () => {
setIsLoading(false);
setIsAgentLoading(false);

let widgetData;
try {
widgetData = typeof data.data === 'string' ? JSON.parse(data.data) : data.data;
} catch {
widgetData = { message: String(data.data), number_of_products: 0 };
}

const newWidget: WidgetMessageProps = {
type: "widget",
id: null,
widget_data: {
widget_type: "product_widget",
message: widgetData.message || '',
data: [],
},
isStreaming: true,
};

setMessages((prevMessages) => [...prevMessages, newWidget]);
scrollToLastMessage();
},
product_widget_end: () => {
setMessages((prevMessages) => {
const lastMessage = prevMessages[prevMessages.length - 1];
if (lastMessage && lastMessage.type === "widget" && lastMessage.isStreaming) {
return [
...prevMessages.slice(0, -1),
{ ...lastMessage, isStreaming: false }
];
}
return prevMessages;
});
},
product_widget_product: () => {
const productData = data.data.chunk as ProductData;

setMessages((prevMessages) => {
const lastMessage = prevMessages[prevMessages.length - 1];

if (lastMessage && lastMessage.type === "widget" && lastMessage.widget_data) {
const updatedData = [...(lastMessage.widget_data.data || []), productData];
return [
...prevMessages.slice(0, -1),
{
...lastMessage,
widget_data: {
...lastMessage.widget_data,
data: updatedData
}
}
];
}
return prevMessages;
});

scrollToLastMessage();
},
error: () => {
setIsLoading(false);
setIsAgentLoading(false);
Expand Down Expand Up @@ -219,6 +309,23 @@ export function ChatSession({ pendingMessage }: ChatSessionProps) {
});
};

const createMessageFromResponse = (response: ApiMessage): MessageProps => {
if (response.widget_data) {
return {
type: "widget",
id: response.id,
widget_data: response.widget_data as WidgetData,
isStreaming: false,
} as WidgetMessageProps;
} else {
return {
type: response.type as "ai" | "system",
text: response.text,
id: response.id,
} as TextMessage;
}
};

const updateTaskStatus = async (conversationId: number, taskHandle: TaskHandle, streaming: boolean) => {
if (streaming) {
return;
Expand All @@ -229,10 +336,8 @@ export function ChatSession({ pendingMessage }: ChatSessionProps) {

if (response) {
setIsAgentLoading(false);
setMessages((prev) => [
...prev,
{ type: "ai", text: response.text, id: response.id }
]);
const message = createMessageFromResponse(response);
setMessages((prev) => [...prev, message]);
setIsLoading(false);
scrollToLastMessage();
} else {
Expand Down Expand Up @@ -317,7 +422,6 @@ export function ChatSession({ pendingMessage }: ChatSessionProps) {
<div className="grow flex-1 space-y-4">
{messages.map((message, index) => {
const inMessageGroup = isUserMessage(message) && index > 0 && isUserMessage(messages[index - 1]);

if (message.type === "system") {
return (
<MessageError key={index} text={message.text} />
Expand All @@ -329,20 +433,42 @@ export function ChatSession({ pendingMessage }: ChatSessionProps) {
<AttachmentBubble key={index} variant="primary" message={message} inMessageGroup={inMessageGroup} />
);
}

if (message.type === 'ai' && message.text === '') {
return (<MessageBubbleTyping key={index} text={agentAction} />);
if (message.type === 'ai' && message.text === '' && index === messages.length) {
return (<MessageBubbleTyping key={index} text={"agentAction"} />);
}

return (
<MessageBubble
if (message.type === 'ai' && message.text !== '') {
return (
<MessageBubble
key={index}
text={message.text}
variant={message.type === "human" ? "primary" : "secondary"}
variant="secondary"
questionId={message.id}
inMessageGroup={inMessageGroup}
/>
);
);
}
if (message.type === "widget" && message.widget_data?.widget_type === "product_widget") {
return (
<ProductWidget
key={index}
message={message.widget_data.message}
products={message.widget_data.data || []}
isStreaming={message.isStreaming}
expectedProductCount={message.widget_data.data?.length}
/>
);
}
if (message.type === "human") {
return (
<MessageBubble
key={index}
text={message.text}
variant="primary"
questionId={message.id}
inMessageGroup={inMessageGroup}
/>
);
}
})}
{isAgentLoading && <MessageBubbleTyping text={agentAction} />}
<div className="mb-4" />
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/components/conversation-view/message-bubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ export function MessageBubble({ text, variant, questionId, inMessageGroup }: Mes

useEffect(() => {
const processText = async () => {
const rawHtml = await marked(text);
const renderer = new marked.Renderer();
renderer.link = ({ href, title, tokens }) => {
const text = tokens.map(token => token.raw).join('');
const titleAttr = title ? ` title="${title}"` : '';
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`;
};

const rawHtml = await marked(text, { renderer });
const cleanHtml = DOMPurify.sanitize(rawHtml);
setSanitizedHtml(cleanHtml);
};
Expand Down
103 changes: 103 additions & 0 deletions frontend/src/components/conversation-view/product-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";

export interface ProductCardProps {
id: number;
name: string;
sku: string;
slug: string;
description: string;
categories: string;
properties: string;
price?: number;
}

export function ProductCard({
name,
sku,
description,
categories,
properties,
price
}: ProductCardProps) {
let parsedCategories: string[] = [];
if (categories) {
try {
const parsed = JSON.parse(categories);
parsedCategories = Array.isArray(parsed) ? parsed : [String(parsed)];
} catch {
parsedCategories = categories.split(',').map(c => c.trim()).filter(Boolean);
}
}

let parsedProperties: Record<string, string> = {};
if (properties) {
try {
const parsed = JSON.parse(properties);
if (typeof parsed === 'object' && parsed !== null) {
parsedProperties = parsed;
}
} catch {
properties.split(';').forEach(pair => {
const [key, value] = pair.split('->').map(s => s.trim());
if (key && value) {
parsedProperties[key] = value;
}
});
}
}

return (
<Card className="transition-all hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-baseline gap-2 mb-0.5">
<CardTitle className="text-base truncate">{name}</CardTitle>
{price !== undefined && (
<span className="text-base font-bold text-primary whitespace-nowrap">
${price.toFixed(2)}
</span>
)}
</div>
<CardDescription className="text-xs">
SKU: {sku}
</CardDescription>
</CardHeader>
<CardContent className="pt-0 pb-4">
{description && (
<p className="text-xs text-gray-600 mb-2 line-clamp-2">
{description}
</p>
)}

{parsedCategories.length > 0 && (
<div className="mb-2">
<div className="flex flex-wrap gap-1">
{parsedCategories.slice(0, 3).map((category: string, index: number) => (
<Badge key={index} variant="secondary" className="text-[10px] px-1.5 py-0">
{category}
</Badge>
))}
{parsedCategories.length > 3 && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
+{parsedCategories.length - 3} more
</Badge>
)}
</div>
</div>
)}

{Object.keys(parsedProperties).length > 0 && (
<div className="text-[11px] text-gray-500 space-y-0.5">
{Object.entries(parsedProperties).slice(0, 3).map(([key, value], index) => (
<div key={index} className="flex justify-between gap-2">
<span className="font-medium">{key}:</span>
<span className="truncate">{String(value)}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

Loading
Loading