File size: 3,525 Bytes
c0ddd13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import { useEffect } from "react";
import { motion } from "motion/react";
import { Bot } from "lucide-react";
import type { Message } from "./types";
import TypingIndicator from "./TypingIndicator";
import FeedbackWidget from "./FeedbackWidget";
import MarkdownRenderer from "./renderers/MarkdownRenderer";

interface MessageBubbleProps {
  message: Message;
  isStreamingPlaceholder?: boolean;
}

export default function MessageBubble({ message, isStreamingPlaceholder }: MessageBubbleProps) {
  useEffect(() => {
    if (message.role === "assistant") {
      // console.log(
      //   `[MessageBubble] id=...${message.id.slice(-6)} isStreamingPlaceholder=${isStreamingPlaceholder} contentLen=${message.content.length}`
      // );
    }
  }, [isStreamingPlaceholder, message.id, message.role, message.content.length]);

  if (message.role === "user") {
    return (
      <motion.div
        className="flex justify-end px-3 sm:px-4 py-1"
        initial={{ opacity: 0, y: 12 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.3, ease: "easeOut" }}
      >
        <div className="max-w-[88%] sm:max-w-[75%] xl:max-w-[65%] px-4 py-3 rounded-2xl rounded-br-sm bg-gradient-to-br from-brand-green-light to-brand-green text-white text-sm xl:text-base leading-relaxed shadow-md shadow-brand-green/20">
          {message.content}
        </div>
      </motion.div>
    );
  }

  // Show TypingIndicator when the assistant placeholder has no content yet
  if (isStreamingPlaceholder && message.content === "") {
    return <TypingIndicator />;
  }

  return (
    <motion.div
      initial={{ opacity: 0, y: 12 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.3, ease: "easeOut" }}
    >
      <div className="flex gap-3 px-3 sm:px-4 py-2">
        <div className="w-7 h-7 xl:w-8 xl:h-8 rounded-full bg-gradient-to-br from-brand-green-light to-brand-green flex-shrink-0 flex items-center justify-center shadow-sm">
          <Bot className="h-4 w-4 text-white" />
        </div>

        <div className="max-w-[92%] sm:max-w-[88%] xl:max-w-[80%] bg-white border border-neutral-100 rounded-2xl rounded-tl-sm shadow-sm px-4 xl:px-5 py-3 xl:py-4">
          <MarkdownRenderer
            key={isStreamingPlaceholder ? "streaming" : "final"}
            content={message.content}
            skipPreprocess={isStreamingPlaceholder}
          />

          {message.sources && message.sources.length > 0 && (
            <div className="mt-3 pt-2 border-t border-neutral-100">
              <p className="text-[10px] text-neutral-400 mb-1.5">Sources:</p>
              <div className="flex flex-wrap gap-1">
                {message.sources.map((src, i) => (
                  <span
                    key={i}
                    className="text-[10px] bg-neutral-100 text-neutral-600 px-2 py-0.5 rounded-full border border-neutral-200"
                    title={src.page_label ? `Page ${src.page_label}` : undefined}
                  >
                    📄 {src.filename}
                    {src.page_label ? ` p.${src.page_label}` : ""}
                  </span>
                ))}
              </div>
            </div>
          )}
        </div>
      </div>

      {!isStreamingPlaceholder && (
        <FeedbackWidget
          messageId={message.id}
          content={message.content}
          audioText={message.audioText}
          audioChunks={message.audioChunks}
          audioSampleRate={message.audioSampleRate}
        />
      )}
    </motion.div>
  );
}