Files
SillyTavern_replica/frontend/src/components/Mid/ChatBox/ChatBox.jsx

556 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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;