556 lines
18 KiB
JavaScript
556 lines
18 KiB
JavaScript
// frontend-react/src/components/ChatBox/ChatBox.jsx
|
||
import React, { useEffect, useRef } from 'react';
|
||
import useChatBoxStore from '../../../Store/Mid/ChatBoxSlice';
|
||
import useChatBoxUIStore from '../../../Store/Mid/ChatBoxUISlice'; // ✅ 新增
|
||
import MarkdownRenderer from '../../shared/MarkdownRenderer';
|
||
import './ChatBox.css';
|
||
|
||
const ChatBox = () => {
|
||
const messagesEndRef = useRef(null);
|
||
const optionsRef = useRef(null);
|
||
const chatSelectorRef = useRef(null);
|
||
|
||
// ✅ 从 ChatBoxUIStore 获取 UI 状态
|
||
const {
|
||
editingId,
|
||
editContent,
|
||
inputValue,
|
||
showOptions,
|
||
showChatSelector,
|
||
characterChats,
|
||
currentSwipeId,
|
||
inputHeight,
|
||
startEditing,
|
||
cancelEditing,
|
||
updateEditContent,
|
||
setInputValue,
|
||
clearInput,
|
||
toggleOptions,
|
||
setShowOptions,
|
||
toggleChatSelector,
|
||
setShowChatSelector,
|
||
setCharacterChats,
|
||
setCurrentSwipeId,
|
||
setInputHeight
|
||
} = useChatBoxUIStore();
|
||
|
||
// 从 ChatBoxStore 获取状态和方法
|
||
const {
|
||
messages,
|
||
isLoading,
|
||
error,
|
||
userName,
|
||
characterName,
|
||
updateMessage,
|
||
isGenerating,
|
||
sendMessage,
|
||
stopGeneration,
|
||
options,
|
||
toggleOption,
|
||
cycleRenderMode // ✅ 新增:切换渲染模式
|
||
} = useChatBoxStore();
|
||
|
||
// 点击外部关闭选项面板
|
||
useEffect(() => {
|
||
const handleClickOutside = (event) => {
|
||
if (optionsRef.current && !optionsRef.current.contains(event.target) &&
|
||
!event.target.closest('.options-button')) {
|
||
setShowOptions(false);
|
||
}
|
||
};
|
||
|
||
if (showOptions) {
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener('mousedown', handleClickOutside);
|
||
};
|
||
}, [showOptions]);
|
||
|
||
// 点击外部关闭聊天选择器
|
||
useEffect(() => {
|
||
const handleClickOutside = (event) => {
|
||
if (chatSelectorRef.current && !chatSelectorRef.current.contains(event.target) &&
|
||
!event.target.closest('.chat-selector-button')) {
|
||
setShowChatSelector(false);
|
||
}
|
||
};
|
||
|
||
if (showChatSelector) {
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener('mousedown', handleClickOutside);
|
||
};
|
||
}, [showChatSelector]);
|
||
|
||
const handleInputHeight = (e) => {
|
||
const textarea = e.target;
|
||
|
||
// 标准方案:先重置为 auto,再设置为 scrollHeight
|
||
// 这是业界公认的最佳实践,确保高度计算准确
|
||
textarea.style.height = 'auto';
|
||
textarea.style.height = textarea.scrollHeight + 'px';
|
||
};
|
||
|
||
// 处理发送或终止
|
||
const handleSendOrStop = () => {
|
||
if (isGenerating) {
|
||
stopGeneration();
|
||
} else {
|
||
sendMessage(inputValue);
|
||
clearInput(); // ✅ 使用 store 方法
|
||
setInputHeight(42); // ✅ 重置为一行高度
|
||
}
|
||
};
|
||
|
||
// 处理键盘事件
|
||
const handleKeyDown = (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSendOrStop();
|
||
}
|
||
};
|
||
|
||
// 自动滚动到底部
|
||
const scrollToBottom = () => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
};
|
||
|
||
useEffect(() => {
|
||
scrollToBottom();
|
||
}, [messages]);
|
||
|
||
// 处理编辑消息
|
||
const handleEdit = (message) => {
|
||
startEditing(message.floor, message.mes); // ✅ 使用 store 方法
|
||
};
|
||
|
||
// 保存编辑
|
||
const handleSaveEdit = (messageId) => {
|
||
// 调用 store 中的 updateMessage 方法
|
||
updateMessage(messageId, editContent);
|
||
cancelEditing(); // ✅ 使用 store 方法
|
||
};
|
||
|
||
// 取消编辑
|
||
const handleCancelEdit = () => {
|
||
cancelEditing(); // ✅ 使用 store 方法
|
||
};
|
||
|
||
// 新增:处理swipe切换
|
||
const handleSwipeChange = (messageId, direction) => {
|
||
const message = messages.find(m => m.floor === messageId);
|
||
if (message && message.swipes && message.swipes.length > 0) {
|
||
const currentIndex = currentSwipeId[messageId] !== undefined
|
||
? currentSwipeId[messageId]
|
||
: message.swipe_id;
|
||
const newIndex = currentIndex + direction;
|
||
|
||
if (newIndex >= 0 && newIndex < message.swipes.length) {
|
||
setCurrentSwipeId({ [messageId]: newIndex }); // ✅ 使用 store 方法
|
||
}
|
||
}
|
||
};
|
||
|
||
// 切换选项显示
|
||
const toggleOptionsPanel = () => {
|
||
toggleOptions(); // ✅ 使用 store 方法
|
||
};
|
||
|
||
// 打开聊天选择器
|
||
const handleOpenChatSelector = async () => {
|
||
const { currentRole } = useChatBoxStore.getState();
|
||
|
||
// 如果没有当前角色,提示用户
|
||
if (!currentRole) {
|
||
alert('请先在左侧选择一个角色');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 获取当前角色的聊天列表
|
||
const response = await fetch(`/api/chat/${encodeURIComponent(currentRole)}`);
|
||
if (!response.ok) throw new Error('获取聊天列表失败');
|
||
|
||
const chats = await response.json();
|
||
setCharacterChats(chats); // ✅ 使用 store 方法
|
||
|
||
// 显示聊天选择器弹窗,让用户手动选择
|
||
toggleChatSelector(); // ✅ 使用 store 方法
|
||
} catch (error) {
|
||
console.error('获取聊天列表失败:', error);
|
||
alert('获取聊天列表失败: ' + error.message);
|
||
}
|
||
};
|
||
|
||
// 选择聊天并加载
|
||
const handleSelectChat = async (chatName) => {
|
||
try {
|
||
const { setChatBoxRoleAndChat, currentRole } = useChatBoxStore.getState();
|
||
|
||
// 设置当前角色和聊天,这会触发 ChatBoxStore 的监听器自动加载聊天历史
|
||
setChatBoxRoleAndChat(currentRole, chatName);
|
||
|
||
// 关闭选择器
|
||
setShowChatSelector(false); // ✅ 使用 store 方法
|
||
|
||
console.log(`已切换到聊天: ${chatName}`);
|
||
} catch (error) {
|
||
console.error('切换聊天失败:', error);
|
||
alert('切换聊天失败: ' + error.message);
|
||
}
|
||
};
|
||
|
||
// 新建聊天
|
||
const handleCreateNewChat = async () => {
|
||
try {
|
||
const { currentRole, createChat, setChatBoxRoleAndChat } = useChatBoxStore.getState();
|
||
|
||
if (!currentRole) {
|
||
alert('请先选择一个角色');
|
||
return;
|
||
}
|
||
|
||
// 使用当前时间戳作为聊天名称
|
||
const chatName = `chat_${Date.now()}`;
|
||
|
||
// 创建新聊天
|
||
await createChat(currentRole, chatName);
|
||
|
||
// 切换到新创建的聊天
|
||
setChatBoxRoleAndChat(currentRole, chatName);
|
||
|
||
// 关闭选择器
|
||
setShowChatSelector(false);
|
||
|
||
console.log(`已创建并切换到新聊天: ${chatName}`);
|
||
} catch (error) {
|
||
console.error('创建聊天失败:', error);
|
||
alert('创建聊天失败: ' + error.message);
|
||
}
|
||
};
|
||
|
||
// 渲染单条消息
|
||
const renderMessage = (message, index) => {
|
||
const isUser = message.is_user;
|
||
const isEditing = editingId === message.floor;
|
||
|
||
// 判断是否为最新消息
|
||
const isLatestMessage = messages.length > 0 && message.floor === messages[messages.length - 1].floor;
|
||
|
||
// 根据消息类型设置显示名称
|
||
const displayName = isUser ? userName : characterName;
|
||
|
||
// 确定当前显示的消息内容
|
||
let currentMes = message.mes;
|
||
let hasSwipes = message.swipes && message.swipes.length > 0;
|
||
let currentSwipeIndex = message.swipe_id;
|
||
|
||
if (hasSwipes) {
|
||
// 如果有swipes数组
|
||
if (currentSwipeId[message.floor] !== undefined) {
|
||
// 如果用户已经切换过版本,使用用户选择的版本
|
||
currentSwipeIndex = currentSwipeId[message.floor];
|
||
} else {
|
||
// 否则使用默认的swipe_id
|
||
currentSwipeIndex = message.swipe_id;
|
||
}
|
||
|
||
if (currentSwipeIndex >= 0 && currentSwipeIndex < message.swipes.length) {
|
||
currentMes = message.swipes[currentSwipeIndex];
|
||
}
|
||
}
|
||
|
||
// ✅ 使用 id 或组合 key 确保唯一性
|
||
const uniqueKey = message.id || `${message.floor}-${index}`;
|
||
|
||
return (
|
||
<div key={uniqueKey} className={`message ${isUser ? 'user' : 'ai'}`}>
|
||
<div className="message-container">
|
||
<div className="message-header">
|
||
<span className="message-name">{displayName}</span>
|
||
<span className="message-id">#{message.floor}</span>
|
||
<div className="message-toolbar">
|
||
<div className="toolbar-buttons">
|
||
<button
|
||
className="toolbar-button"
|
||
onClick={() => handleEdit(message)}
|
||
title="编辑"
|
||
>
|
||
✎
|
||
</button>
|
||
<button
|
||
className="toolbar-button"
|
||
title="更多"
|
||
>
|
||
•••
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="message-content">
|
||
{isEditing ? (
|
||
<div className="edit-container">
|
||
<textarea
|
||
className="edit-textarea"
|
||
value={editContent}
|
||
onChange={(e) => setEditContent(e.target.value)}
|
||
/>
|
||
<div className="edit-buttons">
|
||
<button
|
||
className="cancel-button"
|
||
onClick={handleCancelEdit}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
className="save-button"
|
||
onClick={() => handleSaveEdit(message.floor)}
|
||
>
|
||
保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="bubble">
|
||
{options.renderMode === 'html' && !isUser ? (
|
||
// ✅ 智能 HTML 渲染:检测是否为纯文本,自动转换换行符
|
||
<div dangerouslySetInnerHTML={{
|
||
__html: (() => {
|
||
// 检测是否包含 HTML 标签
|
||
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(currentMes);
|
||
if (hasHtmlTags) {
|
||
// 如果已有 HTML 标签,直接返回
|
||
return currentMes;
|
||
}
|
||
// 如果是纯文本,将换行符转换为 <br>
|
||
return currentMes
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/\n/g, '<br>');
|
||
})()
|
||
}} />
|
||
) : options.renderMode === 'markdown' ? (
|
||
<MarkdownRenderer content={currentMes} />
|
||
) : (
|
||
<div className="plain-text">{currentMes}</div>
|
||
)}
|
||
{hasSwipes && !isUser && isLatestMessage && (
|
||
<div className="swipe-controls">
|
||
<button
|
||
className="swipe-button"
|
||
onClick={() => handleSwipeChange(message.floor, -1)}
|
||
disabled={currentSwipeIndex === 0}
|
||
>
|
||
◀
|
||
</button>
|
||
<span className="swipe-counter">
|
||
{currentSwipeIndex + 1}/{message.swipes.length}
|
||
</span>
|
||
<button
|
||
className="swipe-button"
|
||
onClick={() => handleSwipeChange(message.floor, 1)}
|
||
disabled={currentSwipeIndex === message.swipes.length - 1}
|
||
>
|
||
▶
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="chat-box">
|
||
<div className="chat-messages">
|
||
{isLoading ? (
|
||
<div className="loading">加载中...</div>
|
||
) : error ? (
|
||
<div className="error">{error}</div>
|
||
) : messages.length === 0 ? (
|
||
<div className="loading">暂无消息</div>
|
||
) : (
|
||
messages.map(renderMessage)
|
||
)}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
<div className="chat-input-wrapper">
|
||
<div className="input-container">
|
||
<div className="options-wrapper" ref={optionsRef}>
|
||
<button
|
||
className={`options-toggle ${showOptions ? 'active' : ''}`}
|
||
title="Toggle Options"
|
||
onClick={toggleOptions} // ✅ 使用 store 方法
|
||
>
|
||
{showOptions ? '×' : '≡'}
|
||
</button>
|
||
{showOptions && (
|
||
<div className="chat-options">
|
||
{/* 渲染模式切换按钮 */}
|
||
<div className="option-group-title">显示</div>
|
||
<button
|
||
className="render-mode-btn"
|
||
onClick={() => cycleRenderMode()}
|
||
title={`当前: ${options.renderMode === 'none' ? '纯文本' : options.renderMode === 'html' ? 'HTML' : 'Markdown'},点击切换`}
|
||
>
|
||
{options.renderMode === 'none' ? '📄 纯文本' :
|
||
options.renderMode === 'html' ? '🌐 HTML' :
|
||
'📝 Markdown'}
|
||
</button>
|
||
|
||
<div className="option-group-title">功能</div>
|
||
<label className="option-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={options.streamOutput}
|
||
onChange={() => toggleOption('streamOutput')}
|
||
/>
|
||
<span className="checkmark"></span>
|
||
<span className="option-label">流式输出</span>
|
||
</label>
|
||
<label className="option-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={options.dynamicTable}
|
||
onChange={() => toggleOption('dynamicTable')}
|
||
/>
|
||
<span className="checkmark"></span>
|
||
<span className="option-label">动态表格</span>
|
||
</label>
|
||
|
||
{/* 自动掷骰子替换 */}
|
||
<label className="option-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={options.autoDiceRoll}
|
||
onChange={() => toggleOption('autoDiceRoll')}
|
||
/>
|
||
<span className="checkmark"></span>
|
||
<span className="option-label">🎲 自动掷骰子</span>
|
||
</label>
|
||
|
||
{/* 生图工作流选项 */}
|
||
<label className="option-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={options.imageWorkflow}
|
||
onChange={() => toggleOption('imageWorkflow')}
|
||
/>
|
||
<span className="checkmark"></span>
|
||
<span className="option-label">🎨 生图工作流</span>
|
||
</label>
|
||
|
||
{/* 分隔线 - 区分多选框和按钮区域 */}
|
||
<div className="option-divider"></div>
|
||
|
||
{/* 切换聊天按钮 */}
|
||
<button
|
||
className="switch-chat-btn"
|
||
onClick={handleOpenChatSelector}
|
||
title="切换到当前角色的最后聊天记录"
|
||
>
|
||
💬 切换聊天
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="chat-input-area">
|
||
<textarea
|
||
className="message-input"
|
||
value={inputValue}
|
||
onChange={(e) => {
|
||
setInputValue(e.target.value);
|
||
handleInputHeight(e);
|
||
}}
|
||
onKeyDown={handleKeyDown}
|
||
style={{
|
||
height: `${inputHeight}px`,
|
||
overflowY: inputHeight >= 300 ? 'auto' : 'hidden'
|
||
}}
|
||
placeholder="Type your message..."
|
||
/>
|
||
</div>
|
||
<button
|
||
className={`send-button ${isGenerating ? 'stopping' : ''}`}
|
||
onClick={handleSendOrStop}
|
||
disabled={!inputValue.trim() && !isGenerating}
|
||
title="Send Message"
|
||
>
|
||
{isGenerating ? '■' : '>'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 聊天选择器弹窗 */}
|
||
{showChatSelector && (
|
||
<div className="chat-selector-overlay" onClick={() => setShowChatSelector(false)}>
|
||
<div
|
||
className="chat-selector-modal"
|
||
ref={chatSelectorRef}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="chat-selector-header">
|
||
<h3>{characterName || '未选择角色'} - 选择聊天</h3>
|
||
<button
|
||
className="close-btn"
|
||
onClick={() => setShowChatSelector(false)}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className="chat-selector-body">
|
||
{/* 新建聊天按钮 */}
|
||
<div className="chat-selector-actions">
|
||
<button
|
||
className="new-chat-btn"
|
||
onClick={handleCreateNewChat}
|
||
>
|
||
+ 新建聊天
|
||
</button>
|
||
</div>
|
||
|
||
{characterChats.length > 0 ? (
|
||
<div className="chat-list">
|
||
{characterChats.map((chat) => (
|
||
<div
|
||
key={chat.chat_name}
|
||
className="chat-option"
|
||
>
|
||
<div
|
||
className="chat-option-content"
|
||
onClick={() => handleSelectChat(chat.chat_name)}
|
||
>
|
||
<div className="chat-option-name">{chat.chat_name}</div>
|
||
{chat.last_message && (
|
||
<div className="chat-option-preview">
|
||
{chat.last_message.substring(0, 100)}{chat.last_message.length > 100 ? '...' : ''}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="empty-chats">
|
||
<p>暂无聊天记录</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ChatBox;
|