swipe、右键菜单
This commit is contained in:
@@ -128,7 +128,7 @@ const useChatBoxStore = create(
|
||||
},
|
||||
|
||||
// 发送消息
|
||||
sendMessage: async (content) => {
|
||||
sendMessage: async (content, targetFloor = null) => {
|
||||
const { messages, userName, characterName, currentRole, currentChat, options, wsConnection } = get();
|
||||
|
||||
// ✅ 如果启用了自动掷骰子,处理内容
|
||||
@@ -194,15 +194,22 @@ const useChatBoxStore = create(
|
||||
wsConnection.close();
|
||||
}
|
||||
|
||||
// 计算下一个楼层号
|
||||
const nextFloor = get().getNextFloor(messages);
|
||||
|
||||
// ✅ 判断是重roll还是新消息(现在重roll也创建新消息)
|
||||
const isReroll = targetFloor !== null;
|
||||
|
||||
let userFloor, assistantFloor, nextFloor;
|
||||
|
||||
// ✅ 重roll模式也创建新消息,而不是更新现有消息
|
||||
nextFloor = get().getNextFloor(messages);
|
||||
userFloor = nextFloor;
|
||||
assistantFloor = nextFloor + 1;
|
||||
|
||||
set({
|
||||
isGenerating: true,
|
||||
wsConnection: null, // 重置连接
|
||||
messages: [...messages, {
|
||||
id: Date.now(),
|
||||
floor: nextFloor,
|
||||
id: `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, // ✅ 使用唯一ID
|
||||
floor: userFloor,
|
||||
mes: processedContent, // 使用处理后的内容
|
||||
is_user: true,
|
||||
name: userName || 'User',
|
||||
@@ -235,9 +242,11 @@ const useChatBoxStore = create(
|
||||
// 保存WebSocket连接到store
|
||||
set({ wsConnection: ws });
|
||||
|
||||
// 添加一个空的助手消息,稍后会更新
|
||||
const newMessageId = Date.now();
|
||||
const assistantFloor = nextFloor + 1; // 助手消息的楼层是用户消息楼层+1
|
||||
// ✅ 根据模式处理 AI 消息
|
||||
let newMessageId;
|
||||
|
||||
// ✅ 正常模式:添加一个空的助手消息,稍后会更新
|
||||
newMessageId = `ai_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // ✅ 使用唯一ID
|
||||
set((state) => ({
|
||||
messages: [...state.messages, {
|
||||
id: newMessageId,
|
||||
@@ -246,7 +255,8 @@ const useChatBoxStore = create(
|
||||
is_user: false,
|
||||
name: characterName || 'Assistant',
|
||||
sendDate: new Date().toISOString()
|
||||
}]
|
||||
}],
|
||||
isGenerating: true
|
||||
}));
|
||||
|
||||
let assistantMessage = '';
|
||||
@@ -284,6 +294,8 @@ const useChatBoxStore = create(
|
||||
|
||||
// 处理流式数据块
|
||||
assistantMessage += data.content;
|
||||
|
||||
// ✅ 更新新创建的消息
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === newMessageId ? { ...msg, mes: assistantMessage } : msg
|
||||
@@ -345,6 +357,7 @@ const useChatBoxStore = create(
|
||||
console.log('\n[WebSocket] ✅ 收到完成信号');
|
||||
console.log(' - 总 Chunks:', chunkCount);
|
||||
console.log(' - 消息长度:', assistantMessage.length);
|
||||
|
||||
// 完成响应
|
||||
isStreamComplete = true;
|
||||
ws.close();
|
||||
@@ -382,7 +395,10 @@ const useChatBoxStore = create(
|
||||
|
||||
// 发送请求到WebSocket(确保连接已建立)
|
||||
const sendAfterConnect = () => {
|
||||
console.log('[WebSocket] 📤 sendAfterConnect 被调用, readyState:', ws.readyState);
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
console.log('[WebSocket] ✅ 连接已打开,准备发送消息');
|
||||
// ✅ 获取 API 配置
|
||||
const apiConfigData = {
|
||||
api_url: apiConfigStore.currentProfile?.apis?.mainLLM?.apiUrl || '',
|
||||
@@ -393,6 +409,7 @@ const useChatBoxStore = create(
|
||||
console.log('\n' + '-'.repeat(80));
|
||||
console.log('[WebSocket] 📤 发送消息:');
|
||||
console.log(' - Floor:', nextFloor);
|
||||
console.log(' - Mode:', isReroll ? '🔄 Reroll (新消息)' : '➕ New Message');
|
||||
console.log(' - Role:', currentRole);
|
||||
console.log(' - Chat:', actualChat);
|
||||
console.log(' - Stream:', options.streamOutput);
|
||||
@@ -401,8 +418,9 @@ const useChatBoxStore = create(
|
||||
console.log(' - Current Profile:', apiConfigStore.currentProfile);
|
||||
console.log('-'.repeat(80) + '\n');
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
floor: nextFloor,
|
||||
// ✅ 实际发送消息
|
||||
const messageData = JSON.stringify({
|
||||
floor: nextFloor, // ✅ 总是使用 nextFloor,因为重roll也创建新消息
|
||||
mes: processedContent, // 使用处理后的内容
|
||||
is_user: true,
|
||||
currentRole: currentRole,
|
||||
@@ -452,7 +470,11 @@ const useChatBoxStore = create(
|
||||
// ✅ 时间戳(用于冲突解决)
|
||||
timestamp: Date.now(),
|
||||
stream: options.streamOutput
|
||||
}));
|
||||
});
|
||||
|
||||
console.log('[WebSocket] 📤 正在发送消息,数据长度:', messageData.length);
|
||||
ws.send(messageData);
|
||||
console.log('[WebSocket] ✅ 消息已发送');
|
||||
} else if (ws.readyState === WebSocket.CONNECTING) {
|
||||
// 如果正在连接,继续等待
|
||||
console.log('[WebSocket] 等待连接...', { readyState: ws.readyState });
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
|
||||
.message-content {
|
||||
position: relative;
|
||||
min-height: calc(1lh + var(--spacing-xs) * 2); /* ✅ 确保编辑时保持高度 */
|
||||
}
|
||||
|
||||
.bubble {
|
||||
@@ -171,22 +172,24 @@
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
min-height: calc(1lh + var(--spacing-xs) * 2); /* ✅ 至少保持一行文本的高度 */
|
||||
}
|
||||
|
||||
.edit-textarea {
|
||||
width: 100%;
|
||||
min-height: auto; /* 自适应内容高度 */
|
||||
min-height: calc(1lh + var(--spacing-xs) * 2); /* ✅ 与 bubble 的 padding 保持一致 */
|
||||
max-height: none; /* 不限制最大高度 */
|
||||
padding: var(--spacing-xs) 0; /* 与 bubble 保持一致 */
|
||||
border: none; /* 去掉边框 */
|
||||
border-radius: 0;
|
||||
border: 2px solid var(--color-accent); /* ✅ 添加边框提示编辑状态 */
|
||||
border-radius: var(--radius-sm);
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
background-color: transparent; /* 透明背景 */
|
||||
background-color: rgba(102, 126, 234, 0.05); /* ✅ 轻微背景色区分编辑状态 */
|
||||
color: var(--color-text-primary);
|
||||
outline: none;
|
||||
box-sizing: border-box; /* ✅ 确保宽度计算正确 */
|
||||
}
|
||||
|
||||
.edit-textarea:focus {
|
||||
@@ -234,33 +237,65 @@
|
||||
.swipe-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-md);
|
||||
justify-content: space-between; /* ✅ 左右分布 */
|
||||
padding: 0 var(--spacing-xs); /* ✅ 减少内边距 */
|
||||
width: 100%; /* ✅ 占满宽度 */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.swipe-button {
|
||||
background: none;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.15); /* ✅ 半透明背景 */
|
||||
backdrop-filter: blur(8px); /* ✅ 毛玻璃效果 */
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.8rem;
|
||||
transition: background-color 0.2s;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
min-width: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7; /* ✅ 默认半透明 */
|
||||
}
|
||||
|
||||
/* 深色主题下的 swipe 按钮 */
|
||||
[data-color-theme='dark'] .swipe-button {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.swipe-button:hover:not(:disabled) {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
background-color: rgba(102, 126, 234, 0.2); /* ✅ hover 时更明显 */
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
opacity: 1; /* ✅ hover 时完全不透明 */
|
||||
}
|
||||
|
||||
.swipe-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.swipe-button:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.swipe-counter {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
padding: 2px 8px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ==================== Input Area - Fixed at Bottom ==================== */
|
||||
@@ -721,3 +756,5 @@
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ✅ 右键菜单样式已移至全局 context-menu.css */
|
||||
|
||||
@@ -9,6 +9,17 @@ const ChatBox = () => {
|
||||
const messagesEndRef = useRef(null);
|
||||
const optionsRef = useRef(null);
|
||||
const chatSelectorRef = useRef(null);
|
||||
const messagesContainerRef = useRef(null); // ✅ 新增:消息容器引用
|
||||
const isUserAtBottomRef = useRef(true); // ✅ 新增:跟踪用户是否在底部
|
||||
const contextMenuRef = useRef(null); // ✅ 新增:右键菜单引用
|
||||
|
||||
// ✅ 新增:右键菜单状态
|
||||
const [contextMenu, setContextMenu] = React.useState({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
message: null
|
||||
});
|
||||
|
||||
// ✅ 从 ChatBoxUIStore 获取 UI 状态
|
||||
const {
|
||||
@@ -86,6 +97,26 @@ const ChatBox = () => {
|
||||
};
|
||||
}, [showChatSelector]);
|
||||
|
||||
// ✅ 点击外部关闭右键菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(event.target)) {
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
if (contextMenu.visible) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
// ✅ 添加滚动监听,防止滚动时菜单不关闭
|
||||
document.addEventListener('scroll', closeContextMenu, true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('scroll', closeContextMenu, true);
|
||||
};
|
||||
}, [contextMenu.visible]);
|
||||
|
||||
const handleInputHeight = (e) => {
|
||||
const textarea = e.target;
|
||||
|
||||
@@ -114,15 +145,105 @@ const ChatBox = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 自动滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
// 自动滚动到底部(智能滚动)
|
||||
const scrollToBottom = (force = false) => {
|
||||
// 只有在用户在底部或强制滚动时才执行
|
||||
if (isUserAtBottomRef.current || force) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
// 检测用户是否在底部
|
||||
const checkIfUserAtBottom = () => {
|
||||
if (!messagesContainerRef.current) return true;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current;
|
||||
// 允许 20px 的误差范围
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 20;
|
||||
isUserAtBottomRef.current = isAtBottom;
|
||||
return isAtBottom;
|
||||
};
|
||||
|
||||
// 监听消息容器的滚动事件
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
checkIfUserAtBottom();
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// 当消息变化时,智能滚动
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
// ✅ 监听全局键盘事件 - 左右键切换 swipe 或触发重roll
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (e) => {
|
||||
// 如果正在输入框中输入,不处理
|
||||
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 只处理左右方向键
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// 找到最后一条 AI 消息
|
||||
const lastAiMessage = [...messages].reverse().find(m => !m.is_user);
|
||||
|
||||
if (!lastAiMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSwipes = lastAiMessage.swipes && lastAiMessage.swipes.length > 0;
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
// 左键:如果有 swipe,切换到上一个版本;否则不做任何操作
|
||||
if (hasSwipes) {
|
||||
const currentIndex = currentSwipeId[lastAiMessage.floor] !== undefined
|
||||
? currentSwipeId[lastAiMessage.floor]
|
||||
: lastAiMessage.swipe_id;
|
||||
|
||||
if (currentIndex > 0) {
|
||||
handleSwipeChange(lastAiMessage.floor, -1);
|
||||
}
|
||||
}
|
||||
// 如果没有 swipe,左键不做任何操作
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
// 右键:如果有 swipe 且不是最后一个版本,切换到下一个版本;否则触发重roll
|
||||
if (hasSwipes) {
|
||||
const currentIndex = currentSwipeId[lastAiMessage.floor] !== undefined
|
||||
? currentSwipeId[lastAiMessage.floor]
|
||||
: lastAiMessage.swipe_id;
|
||||
|
||||
if (currentIndex < lastAiMessage.swipes.length - 1) {
|
||||
handleSwipeChange(lastAiMessage.floor, 1);
|
||||
} else {
|
||||
// 已在最后一个版本,触发重roll
|
||||
console.log('[ChatBox] 已在最后一个版本,触发重roll(发送新消息)');
|
||||
handleRerollMessage(lastAiMessage);
|
||||
}
|
||||
} else {
|
||||
// 没有 swipe,直接触发重roll
|
||||
console.log('[ChatBox] 没有 swipe,触发重roll(发送新消息)');
|
||||
handleRerollMessage(lastAiMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleGlobalKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleGlobalKeyDown);
|
||||
}, [messages, currentSwipeId]);
|
||||
|
||||
// 处理编辑消息
|
||||
const handleEdit = (message) => {
|
||||
startEditing(message.floor, message.mes); // ✅ 使用 store 方法
|
||||
@@ -155,6 +276,109 @@ const ChatBox = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 新增:右键菜单处理
|
||||
const handleContextMenu = (e, message) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
message: message
|
||||
});
|
||||
};
|
||||
|
||||
// ✅ 关闭右键菜单
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu({ visible: false, x: 0, y: 0, message: null });
|
||||
};
|
||||
|
||||
// ✅ 复制消息内容
|
||||
const handleCopyMessage = async (message) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(message.mes);
|
||||
console.log('[ChatBox] 消息已复制到剪贴板');
|
||||
closeContextMenu();
|
||||
} catch (err) {
|
||||
console.error('[ChatBox] 复制失败:', err);
|
||||
alert('复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 删除消息
|
||||
const handleDeleteMessage = async (message) => {
|
||||
if (!confirm(`确定要删除这条消息吗?`)) {
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { deleteMessage } = useChatBoxStore.getState();
|
||||
await deleteMessage(message.floor);
|
||||
console.log('[ChatBox] 消息已删除');
|
||||
closeContextMenu();
|
||||
} catch (err) {
|
||||
console.error('[ChatBox] 删除失败:', err);
|
||||
alert('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 从右键菜单编辑
|
||||
const handleEditFromContext = (message) => {
|
||||
startEditing(message.floor, message.mes);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
// ✅ 重roll消息(重新生成)- 在目标消息的swipe数组中添加新版本
|
||||
const handleRerollMessage = async (message) => {
|
||||
// 只有 AI 消息才能重roll
|
||||
if (message.is_user) {
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { currentRole, currentChat, wsConnection } = useChatBoxStore.getState();
|
||||
|
||||
if (!currentRole || !currentChat) {
|
||||
alert('请先选择角色和聊天');
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取上一条用户消息
|
||||
const messageIndex = messages.findIndex(m => m.floor === message.floor);
|
||||
let userMessage = null;
|
||||
|
||||
// 向前查找最近的用户消息
|
||||
for (let i = messageIndex - 1; i >= 0; i--) {
|
||||
if (messages[i].is_user) {
|
||||
userMessage = messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!userMessage) {
|
||||
alert('找不到上一条用户消息');
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ChatBox] 重roll消息,使用用户输入:', userMessage.mes);
|
||||
console.log('[ChatBox] 目标消息 floor:', message.floor);
|
||||
|
||||
// 关闭菜单
|
||||
closeContextMenu();
|
||||
|
||||
// ✅ 调用 sendMessage,传入 targetFloor 参数,在目标消息的swipe数组中添加新版本
|
||||
const { sendMessage } = useChatBoxStore.getState();
|
||||
await sendMessage(userMessage.mes, message.floor); // ✅ 传入 targetFloor
|
||||
|
||||
} catch (err) {
|
||||
console.error('[ChatBox] 重roll失败:', err);
|
||||
alert('重roll失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换选项显示
|
||||
const toggleOptionsPanel = () => {
|
||||
toggleOptions(); // ✅ 使用 store 方法
|
||||
@@ -268,28 +492,16 @@ const ChatBox = () => {
|
||||
const uniqueKey = message.id || `${message.floor}-${index}`;
|
||||
|
||||
return (
|
||||
<div key={uniqueKey} className={`message ${isUser ? 'user' : 'ai'}`}>
|
||||
<div
|
||||
key={uniqueKey}
|
||||
className={`message ${isUser ? 'user' : 'ai'}`}
|
||||
onContextMenu={(e) => handleContextMenu(e, message)} // ✅ 添加右键菜单
|
||||
>
|
||||
<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 ? (
|
||||
@@ -339,22 +551,25 @@ const ChatBox = () => {
|
||||
) : (
|
||||
<div className="plain-text">{currentMes}</div>
|
||||
)}
|
||||
{hasSwipes && !isUser && isLatestMessage && (
|
||||
{/* ✅ Swipe 控制按钮 - 所有有 swipe 的 AI 消息都显示 */}
|
||||
{hasSwipes && !isUser && (
|
||||
<div className="swipe-controls">
|
||||
<button
|
||||
className="swipe-button"
|
||||
onClick={() => handleSwipeChange(message.floor, -1)}
|
||||
disabled={currentSwipeIndex === 0}
|
||||
title="上一个版本"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<span className="swipe-counter">
|
||||
<span className="swipe-counter" title={`共 ${message.swipes.length} 个版本`}>
|
||||
{currentSwipeIndex + 1}/{message.swipes.length}
|
||||
</span>
|
||||
<button
|
||||
className="swipe-button"
|
||||
onClick={() => handleSwipeChange(message.floor, 1)}
|
||||
disabled={currentSwipeIndex === message.swipes.length - 1}
|
||||
title="下一个版本"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
@@ -370,7 +585,7 @@ const ChatBox = () => {
|
||||
|
||||
return (
|
||||
<div className="chat-box">
|
||||
<div className="chat-messages">
|
||||
<div className="chat-messages" ref={messagesContainerRef}>
|
||||
{isLoading ? (
|
||||
<div className="loading">加载中...</div>
|
||||
) : error ? (
|
||||
@@ -548,6 +763,45 @@ const ChatBox = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ 右键菜单 */}
|
||||
{contextMenu.visible && contextMenu.message && (
|
||||
<div
|
||||
className="context-menu-overlay"
|
||||
onClick={closeContextMenu}
|
||||
>
|
||||
<div
|
||||
className="context-menu"
|
||||
ref={contextMenuRef}
|
||||
style={{
|
||||
left: `${contextMenu.x}px`,
|
||||
top: `${contextMenu.y}px`
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="context-menu-item" onClick={() => handleEditFromContext(contextMenu.message)}>
|
||||
<span className="menu-icon">✎</span>
|
||||
<span className="menu-label">编辑</span>
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleCopyMessage(contextMenu.message)}>
|
||||
<span className="menu-icon">📋</span>
|
||||
<span className="menu-label">复制</span>
|
||||
</div>
|
||||
{/* ✅ 只有 AI 消息才显示重roll选项 */}
|
||||
{!contextMenu.message.is_user && (
|
||||
<div className="context-menu-item" onClick={() => handleRerollMessage(contextMenu.message)}>
|
||||
<span className="menu-icon">🔄</span>
|
||||
<span className="menu-label">重roll</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider"></div>
|
||||
<div className="context-menu-item danger" onClick={() => handleDeleteMessage(contextMenu.message)}>
|
||||
<span className="menu-icon">🗑️</span>
|
||||
<span className="menu-label">删除</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -94,3 +94,29 @@
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ✅ 左侧边栏标签右键菜单样式 */
|
||||
.sidebar-tab-context-menu {
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.context-menu-header {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1), rgba(102, 126, 234, 0.05));
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.context-menu-description {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// frontend-react/src/components/SideBarLeft/SideBarLeft.jsx
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import './SideBarLeft.css';
|
||||
import { useSideBarLeftStore } from '../../Store/indexStore';
|
||||
import usePresetStore from '../../Store/SideBarLeft/PresetSlice';
|
||||
@@ -15,6 +15,15 @@ const SideBarLeft = () => {
|
||||
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
|
||||
const { fetchPresets } = usePresetStore();
|
||||
const { fetchProfiles } = useApiConfigStore();
|
||||
const contextMenuRef = useRef(null);
|
||||
|
||||
// ✅ 右键菜单状态
|
||||
const [contextMenu, setContextMenu] = React.useState({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
tab: null
|
||||
});
|
||||
|
||||
// 处理标签切换
|
||||
const handleTabClick = (tabId) => {
|
||||
@@ -31,6 +40,28 @@ const SideBarLeft = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 显示右键菜单
|
||||
const handleContextMenu = (e, tab) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
tab: tab
|
||||
});
|
||||
};
|
||||
|
||||
// ✅ 关闭右键菜单
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu({ visible: false, x: 0, y: 0, tab: null });
|
||||
};
|
||||
|
||||
// ✅ 点击菜单项后自动切换到对应标签
|
||||
const handleSwitchToTab = (tabId) => {
|
||||
handleTabClick(tabId);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar-left">
|
||||
<div className="sidebar-tabs">
|
||||
@@ -39,7 +70,8 @@ const SideBarLeft = () => {
|
||||
key={tab.id}
|
||||
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
title={tab.title}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab)} // ✅ 添加右键菜单
|
||||
// ✅ 移除 title,改用右键菜单显示详情
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
@@ -54,6 +86,67 @@ const SideBarLeft = () => {
|
||||
{activeTab === 'worldbook' && <WorldBook />}
|
||||
{activeTab === 'tokenUsage' && <TokenUsage />}
|
||||
</div>
|
||||
|
||||
{/* ✅ 右键菜单 */}
|
||||
{contextMenu.visible && contextMenu.tab && (
|
||||
<div
|
||||
className="context-menu-overlay"
|
||||
onClick={closeContextMenu}
|
||||
>
|
||||
<div
|
||||
className="context-menu sidebar-tab-context-menu"
|
||||
ref={contextMenuRef}
|
||||
style={{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 菜单标题 - 显示标签名称 */}
|
||||
<div className="context-menu-header">
|
||||
<span className="menu-title">{contextMenu.tab.label}</span>
|
||||
</div>
|
||||
|
||||
{/* 菜单描述 */}
|
||||
<div className="context-menu-description">
|
||||
{contextMenu.tab.title}
|
||||
</div>
|
||||
|
||||
<div className="context-menu-divider"></div>
|
||||
|
||||
{/* 操作项 */}
|
||||
<div className="context-menu-item" onClick={() => handleSwitchToTab(contextMenu.tab.id)}>
|
||||
<span className="menu-icon">📂</span>
|
||||
<span className="menu-label">打开</span>
|
||||
</div>
|
||||
|
||||
{/* 根据标签类型显示不同的快捷操作 */}
|
||||
{contextMenu.tab.id === 'character' && (
|
||||
<>
|
||||
<div className="context-menu-item" onClick={() => { handleSwitchToTab('character'); setTimeout(() => document.querySelector('.create-character-btn')?.click(), 100); }}>
|
||||
<span className="menu-icon">➕</span>
|
||||
<span className="menu-label">新建角色</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{contextMenu.tab.id === 'presets' && (
|
||||
<>
|
||||
<div className="context-menu-item" onClick={() => { handleSwitchToTab('presets'); setTimeout(() => document.querySelector('.create-preset-btn')?.click(), 100); }}>
|
||||
<span className="menu-icon">➕</span>
|
||||
<span className="menu-label">新建预设</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{contextMenu.tab.id === 'worldbook' && (
|
||||
<>
|
||||
<div className="context-menu-item" onClick={() => { handleSwitchToTab('worldbook'); setTimeout(() => document.querySelector('.action-btn')?.click(), 100); }}>
|
||||
<span className="menu-icon">➕</span>
|
||||
<span className="menu-label">新建世界书</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useState, useEffect, useRef, useCallback, memo } from 'react'; /
|
||||
import { useCharacterStore, useCharacterCardUIStore } from '../../../../Store/SideBarLeft'; // ✅ 新增
|
||||
import useChatBoxStore from '../../../../Store/Mid/ChatBoxSlice';
|
||||
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice'; // 引入世界书 Store
|
||||
import useSideBarLeftStore from '../../../../Store/SideBarLeft/SideBarLeftSlice'; // ✅ 引入侧边栏 Store
|
||||
import './CharacterCard.css';
|
||||
|
||||
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
|
||||
@@ -236,6 +237,12 @@ const CharacterCard = () => {
|
||||
|
||||
try {
|
||||
await deleteCharacter(name);
|
||||
|
||||
// ✅ 删除成功后自动切换到角色分页
|
||||
const { setActiveTab } = useSideBarLeftStore.getState();
|
||||
setActiveTab('character');
|
||||
|
||||
console.log('[CharacterCard] 角色已删除,已切换到角色分页');
|
||||
} catch (err) {
|
||||
console.error('删除失败:', err);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@import './styles/variables.css';
|
||||
@import './styles/reset.css';
|
||||
@import './styles/z-index.css'; /* ✅ Z-Index 层级规范 */
|
||||
@import './styles/context-menu.css'; /* ✅ 右键菜单通用样式 */
|
||||
|
||||
/* ==================== Main Layout ==================== */
|
||||
.app {
|
||||
|
||||
73
frontend/src/styles/context-menu.css
Normal file
73
frontend/src/styles/context-menu.css
Normal file
@@ -0,0 +1,73 @@
|
||||
/* ==================== 右键菜单通用样式 ==================== */
|
||||
.context-menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: var(--z-context-menu); /* ✅ 上下文菜单层 */
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
min-width: 160px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: var(--spacing-xs) 0;
|
||||
z-index: calc(var(--z-context-menu) + 1);
|
||||
animation: contextMenuFadeIn 0.1s ease;
|
||||
}
|
||||
|
||||
@keyframes contextMenuFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background-color: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.context-menu-item.danger {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.context-menu-item.danger:hover {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 1rem;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
--z-tooltip: 1200;
|
||||
--z-chat-actions: 1000;
|
||||
--z-character-preview: 1000;
|
||||
--z-context-menu: 1500; /* ✅ 右键菜单 */
|
||||
|
||||
/* ==================== 弹窗层 (10000-19999) ==================== */
|
||||
--z-modal-overlay: 10000;
|
||||
|
||||
Reference in New Issue
Block a user