| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340 |
- 'use client';
- import { useImperativeHandle, forwardRef, useRef, useCallback, useState, useMemo } from 'react';
- import type { SessionType } from '@/lib/types/chat';
- import type { LectureNoteEntry } from '@/lib/types/chat';
- import type { DiscussionRequest } from '@/components/roundtable';
- import type { Action, SpeechAction, DiscussionAction } from '@/lib/types/action';
- import { cn } from '@/lib/utils';
- import { useI18n } from '@/lib/hooks/use-i18n';
- import { useStageStore } from '@/lib/store';
- import { PanelRightClose, BookOpen, MessageSquare } from 'lucide-react';
- import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
- import { useChatSessions } from './use-chat-sessions';
- import { SessionList } from './session-list';
- import { LectureNotesView } from './lecture-notes-view';
- interface ChatAreaProps {
- className?: string;
- width?: number;
- onWidthChange?: (width: number) => void;
- collapsed?: boolean;
- onCollapseChange?: (collapsed: boolean) => void;
- activeBubbleId?: string | null;
- onActiveBubble?: (messageId: string | null) => void;
- onLiveSpeech?: (text: string | null, agentId?: string | null) => void;
- onSpeechProgress?: (ratio: number | null) => void;
- onThinking?: (state: { stage: string; agentId?: string } | null) => void;
- onCueUser?: (fromAgentId?: string, prompt?: string) => void;
- onLiveSessionError?: () => void;
- onStopSession?: () => void;
- onSegmentSealed?: (
- messageId: string,
- partId: string,
- fullText: string,
- agentId: string | null,
- ) => void;
- /** When provided and returns true, StreamBuffer holds on the current text item after reveal. */
- shouldHoldAfterReveal?: () => { holding: boolean; segmentDone: number } | boolean;
- currentSceneId?: string | null;
- }
- export interface ChatAreaRef {
- createSession: (type: SessionType, title: string) => Promise<string>;
- endSession: (sessionId: string) => Promise<void>;
- endActiveSession: () => Promise<void>;
- softPauseActiveSession: () => Promise<void>;
- resumeActiveSession: () => Promise<void>;
- sendMessage: (content: string) => Promise<void>;
- startDiscussion: (request: DiscussionRequest) => Promise<void>;
- startLecture: (sceneId: string) => Promise<string>;
- addLectureMessage: (sessionId: string, action: Action, actionIndex: number) => void;
- getIsStreaming: () => boolean;
- getActiveSessionType: () => string | null;
- getLectureMessageId: (sessionId: string) => string | null;
- pauseBuffer: (sessionId: string) => void;
- resumeBuffer: (sessionId: string) => void;
- pauseActiveLiveBuffer: () => boolean;
- resumeActiveLiveBuffer: () => void;
- switchToTab: (tab: 'lecture' | 'chat') => void;
- }
- const DEFAULT_WIDTH = 340;
- const MIN_WIDTH = 240;
- const MAX_WIDTH = 560;
- export const ChatArea = forwardRef<ChatAreaRef, ChatAreaProps>(
- (
- {
- className,
- width = DEFAULT_WIDTH,
- onWidthChange,
- collapsed = false,
- onCollapseChange,
- activeBubbleId,
- onActiveBubble,
- onLiveSpeech,
- onSpeechProgress,
- onThinking,
- onCueUser,
- onLiveSessionError,
- onStopSession,
- onSegmentSealed,
- shouldHoldAfterReveal,
- currentSceneId,
- },
- ref,
- ) => {
- const { t } = useI18n();
- const scenes = useStageStore((s) => s.scenes);
- const {
- sessions,
- activeSessionType,
- expandedSessionIds,
- isStreaming,
- createSession,
- endSession,
- endActiveSession,
- softPauseActiveSession,
- resumeActiveSession,
- sendMessage,
- startDiscussion,
- startLecture,
- addLectureMessage,
- toggleSessionExpand,
- getLectureMessageId,
- pauseBuffer,
- resumeBuffer,
- pauseActiveLiveBuffer,
- resumeActiveLiveBuffer,
- } = useChatSessions({
- onLiveSpeech,
- onSpeechProgress,
- onThinking,
- onCueUser,
- onActiveBubble,
- onLiveSessionError,
- onStopSession,
- onSegmentSealed,
- shouldHoldAfterReveal,
- });
- const [activeTab, setActiveTab] = useState<'lecture' | 'chat'>('lecture');
- const isDraggingRef = useRef(false);
- const [isDragging, setIsDragging] = useState(false);
- const bottomRef = useRef<HTMLDivElement>(null);
- // Derive lecture notes directly from scenes — updates reactively as scenes stream in
- // Preserves action order so spotlight/laser badges appear inline between speech texts
- const lectureNotes: LectureNoteEntry[] = useMemo(
- () =>
- scenes
- .filter((scene) => scene.actions && scene.actions.length > 0)
- .map((scene) => ({
- sceneId: scene.id,
- sceneTitle: scene.title,
- sceneOrder: scene.order,
- items: scene
- .actions!.filter(
- (a) =>
- a.type === 'speech' ||
- a.type === 'spotlight' ||
- a.type === 'laser' ||
- a.type === 'play_video' ||
- a.type === 'discussion',
- )
- .map((a) => {
- if (a.type === 'speech') {
- return {
- kind: 'speech' as const,
- text: (a as SpeechAction).text,
- };
- }
- return {
- kind: 'action' as const,
- type: a.type,
- label: a.type === 'discussion' ? (a as DiscussionAction).topic : undefined,
- };
- }),
- completedAt: scene.updatedAt || scene.createdAt || 0,
- }))
- .sort((a, b) => a.sceneOrder - b.sceneOrder),
- [scenes],
- );
- // Filter out lecture sessions for the Chat tab
- const chatSessions = useMemo(() => sessions.filter((s) => s.type !== 'lecture'), [sessions]);
- // Whether there's an active discussion/QA session (for amber dot on Chat tab)
- const hasActiveChatSession = useMemo(
- () => chatSessions.some((s) => s.status === 'active'),
- [chatSessions],
- );
- // Wrap endSession for QA/Discussion: also notify parent for engine cleanup
- const handleEndSession = useCallback(
- async (sessionId: string) => {
- await endSession(sessionId);
- onStopSession?.();
- },
- [endSession, onStopSession],
- );
- const switchToTab = useCallback((tab: 'lecture' | 'chat') => {
- setActiveTab(tab);
- }, []);
- useImperativeHandle(ref, () => ({
- createSession,
- endSession,
- endActiveSession,
- softPauseActiveSession,
- resumeActiveSession,
- sendMessage,
- startDiscussion,
- startLecture,
- addLectureMessage,
- getIsStreaming: () => isStreaming,
- getActiveSessionType: () => activeSessionType,
- getLectureMessageId,
- pauseBuffer,
- resumeBuffer,
- pauseActiveLiveBuffer,
- resumeActiveLiveBuffer,
- switchToTab,
- }));
- // Drag-to-resize
- const handleDragStart = useCallback(
- (e: React.MouseEvent) => {
- e.preventDefault();
- isDraggingRef.current = true;
- setIsDragging(true);
- const startX = e.clientX;
- const startWidth = width;
- const handleMouseMove = (me: MouseEvent) => {
- const delta = startX - me.clientX;
- const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta));
- onWidthChange?.(newWidth);
- };
- const handleMouseUp = () => {
- isDraggingRef.current = false;
- setIsDragging(false);
- document.removeEventListener('mousemove', handleMouseMove);
- document.removeEventListener('mouseup', handleMouseUp);
- document.body.style.cursor = '';
- document.body.style.userSelect = '';
- };
- document.body.style.cursor = 'col-resize';
- document.body.style.userSelect = 'none';
- document.addEventListener('mousemove', handleMouseMove);
- document.addEventListener('mouseup', handleMouseUp);
- },
- [width, onWidthChange],
- );
- const displayWidth = collapsed ? 0 : width;
- return (
- <div
- style={{
- width: displayWidth,
- transition: isDragging ? 'none' : 'width 0.3s ease',
- }}
- className={cn(
- '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',
- className,
- )}
- >
- {/* Drag handle */}
- {!collapsed && (
- <div
- onMouseDown={handleDragStart}
- 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"
- >
- <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" />
- </div>
- )}
- <div className={cn('flex flex-col w-full h-full overflow-hidden', collapsed && 'hidden')}>
- <Tabs
- value={activeTab}
- onValueChange={(v) => setActiveTab(v as 'lecture' | 'chat')}
- className="flex flex-col h-full gap-0"
- >
- {/* Tab header row */}
- <div className="h-10 flex items-center gap-1 shrink-0 mt-3 mb-1 px-3">
- <TabsList variant="line" className="h-full flex-1 w-0">
- <TabsTrigger value="lecture" className="text-xs gap-1 flex-1">
- <BookOpen className="w-3.5 h-3.5" />
- {t('chat.tabs.lecture')}
- </TabsTrigger>
- <TabsTrigger value="chat" className="text-xs gap-1 flex-1 relative">
- <MessageSquare className="w-3.5 h-3.5" />
- {t('chat.tabs.chat')}
- {/* Amber pulse dot when there's an active chat session and user is on Notes tab */}
- {hasActiveChatSession && activeTab === 'lecture' && (
- <span className="absolute -top-0.5 -right-0.5 flex h-2 w-2">
- <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
- <span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500" />
- </span>
- )}
- </TabsTrigger>
- </TabsList>
- {onCollapseChange && (
- <button
- onClick={() => onCollapseChange(true)}
- 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"
- >
- <PanelRightClose className="w-4 h-4" />
- </button>
- )}
- </div>
- {/* Notes Tab */}
- <TabsContent value="lecture" className="flex-1 overflow-hidden flex flex-col">
- <LectureNotesView notes={lectureNotes} currentSceneId={currentSceneId} />
- </TabsContent>
- {/* Chat Tab */}
- <TabsContent value="chat" className="flex-1 overflow-hidden flex flex-col">
- <div className="flex-1 overflow-y-auto overflow-x-hidden p-3 space-y-2 scrollbar-hide">
- {chatSessions.length === 0 ? (
- <div className="h-full flex flex-col items-center justify-center text-center p-6 opacity-50">
- <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">
- <MessageSquare className="w-6 h-6" />
- </div>
- <p className="text-xs font-medium text-gray-500 dark:text-gray-400">
- {t('chat.noConversations')}
- </p>
- <p className="text-[10px] text-gray-400 dark:text-gray-500 mt-1">
- {t('chat.startConversation')}
- </p>
- </div>
- ) : (
- <>
- <SessionList
- sessions={chatSessions}
- expandedSessionIds={expandedSessionIds}
- isStreaming={isStreaming}
- activeBubbleId={activeBubbleId}
- onToggleExpand={toggleSessionExpand}
- onEndSession={handleEndSession}
- />
- <div ref={bottomRef} />
- </>
- )}
- </div>
- </TabsContent>
- </Tabs>
- </div>
- </div>
- );
- },
- );
- ChatArea.displayName = 'ChatArea';
|