chat-area.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. 'use client';
  2. import { useImperativeHandle, forwardRef, useRef, useCallback, useState, useMemo } from 'react';
  3. import type { SessionType } from '@/lib/types/chat';
  4. import type { LectureNoteEntry } from '@/lib/types/chat';
  5. import type { DiscussionRequest } from '@/components/roundtable';
  6. import type { Action, SpeechAction, DiscussionAction } from '@/lib/types/action';
  7. import { cn } from '@/lib/utils';
  8. import { useI18n } from '@/lib/hooks/use-i18n';
  9. import { useStageStore } from '@/lib/store';
  10. import { PanelRightClose, BookOpen, MessageSquare } from 'lucide-react';
  11. import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
  12. import { useChatSessions } from './use-chat-sessions';
  13. import { SessionList } from './session-list';
  14. import { LectureNotesView } from './lecture-notes-view';
  15. interface ChatAreaProps {
  16. className?: string;
  17. width?: number;
  18. onWidthChange?: (width: number) => void;
  19. collapsed?: boolean;
  20. onCollapseChange?: (collapsed: boolean) => void;
  21. activeBubbleId?: string | null;
  22. onActiveBubble?: (messageId: string | null) => void;
  23. onLiveSpeech?: (text: string | null, agentId?: string | null) => void;
  24. onSpeechProgress?: (ratio: number | null) => void;
  25. onThinking?: (state: { stage: string; agentId?: string } | null) => void;
  26. onCueUser?: (fromAgentId?: string, prompt?: string) => void;
  27. onLiveSessionError?: () => void;
  28. onStopSession?: () => void;
  29. onSegmentSealed?: (
  30. messageId: string,
  31. partId: string,
  32. fullText: string,
  33. agentId: string | null,
  34. ) => void;
  35. /** When provided and returns true, StreamBuffer holds on the current text item after reveal. */
  36. shouldHoldAfterReveal?: () => { holding: boolean; segmentDone: number } | boolean;
  37. currentSceneId?: string | null;
  38. }
  39. export interface ChatAreaRef {
  40. createSession: (type: SessionType, title: string) => Promise<string>;
  41. endSession: (sessionId: string) => Promise<void>;
  42. endActiveSession: () => Promise<void>;
  43. softPauseActiveSession: () => Promise<void>;
  44. resumeActiveSession: () => Promise<void>;
  45. sendMessage: (content: string) => Promise<void>;
  46. startDiscussion: (request: DiscussionRequest) => Promise<void>;
  47. startLecture: (sceneId: string) => Promise<string>;
  48. addLectureMessage: (sessionId: string, action: Action, actionIndex: number) => void;
  49. getIsStreaming: () => boolean;
  50. getActiveSessionType: () => string | null;
  51. getLectureMessageId: (sessionId: string) => string | null;
  52. pauseBuffer: (sessionId: string) => void;
  53. resumeBuffer: (sessionId: string) => void;
  54. pauseActiveLiveBuffer: () => boolean;
  55. resumeActiveLiveBuffer: () => void;
  56. switchToTab: (tab: 'lecture' | 'chat') => void;
  57. }
  58. const DEFAULT_WIDTH = 340;
  59. const MIN_WIDTH = 240;
  60. const MAX_WIDTH = 560;
  61. export const ChatArea = forwardRef<ChatAreaRef, ChatAreaProps>(
  62. (
  63. {
  64. className,
  65. width = DEFAULT_WIDTH,
  66. onWidthChange,
  67. collapsed = false,
  68. onCollapseChange,
  69. activeBubbleId,
  70. onActiveBubble,
  71. onLiveSpeech,
  72. onSpeechProgress,
  73. onThinking,
  74. onCueUser,
  75. onLiveSessionError,
  76. onStopSession,
  77. onSegmentSealed,
  78. shouldHoldAfterReveal,
  79. currentSceneId,
  80. },
  81. ref,
  82. ) => {
  83. const { t } = useI18n();
  84. const scenes = useStageStore((s) => s.scenes);
  85. const {
  86. sessions,
  87. activeSessionType,
  88. expandedSessionIds,
  89. isStreaming,
  90. createSession,
  91. endSession,
  92. endActiveSession,
  93. softPauseActiveSession,
  94. resumeActiveSession,
  95. sendMessage,
  96. startDiscussion,
  97. startLecture,
  98. addLectureMessage,
  99. toggleSessionExpand,
  100. getLectureMessageId,
  101. pauseBuffer,
  102. resumeBuffer,
  103. pauseActiveLiveBuffer,
  104. resumeActiveLiveBuffer,
  105. } = useChatSessions({
  106. onLiveSpeech,
  107. onSpeechProgress,
  108. onThinking,
  109. onCueUser,
  110. onActiveBubble,
  111. onLiveSessionError,
  112. onStopSession,
  113. onSegmentSealed,
  114. shouldHoldAfterReveal,
  115. });
  116. const [activeTab, setActiveTab] = useState<'lecture' | 'chat'>('lecture');
  117. const isDraggingRef = useRef(false);
  118. const [isDragging, setIsDragging] = useState(false);
  119. const bottomRef = useRef<HTMLDivElement>(null);
  120. // Derive lecture notes directly from scenes — updates reactively as scenes stream in
  121. // Preserves action order so spotlight/laser badges appear inline between speech texts
  122. const lectureNotes: LectureNoteEntry[] = useMemo(
  123. () =>
  124. scenes
  125. .filter((scene) => scene.actions && scene.actions.length > 0)
  126. .map((scene) => ({
  127. sceneId: scene.id,
  128. sceneTitle: scene.title,
  129. sceneOrder: scene.order,
  130. items: scene
  131. .actions!.filter(
  132. (a) =>
  133. a.type === 'speech' ||
  134. a.type === 'spotlight' ||
  135. a.type === 'laser' ||
  136. a.type === 'play_video' ||
  137. a.type === 'discussion',
  138. )
  139. .map((a) => {
  140. if (a.type === 'speech') {
  141. return {
  142. kind: 'speech' as const,
  143. text: (a as SpeechAction).text,
  144. };
  145. }
  146. return {
  147. kind: 'action' as const,
  148. type: a.type,
  149. label: a.type === 'discussion' ? (a as DiscussionAction).topic : undefined,
  150. };
  151. }),
  152. completedAt: scene.updatedAt || scene.createdAt || 0,
  153. }))
  154. .sort((a, b) => a.sceneOrder - b.sceneOrder),
  155. [scenes],
  156. );
  157. // Filter out lecture sessions for the Chat tab
  158. const chatSessions = useMemo(() => sessions.filter((s) => s.type !== 'lecture'), [sessions]);
  159. // Whether there's an active discussion/QA session (for amber dot on Chat tab)
  160. const hasActiveChatSession = useMemo(
  161. () => chatSessions.some((s) => s.status === 'active'),
  162. [chatSessions],
  163. );
  164. // Wrap endSession for QA/Discussion: also notify parent for engine cleanup
  165. const handleEndSession = useCallback(
  166. async (sessionId: string) => {
  167. await endSession(sessionId);
  168. onStopSession?.();
  169. },
  170. [endSession, onStopSession],
  171. );
  172. const switchToTab = useCallback((tab: 'lecture' | 'chat') => {
  173. setActiveTab(tab);
  174. }, []);
  175. useImperativeHandle(ref, () => ({
  176. createSession,
  177. endSession,
  178. endActiveSession,
  179. softPauseActiveSession,
  180. resumeActiveSession,
  181. sendMessage,
  182. startDiscussion,
  183. startLecture,
  184. addLectureMessage,
  185. getIsStreaming: () => isStreaming,
  186. getActiveSessionType: () => activeSessionType,
  187. getLectureMessageId,
  188. pauseBuffer,
  189. resumeBuffer,
  190. pauseActiveLiveBuffer,
  191. resumeActiveLiveBuffer,
  192. switchToTab,
  193. }));
  194. // Drag-to-resize
  195. const handleDragStart = useCallback(
  196. (e: React.MouseEvent) => {
  197. e.preventDefault();
  198. isDraggingRef.current = true;
  199. setIsDragging(true);
  200. const startX = e.clientX;
  201. const startWidth = width;
  202. const handleMouseMove = (me: MouseEvent) => {
  203. const delta = startX - me.clientX;
  204. const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta));
  205. onWidthChange?.(newWidth);
  206. };
  207. const handleMouseUp = () => {
  208. isDraggingRef.current = false;
  209. setIsDragging(false);
  210. document.removeEventListener('mousemove', handleMouseMove);
  211. document.removeEventListener('mouseup', handleMouseUp);
  212. document.body.style.cursor = '';
  213. document.body.style.userSelect = '';
  214. };
  215. document.body.style.cursor = 'col-resize';
  216. document.body.style.userSelect = 'none';
  217. document.addEventListener('mousemove', handleMouseMove);
  218. document.addEventListener('mouseup', handleMouseUp);
  219. },
  220. [width, onWidthChange],
  221. );
  222. const displayWidth = collapsed ? 0 : width;
  223. return (
  224. <div
  225. style={{
  226. width: displayWidth,
  227. transition: isDragging ? 'none' : 'width 0.3s ease',
  228. }}
  229. className={cn(
  230. 'bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-l border-gray-100 dark:border-gray-800 shadow-[-2px_0_24px_rgba(0,0,0,0.02)] flex flex-col shrink-0 z-20 relative overflow-visible',
  231. className,
  232. )}
  233. >
  234. {/* Drag handle */}
  235. {!collapsed && (
  236. <div
  237. onMouseDown={handleDragStart}
  238. className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize z-50 group hover:bg-purple-400/30 dark:hover:bg-purple-600/30 active:bg-purple-500/40 dark:active:bg-purple-500/40 transition-colors"
  239. >
  240. <div className="absolute left-0.5 top-1/2 -translate-y-1/2 w-0.5 h-8 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-purple-400 dark:group-hover:bg-purple-500 transition-colors" />
  241. </div>
  242. )}
  243. <div className={cn('flex flex-col w-full h-full overflow-hidden', collapsed && 'hidden')}>
  244. <Tabs
  245. value={activeTab}
  246. onValueChange={(v) => setActiveTab(v as 'lecture' | 'chat')}
  247. className="flex flex-col h-full gap-0"
  248. >
  249. {/* Tab header row */}
  250. <div className="h-10 flex items-center gap-1 shrink-0 mt-3 mb-1 px-3">
  251. <TabsList variant="line" className="h-full flex-1 w-0">
  252. <TabsTrigger value="lecture" className="text-xs gap-1 flex-1">
  253. <BookOpen className="w-3.5 h-3.5" />
  254. {t('chat.tabs.lecture')}
  255. </TabsTrigger>
  256. <TabsTrigger value="chat" className="text-xs gap-1 flex-1 relative">
  257. <MessageSquare className="w-3.5 h-3.5" />
  258. {t('chat.tabs.chat')}
  259. {/* Amber pulse dot when there's an active chat session and user is on Notes tab */}
  260. {hasActiveChatSession && activeTab === 'lecture' && (
  261. <span className="absolute -top-0.5 -right-0.5 flex h-2 w-2">
  262. <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
  263. <span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500" />
  264. </span>
  265. )}
  266. </TabsTrigger>
  267. </TabsList>
  268. {onCollapseChange && (
  269. <button
  270. onClick={() => onCollapseChange(true)}
  271. className="w-7 h-7 shrink-0 rounded-lg flex items-center justify-center bg-gray-100/80 dark:bg-gray-800/80 text-gray-500 dark:text-gray-400 ring-1 ring-black/[0.04] dark:ring-white/[0.06] hover:bg-gray-200/90 dark:hover:bg-gray-700/90 hover:text-gray-700 dark:hover:text-gray-200 active:scale-90 transition-all duration-200"
  272. >
  273. <PanelRightClose className="w-4 h-4" />
  274. </button>
  275. )}
  276. </div>
  277. {/* Notes Tab */}
  278. <TabsContent value="lecture" className="flex-1 overflow-hidden flex flex-col">
  279. <LectureNotesView notes={lectureNotes} currentSceneId={currentSceneId} />
  280. </TabsContent>
  281. {/* Chat Tab */}
  282. <TabsContent value="chat" className="flex-1 overflow-hidden flex flex-col">
  283. <div className="flex-1 overflow-y-auto overflow-x-hidden p-3 space-y-2 scrollbar-hide">
  284. {chatSessions.length === 0 ? (
  285. <div className="h-full flex flex-col items-center justify-center text-center p-6 opacity-50">
  286. <div className="w-12 h-12 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-3 text-gray-300 dark:text-gray-600">
  287. <MessageSquare className="w-6 h-6" />
  288. </div>
  289. <p className="text-xs font-medium text-gray-500 dark:text-gray-400">
  290. {t('chat.noConversations')}
  291. </p>
  292. <p className="text-[10px] text-gray-400 dark:text-gray-500 mt-1">
  293. {t('chat.startConversation')}
  294. </p>
  295. </div>
  296. ) : (
  297. <>
  298. <SessionList
  299. sessions={chatSessions}
  300. expandedSessionIds={expandedSessionIds}
  301. isStreaming={isStreaming}
  302. activeBubbleId={activeBubbleId}
  303. onToggleExpand={toggleSessionExpand}
  304. onEndSession={handleEndSession}
  305. />
  306. <div ref={bottomRef} />
  307. </>
  308. )}
  309. </div>
  310. </TabsContent>
  311. </Tabs>
  312. </div>
  313. </div>
  314. );
  315. },
  316. );
  317. ChatArea.displayName = 'ChatArea';