import React, { useCallback, useEffect, useRef, useState } from 'react'; import type { ChatSheetProps, QuickAction } from '../../types'; import { WelcomePage } from '../WelcomePage'; import { MessageList } from '../MessageList'; import { InputBar } from '../InputBar'; import { QuickActionBar } from '../QuickActionBar'; import { useChatMessages } from '../../hooks/useChatMessages'; import { useChatSession } from '../../hooks/useChatSession'; import { useTouch } from '../../hooks/useTouch'; import { useVirtualKeyboard } from '../../hooks/useVirtualKeyboard'; import { sendChatRequest, consumeStream, createUserMessage, createAssistantMessage, } from '../../services/chatService'; import styles from './index.module.less'; type SheetMode = 'expanded' | 'collapsed'; export const ChatSheet: React.FC = ({ visible, onClose, apiConfig, title, hideNavbar = false, assistantName = '小亦', quickActions, initialMessages = [], messageActions, onEventResult, onEventChange, eventContent, sendOnOpen, onSendOnOpenConsumed, avatarUrl, onQuickActionClick, initialVoiceMode, collapsed: collapsedProp, onMessageDone, onParaSourceClick, className, }) => { const overlayRef = useRef(null); const sheetRef = useRef(null); const bodyRef = useRef(null); const { messages, addMessage, updateMessage, appendSuggestedQuestions } = useChatMessages(initialMessages); const { ensureSession } = useChatSession(); const { keyboardHeight } = useVirtualKeyboard(); const [sending, setSending] = useState(false); const abortRef = useRef(null); const currentAiMsgIdRef = useRef(null); const lastAutoSentRef = useRef(null); const [sheetMode, setSheetMode] = useState('expanded'); const [activeQuickActionId, setActiveQuickActionId] = useState(); const [activeSceneCode, setActiveSceneCode] = useState(); const [showQuickActionBar, setShowQuickActionBar] = useState(false); const { touch, start, move } = useTouch(); const [dragOffset, setDragOffset] = useState(0); const draggingRef = useRef(false); const dragOffsetRef = useRef(0); const mouseStartYRef = useRef(0); const DRAG_CLOSE_THRESHOLD = 120; useEffect(() => { if (visible) { setSheetMode('expanded'); } }, [visible]); useEffect(() => { if (collapsedProp && visible) { setSheetMode('collapsed'); } }, [collapsedProp, visible]); useEffect(() => { const el = sheetRef.current; if (!el) return; const preventOverscroll = (e: TouchEvent) => { if (draggingRef.current && e.cancelable) { e.preventDefault(); } }; el.addEventListener('touchmove', preventOverscroll, { passive: false }); return () => el.removeEventListener('touchmove', preventOverscroll); }, []); useEffect(() => { const onWindowMouseUp = (e: MouseEvent) => { if (!draggingRef.current) return; const offset = dragOffsetRef.current; draggingRef.current = false; dragOffsetRef.current = 0; setDragOffset(0); if (offset >= DRAG_CLOSE_THRESHOLD) { setSheetMode(prev => { if (prev === 'expanded') return 'collapsed'; onClose(); return prev; }); } else if (offset <= -DRAG_CLOSE_THRESHOLD && sheetMode === 'collapsed') { setSheetMode('expanded'); } }; window.addEventListener('mouseup', onWindowMouseUp); return () => window.removeEventListener('mouseup', onWindowMouseUp); }, [onClose, sheetMode]); const onTouchStart = useCallback( (e: React.TouchEvent) => { const handle = (e.target as HTMLElement).closest('[data-drag-handle]'); if (!handle) return; start(e); draggingRef.current = true; }, [start], ); const onTouchMove = useCallback( (e: React.TouchEvent) => { if (!draggingRef.current) return; if (e.cancelable) e.preventDefault(); move(e); const dy = touch.deltaY; if (dy > 0) { dragOffsetRef.current = dy; setDragOffset(dy); } else if (sheetMode === 'collapsed' && dy < 0) { dragOffsetRef.current = dy; setDragOffset(dy); } }, [move, touch, sheetMode], ); const onTouchEnd = useCallback(() => { if (!draggingRef.current) return; draggingRef.current = false; const offset = dragOffsetRef.current; dragOffsetRef.current = 0; setDragOffset(0); if (offset >= DRAG_CLOSE_THRESHOLD) { setSheetMode(prev => { if (prev === 'expanded') return 'collapsed'; onClose(); return prev; }); } else if (offset <= -DRAG_CLOSE_THRESHOLD && sheetMode === 'collapsed') { setSheetMode('expanded'); } }, [onClose, sheetMode]); const onMouseDown = useCallback((e: React.MouseEvent) => { const handle = (e.target as HTMLElement).closest('[data-drag-handle]'); if (!handle) return; e.preventDefault(); draggingRef.current = true; mouseStartYRef.current = e.clientY; dragOffsetRef.current = 0; setDragOffset(0); }, []); const onMouseMove = useCallback((e: React.MouseEvent) => { if (!draggingRef.current) return; e.preventDefault(); const dy = e.clientY - mouseStartYRef.current; if (dy > 0) { dragOffsetRef.current = dy; setDragOffset(dy); } else if (sheetMode === 'collapsed' && dy < 0) { dragOffsetRef.current = dy; setDragOffset(dy); } }, [sheetMode]); const onMouseUp = useCallback((e: React.MouseEvent) => { if (!draggingRef.current) return; draggingRef.current = false; const offset = dragOffsetRef.current; dragOffsetRef.current = 0; setDragOffset(0); if (offset >= DRAG_CLOSE_THRESHOLD) { setSheetMode(prev => { if (prev === 'expanded') return 'collapsed'; onClose(); return prev; }); } else if (offset <= -DRAG_CLOSE_THRESHOLD && sheetMode === 'collapsed') { setSheetMode('expanded'); } }, [onClose, sheetMode]); const onMouseLeave = useCallback((e: React.MouseEvent) => { if (!draggingRef.current || e.buttons !== 0) return; const offset = dragOffsetRef.current; draggingRef.current = false; dragOffsetRef.current = 0; setDragOffset(0); if (offset >= DRAG_CLOSE_THRESHOLD) { setSheetMode(prev => { if (prev === 'expanded') return 'collapsed'; onClose(); return prev; }); } else if (offset <= -DRAG_CLOSE_THRESHOLD && sheetMode === 'collapsed') { setSheetMode('expanded'); } }, [onClose, sheetMode]); const handleExpandFromHistory = useCallback(() => { setSheetMode('expanded'); }, []); const showQuickActionPrompts = useCallback( (action: QuickAction) => { const responseText = action.responseText ?? `您好,我可以帮你查询本活动相关的${action.title}。`; const questions = action.questions ?? [ '关于活动的问题', '关于活动的问题', '关于活动的问题', ]; const aiMsg = createAssistantMessage(responseText); aiMsg.status = 'success'; aiMsg.hideActions = true; aiMsg.suggestedQuestions = questions.map((q, i) => ({ id: `qa-${action.id}-${Date.now()}-${i}`, text: q, })); addMessage(aiMsg); }, [addMessage], ); const handleCardClick = useCallback( (action: QuickAction) => { setActiveQuickActionId(action.id); if (action.sceneCode) setActiveSceneCode(action.sceneCode); setShowQuickActionBar(true); onQuickActionClick?.(action); showQuickActionPrompts(action); }, [onQuickActionClick, showQuickActionPrompts], ); const handleQuickActionPillClick = useCallback( (action: QuickAction) => { setActiveQuickActionId(action.id); if (action.sceneCode) setActiveSceneCode(action.sceneCode); showQuickActionPrompts(action); }, [showQuickActionPrompts], ); const handleStop = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; if (currentAiMsgIdRef.current) { updateMessage(currentAiMsgIdRef.current, prev => ({ content: prev.content || '', status: 'stopped' as const, })); currentAiMsgIdRef.current = null; } setSending(false); }, [updateMessage]); const handleSend = useCallback( async (text: string) => { if (!apiConfig || sending) return; setShowQuickActionBar(true); abortRef.current?.abort(); abortRef.current = new AbortController(); const userMsg = createUserMessage(text); addMessage(userMsg); const aiMsg = createAssistantMessage(); addMessage(aiMsg); currentAiMsgIdRef.current = aiMsg.id; setSending(true); const mergedConfig = activeSceneCode ? { ...apiConfig, headers: { ...(apiConfig.headers ?? {}), sceneCode: activeSceneCode, }, } : apiConfig; try { const sid = await ensureSession(); const response = await sendChatRequest( mergedConfig, text, sid || undefined, abortRef.current.signal, ); await consumeStream(response, { onChunk: fullText => { updateMessage(aiMsg.id, { content: fullText, status: 'loading' }); }, onDone: fullText => { currentAiMsgIdRef.current = null; updateMessage(aiMsg.id, { content: fullText, status: 'success' }); setSending(false); onMessageDone?.(aiMsg.id); }, onError: err => { if (err.name !== 'AbortError') { currentAiMsgIdRef.current = null; updateMessage(aiMsg.id, { content: '抱歉,请求失败,请重试。', status: 'error', }); setSending(false); onMessageDone?.(aiMsg.id); } }, onSuggestedQuestions: questions => { appendSuggestedQuestions(aiMsg.id, questions); }, onAction: actions => { onEventChange?.(actions, aiMsg.id); }, onPara: para => { updateMessage(aiMsg.id, { para }); }, }); } catch (err: unknown) { const e = err as Error; if (e.name !== 'AbortError') { currentAiMsgIdRef.current = null; updateMessage(aiMsg.id, { content: '网络异常,请重试。', status: 'error' }); setSending(false); onMessageDone?.(aiMsg.id); } } if (onEventResult) { onEventResult(null, aiMsg.id); } }, [ apiConfig, activeSceneCode, sending, addMessage, updateMessage, appendSuggestedQuestions, ensureSession, onEventResult, onEventChange, onMessageDone, ], ); const handleSuggestionClick = useCallback( (text: string) => { void handleSend(text); }, [handleSend], ); useEffect(() => { if (!visible) { lastAutoSentRef.current = null; return; } const t = sendOnOpen?.trim(); if (!t) return; if (lastAutoSentRef.current === t) return; lastAutoSentRef.current = t; onSendOnOpenConsumed?.(); void handleSend(t); }, [visible, sendOnOpen, handleSend, onSendOnOpenConsumed]); // 滚动到底部 useEffect(() => { const el = bodyRef.current; if (!el) return; requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }); }, [messages.length, messages[messages.length - 1]?.content]); const isCollapsed = sheetMode === 'collapsed'; return (
0 ? dragOffset : 0}px)` : 'translateY(100%)', ...(isCollapsed && dragOffset < 0 ? { maxHeight: `${665 + Math.abs(dragOffset)}px` } : {}), transition: draggingRef.current ? 'none' : 'transform 0.3s ease, max-height 0.3s ease', }} onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} onMouseDown={onMouseDown} onMouseMove={onMouseMove} onMouseUp={onMouseUp} onMouseLeave={onMouseLeave} > {/* 拖拽把手 + 历史问答胶囊 */}
{isCollapsed && (
)} {isCollapsed && ( )}
{/* 导航栏:仅展开态显示,可由 hideNavbar 关闭 */} {!isCollapsed && !hideNavbar && (
{title && {title}}
)} {/* 隐藏导航栏时的右上角关闭按钮 */} {!isCollapsed && hideNavbar && ( )} {/* 内容区 */}
{!isCollapsed && ( )} {messages.length > 0 && ( )}
{/* 快捷按钮行:卡片被点击过或用户发送了问题后才显示 */} {showQuickActionBar && quickActions && quickActions.length > 0 && ( )}
); }; const BackIcon = () => ( ); const CloseIcon = () => ( );