chat-session.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. 'use client';
  2. import { useEffect, useRef, useCallback, memo } from 'react';
  3. import { motion, AnimatePresence } from 'motion/react';
  4. import type { ChatSession, ChatMessageMetadata } from '@/lib/types/chat';
  5. import type { UIMessage } from 'ai';
  6. import { cn } from '@/lib/utils';
  7. import { useI18n } from '@/lib/hooks/use-i18n';
  8. import { AvatarDisplay } from '@/components/ui/avatar-display';
  9. import { CircleStop } from 'lucide-react';
  10. import { InlineActionTag } from './inline-action-tag';
  11. import { useUserProfileStore } from '@/lib/store/user-profile';
  12. /** Extended message part type covering standard + custom action parts */
  13. interface MessagePart {
  14. type: string;
  15. text?: string;
  16. _partId?: string;
  17. actionName?: string;
  18. state?: string;
  19. }
  20. interface ChatSessionProps {
  21. readonly session: ChatSession;
  22. readonly isActive: boolean;
  23. readonly isStreaming?: boolean;
  24. readonly activeBubbleId?: string | null;
  25. readonly onEndSession?: (sessionId: string) => void;
  26. }
  27. const AVATARS = {
  28. teacher: '/avatars/teacher.png',
  29. user: '/avatars/user.png',
  30. };
  31. /**
  32. * MessageBubble — renders one message as a single chat bubble.
  33. *
  34. * Text is already paced by the StreamBuffer (30ms / 1 char) before it reaches
  35. * React state. No UI-layer animation is needed — we render parts directly.
  36. * Action badges only appear once the buffer's tick loop reaches them (after
  37. * all preceding text is fully revealed).
  38. */
  39. const MessageBubble = memo(function MessageBubble({
  40. message,
  41. isUser,
  42. isTeacher,
  43. isStreaming,
  44. isLastMessage,
  45. isActive,
  46. }: {
  47. message: UIMessage<ChatMessageMetadata>;
  48. isUser: boolean;
  49. isTeacher: boolean;
  50. isStreaming: boolean;
  51. isLastMessage: boolean;
  52. isActive: boolean;
  53. }) {
  54. const parts: MessagePart[] = (message.parts || []) as MessagePart[];
  55. const isLive = !!(isStreaming && isLastMessage);
  56. // ── Determine renderable content ──
  57. const hasContent = parts.some(
  58. (p: MessagePart) => (p.type === 'text' && p.text) || p.type?.startsWith('action-'),
  59. );
  60. // Loading dots (between agent_start and first text_delta)
  61. if (!hasContent && isActive && message.role === 'assistant') {
  62. return (
  63. <div className="flex gap-1.5 items-center py-1.5 px-1">
  64. <span
  65. className={cn(
  66. 'w-1.5 h-1.5 rounded-full animate-pulse',
  67. isTeacher
  68. ? 'bg-purple-400/70 dark:bg-purple-500/70'
  69. : 'bg-indigo-400/70 dark:bg-indigo-500/70',
  70. )}
  71. />
  72. <span
  73. className={cn(
  74. 'w-1.5 h-1.5 rounded-full animate-pulse',
  75. isTeacher
  76. ? 'bg-purple-400/70 dark:bg-purple-500/70'
  77. : 'bg-indigo-400/70 dark:bg-indigo-500/70',
  78. )}
  79. style={{ animationDelay: '200ms' }}
  80. />
  81. <span
  82. className={cn(
  83. 'w-1.5 h-1.5 rounded-full animate-pulse',
  84. isTeacher
  85. ? 'bg-purple-400/70 dark:bg-purple-500/70'
  86. : 'bg-indigo-400/70 dark:bg-indigo-500/70',
  87. )}
  88. style={{ animationDelay: '400ms' }}
  89. />
  90. </div>
  91. );
  92. }
  93. if (!hasContent) return null;
  94. const lastTextIdx = parts.reduce(
  95. (acc: number, p: MessagePart, i: number) => (p.type === 'text' && p.text ? i : acc),
  96. -1,
  97. );
  98. return (
  99. <div
  100. className={cn(
  101. 'inline-block px-2.5 py-1.5 rounded-xl text-[12px] leading-relaxed max-w-full text-left transition-shadow duration-300',
  102. isUser
  103. ? 'bg-gradient-to-br from-purple-600 to-purple-700 dark:from-purple-500 dark:to-purple-600 text-white rounded-tr-sm shadow-sm shadow-purple-300/30 dark:shadow-purple-900/50 ring-1 ring-purple-500/20'
  104. : isTeacher
  105. ? 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-100 dark:border-gray-700 rounded-tl-sm shadow-sm'
  106. : 'bg-indigo-50 dark:bg-indigo-900/20 text-indigo-900 dark:text-indigo-200 border border-indigo-100/50 dark:border-indigo-800/50 rounded-tl-sm',
  107. )}
  108. >
  109. <span className="break-words">
  110. {parts.map((part: MessagePart, i: number) => {
  111. if (part.type === 'text' || part.type === 'step-start') {
  112. const text = part.type === 'text' ? part.text : '';
  113. if (!text) return null;
  114. const isLast = i === lastTextIdx;
  115. return (
  116. <span key={`${message.id}-${i}`}>
  117. {text}
  118. {isLive && isLast && (
  119. <span className="inline-block w-1.5 h-1.5 rounded-full bg-current opacity-50 animate-pulse ml-1 align-middle" />
  120. )}
  121. {message.metadata?.interrupted && isLast && !isLive && (
  122. <span className="inline-block w-1.5 h-1.5 rounded-full bg-red-500 ml-1 align-middle" />
  123. )}
  124. </span>
  125. );
  126. }
  127. if (part.type?.startsWith('action-')) {
  128. return (
  129. <InlineActionTag
  130. key={`${message.id}-action-${i}`}
  131. actionName={part.actionName || part.type.replace('action-', '')}
  132. state={part.state || 'result'}
  133. />
  134. );
  135. }
  136. return null;
  137. })}
  138. </span>
  139. </div>
  140. );
  141. });
  142. export function ChatSessionComponent({
  143. session,
  144. isActive,
  145. isStreaming,
  146. activeBubbleId,
  147. onEndSession,
  148. }: ChatSessionProps) {
  149. const { t } = useI18n();
  150. const userProfileAvatar = useUserProfileStore((s) => s.avatar);
  151. const scrollContainerRef = useRef<HTMLDivElement>(null);
  152. const bottomRef = useRef<HTMLDivElement>(null);
  153. const activeBubbleRef = useRef<HTMLDivElement>(null);
  154. const isDiscussion = session.type === 'discussion';
  155. const isQA = session.type === 'qa';
  156. const canEnd = (isDiscussion || isQA) && session.status === 'active';
  157. const isEnded = session.status === 'completed' && (isDiscussion || isQA);
  158. // Track whether user is at the bottom of the scroll container.
  159. // When user scrolls up to read history, auto-scroll is suppressed.
  160. const isAtBottomRef = useRef(true);
  161. const handleScroll = useCallback(() => {
  162. const el = scrollContainerRef.current;
  163. if (!el) return;
  164. isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
  165. }, []);
  166. // Auto-scroll: smooth scroll when a NEW message arrives — always (new agent bubble should be visible)
  167. const msgCount = session.messages.length;
  168. useEffect(() => {
  169. if (bottomRef.current) {
  170. bottomRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
  171. isAtBottomRef.current = true;
  172. }
  173. }, [msgCount]);
  174. // Auto-scroll: rAF-throttled instant scroll as text grows — only when user is at bottom
  175. const scrollRaf = useRef(0);
  176. useEffect(() => {
  177. if (!isAtBottomRef.current) return;
  178. cancelAnimationFrame(scrollRaf.current);
  179. scrollRaf.current = requestAnimationFrame(() => {
  180. const el = scrollContainerRef.current;
  181. if (el) el.scrollTop = el.scrollHeight;
  182. });
  183. }, [session.messages]);
  184. // Scroll to active bubble when it changes
  185. useEffect(() => {
  186. if (activeBubbleId && activeBubbleRef.current) {
  187. activeBubbleRef.current.scrollIntoView({
  188. behavior: 'smooth',
  189. block: 'nearest',
  190. });
  191. isAtBottomRef.current = true;
  192. }
  193. }, [activeBubbleId]);
  194. if (session.messages.length === 0 && !isActive) {
  195. return (
  196. <div className="h-20 flex items-center justify-center text-center px-2">
  197. <p className="text-[10px] text-gray-400 dark:text-gray-500">{t('chat.noMessages')}</p>
  198. </div>
  199. );
  200. }
  201. // Button text based on session type
  202. const endButtonText = isDiscussion ? t('chat.stopDiscussion') : t('chat.endQA');
  203. return (
  204. <div className="flex flex-col">
  205. {/* Messages */}
  206. <div
  207. ref={scrollContainerRef}
  208. onScroll={handleScroll}
  209. className="space-y-1 overflow-y-auto scrollbar-hide"
  210. >
  211. {session.messages.map((message, msgIdx) => {
  212. const isUser = message.metadata?.originalRole === 'user';
  213. const isTeacher = message.metadata?.originalRole === 'teacher';
  214. const avatar = isUser
  215. ? userProfileAvatar || AVATARS.user
  216. : message.metadata?.senderAvatar || AVATARS.teacher;
  217. const isActiveBubble = activeBubbleId === message.id;
  218. const isLastMessage = msgIdx === session.messages.length - 1;
  219. return (
  220. <motion.div
  221. key={message.id}
  222. ref={isActiveBubble ? activeBubbleRef : undefined}
  223. initial={{ opacity: 0, y: 4 }}
  224. animate={
  225. isActiveBubble
  226. ? {
  227. opacity: 1,
  228. y: 0,
  229. boxShadow: [
  230. '0 0 0 0 rgba(124, 58, 237, 0)',
  231. '0 0 20px 0 rgba(124, 58, 237, 0.15)',
  232. '0 0 8px 0 rgba(124, 58, 237, 0.08)',
  233. ],
  234. }
  235. : {
  236. opacity: 1,
  237. y: 0,
  238. boxShadow: '0 0 0 0 rgba(124, 58, 237, 0)',
  239. }
  240. }
  241. transition={
  242. isActiveBubble
  243. ? {
  244. boxShadow: {
  245. duration: 2.5,
  246. repeat: Infinity,
  247. ease: 'easeInOut',
  248. },
  249. default: { duration: 0.3 },
  250. }
  251. : { duration: 0.3 }
  252. }
  253. className={cn(
  254. 'flex gap-2 px-1.5 py-1 rounded-lg border-l-[3px] border-l-transparent transition-[background-color,border-color] duration-300',
  255. isUser && 'flex-row-reverse',
  256. isActiveBubble &&
  257. 'border-l-violet-500 dark:border-l-violet-400 bg-violet-50/50 dark:bg-violet-900/20',
  258. )}
  259. >
  260. {/* Mini Avatar */}
  261. <div className="w-5 h-5 rounded-full overflow-hidden bg-gray-100 dark:bg-gray-800 shrink-0 mt-0.5 ring-1 ring-gray-200/50 dark:ring-gray-700/50">
  262. <AvatarDisplay src={avatar} alt="avatar" className="text-xs" />
  263. </div>
  264. {/* Content */}
  265. <div className={cn('flex-1 min-w-0', isUser && 'text-right')}>
  266. <span
  267. className={cn(
  268. 'text-[9px] font-bold uppercase tracking-wider block mb-0.5',
  269. isUser
  270. ? 'text-purple-500 dark:text-purple-400'
  271. : isTeacher
  272. ? 'text-purple-400 dark:text-purple-300'
  273. : 'text-indigo-400 dark:text-indigo-300',
  274. )}
  275. >
  276. {(() => {
  277. const agentId = message.metadata?.agentId;
  278. if (agentId) {
  279. const i18nName = t(`settings.agentNames.${agentId}`);
  280. if (i18nName !== `settings.agentNames.${agentId}`) return i18nName;
  281. }
  282. return message.metadata?.senderName || t('chat.unknown');
  283. })()}
  284. </span>
  285. <MessageBubble
  286. message={message}
  287. isUser={isUser}
  288. isTeacher={isTeacher}
  289. isStreaming={!!isStreaming}
  290. isLastMessage={isLastMessage}
  291. isActive={isActive}
  292. />
  293. </div>
  294. </motion.div>
  295. );
  296. })}
  297. {/* Session ended indicator */}
  298. <AnimatePresence>
  299. {isEnded && (
  300. <motion.div
  301. initial={{ opacity: 0, scaleX: 0 }}
  302. animate={{ opacity: 1, scaleX: 1 }}
  303. exit={{ opacity: 0, scaleX: 0 }}
  304. transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
  305. className="mx-3 mt-2 mb-1 flex items-center gap-2"
  306. >
  307. <div className="flex-1 h-px bg-gradient-to-r from-transparent via-gray-200 dark:via-gray-700 to-transparent" />
  308. <span className="flex items-center gap-1 text-[9px] text-gray-400 dark:text-gray-500 font-medium">
  309. <CircleStop className="w-2.5 h-2.5" />
  310. {t('chat.ended')}
  311. </span>
  312. <div className="flex-1 h-px bg-gradient-to-r from-transparent via-gray-200 dark:via-gray-700 to-transparent" />
  313. </motion.div>
  314. )}
  315. </AnimatePresence>
  316. <div ref={bottomRef} />
  317. </div>
  318. {/* End Session Button (for Q&A and Discussion) */}
  319. <AnimatePresence>
  320. {canEnd && onEndSession && (
  321. <motion.button
  322. initial={{ opacity: 0, y: 5 }}
  323. animate={{ opacity: 1, y: 0 }}
  324. exit={{ opacity: 0, y: 5 }}
  325. whileHover={{ scale: 1.02 }}
  326. onClick={() => onEndSession(session.id)}
  327. className="mt-2 mx-2 bg-red-50/80 dark:bg-red-900/20 backdrop-blur-md text-red-600 dark:text-red-400 border border-red-200/50 dark:border-red-800/50 px-3 py-1.5 rounded-full text-[11px] font-semibold flex items-center justify-center gap-1.5 transition-all shadow-sm hover:shadow-md"
  328. >
  329. <span className="relative flex h-2 w-2">
  330. <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 dark:bg-red-500 opacity-75"></span>
  331. <span className="relative inline-flex rounded-full h-2 w-2 bg-red-500 dark:bg-red-400"></span>
  332. </span>
  333. {endButtonText}
  334. </motion.button>
  335. )}
  336. </AnimatePresence>
  337. </div>
  338. );
  339. }