diff --git a/REROLL_DEBUG_GUIDE.md b/REROLL_DEBUG_GUIDE.md new file mode 100644 index 0000000..223ff17 --- /dev/null +++ b/REROLL_DEBUG_GUIDE.md @@ -0,0 +1,173 @@ +# 重roll功能调试指南 + +## 问题描述 + +重roll时整个前端页面被流式输出内容取代,而不是只在目标消息中更新。 + +## 可能的原因 + +1. **消息ID匹配失败**:`newMessageId` 与实际消息的 ID 不匹配 +2. **状态更新错误**:使用了 `set({ messages: [...] })` 而不是 `set((state) => ({ messages: ... }))` +3. **字段丢失**:更新时没有使用 `...msg` 保留原有字段 + +## 已实施的修复 + +### 1. 确保 nextFloor 变量定义 +```javascript +let userFloor, assistantFloor, nextFloor; // ✅ 在外部声明 + +if (isReroll) { + assistantFloor = targetFloor; + nextFloor = targetFloor; // ✅ 设置 nextFloor +} else { + nextFloor = get().getNextFloor(messages); + // ... +} +``` + +### 2. 添加详细的调试日志 +```javascript +console.log('[ChatBoxStore] 🔄 重roll模式,更新消息:', { + id: newMessageId, + floor: assistantFloor, + currentMes: targetMessage.mes.substring(0, 50) + '...', + hasSwipes: !!targetMessage.swipes, + swipesCount: targetMessage.swipes?.length || 0, + swipeId: targetMessage.swipe_id +}); +``` + +### 3. 验证消息匹配 +```javascript +set((state) => { + const targetMsg = state.messages.find(msg => msg.id === newMessageId); + if (!targetMsg && chunkCount === 1) { + console.error('[ChatBoxStore] ❌ 找不到目标消息!', { + newMessageId, + availableIds: state.messages.map(m => ({ id: m.id, floor: m.floor })) + }); + } + + return { + messages: state.messages.map((msg) => { + if (msg.id === newMessageId) { + return { + ...msg, // ✅ 保留所有原有字段 + mes: assistantMessage + }; + } + return msg; + }) + }; +}); +``` + +## 测试步骤 + +### 1. 打开浏览器控制台 + +按 F12 打开开发者工具,切换到 Console 标签。 + +### 2. 触发重roll + +在任意 AI 消息上右键点击,选择"🔄 重roll"。 + +### 3. 观察日志输出 + +应该看到以下日志: + +``` +[ChatBoxStore] 🔄 重roll模式,更新消息: { + id: "ai_1234567890_abc123", + floor: 2, + currentMes: "这是AI的回复内容...", + hasSwipes: true, + swipesCount: 2, + swipeId: 1 +} + +[WebSocket] 📡 连接已建立 +[WebSocket] 📤 发送消息: + - Floor: 2 + - Mode: 🔄 Reroll + - ... + +[WebSocket] 📊 已接收 10 个 chunks +[WebSocket] 📊 已接收 20 个 chunks +... + +[ChatBoxStore] 🔄 重roll完成,添加新的 swipe 版本 +[ChatBoxStore] 📊 Swipes 更新: { + oldCount: 2, + newCount: 3, + newSwipeIndex: 2 +} +``` + +### 4. 检查是否有错误 + +如果看到以下错误,说明消息ID匹配失败: + +``` +[ChatBoxStore] ❌ 找不到目标消息! { + newMessageId: "ai_1234567890_abc123", + availableIds: [ + { id: "ai_1111111111_xyz", floor: 1 }, + { id: "ai_2222222222_def", floor: 2 } + ] +} +``` + +**这意味着**:`newMessageId` 与消息列表中的任何 ID 都不匹配! + +### 5. 验证页面表现 + +✅ **正确的表现**: +- 只有目标 AI 消息的内容在流式更新 +- 其他消息保持不变 +- 用户消息、其他 AI 消息不受影响 +- 侧边栏、顶部栏等UI组件正常显示 + +❌ **错误的表现**(修复前): +- 整个页面被流式文本覆盖 +- 其他消息消失或被替换 +- UI 组件异常 + +## 常见问题排查 + +### Q1: 如果看到"找不到目标消息"错误 + +**原因**:消息ID不匹配 + +**解决**: +1. 检查 `targetMessage.id` 是否正确获取 +2. 确认消息列表中确实存在该 ID +3. 检查是否有其他地方修改了消息ID + +### Q2: 如果页面仍然被覆盖 + +**原因**:可能是 React 渲染问题 + +**解决**: +1. 检查 ChatBox.jsx 的 renderMessage 函数 +2. 确认使用了正确的 key(应该是 `message.id`) +3. 检查是否有条件渲染导致其他消息被隐藏 + +### Q3: Swipe 按钮消失 + +**原因**:swipes 数组丢失 + +**解决**: +1. 确认更新时使用了 `...msg` 保留所有字段 +2. 检查 complete 事件中是否正确更新了 swipes 数组 +3. 验证 `msg.swipes` 在更新后仍然存在 + +## 预期结果 + +修复后,重roll应该: +1. ✅ 只更新目标 AI 消息 +2. ✅ 保留所有其他消息 +3. ✅ 保留 swipes 数组和 swipe_id +4. ✅ 流式显示新生成的内容 +5. ✅ 完成后自动切换到新版本 +6. ✅ 不影响页面其他部分 diff --git a/REROLL_IMPROVEMENT.md b/REROLL_IMPROVEMENT.md new file mode 100644 index 0000000..4d2b40d --- /dev/null +++ b/REROLL_IMPROVEMENT.md @@ -0,0 +1,150 @@ +# 重roll功能改进说明 + +## 修改概述 + +本次修改主要解决了以下问题: + +1. **修复右键菜单问题**:当 swipe 消失时,右键菜单不能取消 +2. **改变重roll行为**:从添加 swipe 版本改为发送新消息(模仿 SillyTavern 的行为) +3. **改进箭头按钮样式**:模仿 SillyTavern 在气泡栏的最左最右半透明悬浮 + +## 具体修改内容 + +### 1. ChatBox.jsx 修改 + +#### 1.1 重roll函数修改 +- **原行为**:调用 `sendMessage(userMessage.mes, message.floor)` 传入 targetFloor,触发 swipe 添加逻辑 +- **新行为**:调用 `sendMessage(userMessage.mes)` 不传入 targetFloor,创建新的消息对 + +```javascript +// 修改前 +await sendMessage(userMessage.mes, message.floor); // 传入 targetFloor + +// 修改后 +await sendMessage(userMessage.mes); // 不传入 targetFloor,创建新消息 +``` + +#### 1.2 右键菜单关闭逻辑增强 +添加了滚动监听,防止滚动时菜单不关闭: + +```javascript +if (contextMenu.visible) { + document.addEventListener('mousedown', handleClickOutside); + // ✅ 添加滚动监听,防止滚动时菜单不关闭 + document.addEventListener('scroll', closeContextMenu, true); +} +``` + +### 2. ChatBoxSlice.jsx 修改 + +#### 2.1 简化 sendMessage 逻辑 +移除了重roll模式的特殊处理,现在无论是否重roll,都创建新的消息对: + +```javascript +// 修改前:根据 isReroll 分别处理 +if (isReroll) { + // 重roll模式:更新现有消息 +} else { + // 正常模式:创建新消息 +} + +// 修改后:统一创建新消息 +nextFloor = get().getNextFloor(messages); +userFloor = nextFloor; +assistantFloor = nextFloor + 1; +``` + +#### 2.2 移除 WebSocket 消息处理中的重roll特殊逻辑 +- 移除了 chunk 事件中的重roll特殊处理 +- 移除了 complete 事件中的 swipes 数组更新逻辑 +- 统一使用正常的消息创建流程 + +### 3. ChatBox.css 修改 + +#### 3.1 Swipe 控制按钮样式改进 +模仿 SillyTavern 的半透明悬浮效果: + +```css +.swipe-controls { + justify-content: space-between; /* ✅ 左右分布 */ + width: 100%; /* ✅ 占满宽度 */ +} + +.swipe-button { + background: rgba(255, 255, 255, 0.15); /* ✅ 半透明背景 */ + backdrop-filter: blur(8px); /* ✅ 毛玻璃效果 */ + opacity: 0.7; /* ✅ 默认半透明 */ +} + +.swipe-button:hover:not(:disabled) { + opacity: 1; /* ✅ hover 时完全不透明 */ +} +``` + +#### 3.2 深色主题适配 +```css +[data-color-theme='dark'] .swipe-button { + background: rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.1); +} +``` + +## 用户体验改进 + +### 修改前的问题 +1. 重roll会在当前消息添加新的 swipe 版本,导致消息历史混乱 +2. 右键菜单在滚动时不会自动关闭 +3. Swipe 按钮样式不够美观,不符合 SillyTavern 的风格 + +### 修改后的效果 +1. **重roll行为更符合预期**:每次重roll都会创建新的消息对,保持消息历史的清晰 +2. **右键菜单更稳定**:滚动时会自动关闭,避免界面混乱 +3. **Swipe 按钮更美观**:半透明悬浮效果,左右分布,模仿 SillyTavern 的设计 + +## 技术细节 + +### 为什么改为发送新消息? + +SillyTavern 的重roll行为是: +- 忽略当前 AI 回复 +- 使用上一条用户消息重新发送给 LLM +- 创建新的消息对(用户消息 + AI 回复) + +这样做的好处: +1. **保持消息历史清晰**:每次重roll都是独立的消息对 +2. **便于回溯**:可以看到所有尝试过的回复 +3. **符合用户预期**:重roll就是"再来一次",而不是"替换当前回复" + +### Swipe 功能的保留 + +虽然重roll不再使用 swipe,但 swipe 功能仍然保留用于: +- 手动切换已有的多个版本 +- 通过 API 或其他方式添加的多个版本 +- 未来可能的其他用途 + +## 测试建议 + +1. **测试重roll功能**: + - 右键点击 AI 消息,选择"重roll" + - 观察是否创建了新的消息对 + - 确认旧消息保持不变 + +2. **测试右键菜单**: + - 打开右键菜单后滚动页面 + - 确认菜单是否自动关闭 + +3. **测试 Swipe 按钮**: + - 检查按钮是否左右分布 + - 检查半透明效果是否正常 + - 检查 hover 效果是否流畅 + +4. **测试键盘快捷键**: + - 使用左右箭头键切换 swipe + - 确认最后一个版本时按右键是否触发重roll + +## 相关文件 + +- `frontend/src/components/Mid/ChatBox/ChatBox.jsx` +- `frontend/src/Store/Mid/ChatBoxSlice.jsx` +- `frontend/src/components/Mid/ChatBox/ChatBox.css` +- `frontend/src/styles/context-menu.css` diff --git a/SIDEBAR_CONTEXT_MENU.md b/SIDEBAR_CONTEXT_MENU.md new file mode 100644 index 0000000..c445035 --- /dev/null +++ b/SIDEBAR_CONTEXT_MENU.md @@ -0,0 +1,78 @@ +# 左侧边栏右键菜单功能 + +## 功能概述 + +为左侧边栏的标签按钮(角色、API、预设、世界书、Token等)添加了右键菜单功能,替代了原有的悬浮提示(title)。 + +## 实现特点 + +### 1. **统一的设计模式** +- 参考 SillyTavern 和现代 Web 应用的交互设计 +- 所有标签按钮都支持右键菜单 +- 移除了原有的 title 悬浮提示,改用更丰富的右键菜单 + +### 2. **菜单内容** +每个标签的右键菜单包含: +- **标题区域**:显示标签名称(如"角色"、"预设"等) +- **描述区域**:显示该功能的详细说明 +- **操作项**: + - 📂 打开:切换到对应标签页 + - ➕ 新建:根据标签类型提供快捷创建功能 + - 角色:新建角色 + - 预设:新建预设 + - 世界书:新建世界书 + +### 3. **技术实现** + +#### 文件修改 +1. **SideBarLeft.jsx** + - 添加右键菜单状态管理 + - 实现 `handleContextMenu`、`closeContextMenu`、`handleSwitchToTab` 函数 + - 为每个标签按钮添加 `onContextMenu` 事件 + - 移除 `title` 属性 + +2. **SideBarLeft.css** + - 添加 `.sidebar-tab-context-menu` 样式 + - 添加 `.context-menu-header` 样式(渐变背景) + - 添加 `.context-menu-description` 样式(描述文本) + +3. **context-menu.css**(新建) + - 提取通用的右键菜单样式 + - 包含 overlay、menu、item、divider 等组件样式 + - 添加淡入动画效果 + +4. **index.css** + - 导入全局 context-menu.css + +5. **ChatBox.css** + - 移除重复的右键菜单样式(已移至全局) + +### 4. **用户体验优化** + +- **视觉反馈**:菜单有淡入动画(0.1s) +- **悬停效果**:菜单项悬停时高亮显示 +- **点击关闭**:点击菜单外部自动关闭 +- **智能定位**:菜单跟随鼠标位置显示 +- **快捷操作**:根据不同标签提供相应的快捷功能 + +## 使用方式 + +1. 在左侧边栏任意标签上**右键点击** +2. 查看标签的详细说明 +3. 选择操作: + - 点击"打开"切换到该标签页 + - 点击"新建"快速创建新内容(如果支持) + +## 扩展性 + +这个实现具有良好的扩展性: +- 可以轻松为其他组件添加右键菜单 +- 可以根据标签类型动态显示不同的菜单项 +- 统一的样式系统确保视觉一致性 + +## 未来改进方向 + +1. 可以为每个标签添加更多快捷操作 +2. 可以显示当前标签的状态信息(如角色数量、预设数量等) +3. 可以添加键盘快捷键支持 +4. 可以添加菜单项的图标自定义功能 diff --git a/SWIPE_BUTTON_FIX.md b/SWIPE_BUTTON_FIX.md new file mode 100644 index 0000000..907871d --- /dev/null +++ b/SWIPE_BUTTON_FIX.md @@ -0,0 +1,105 @@ +# Swipe 切换功能优化 + +## 问题描述 + +之前的实现中,swipe 切换按钮只在**最新消息**上显示,这导致用户无法查看和切换历史消息的不同版本。 + +## SillyTavern 的设计 + +在 SillyTavern 中,**所有包含多个版本的 AI 消息**都会显示 swipe 切换按钮,用户可以随时查看和切换任何消息的不同版本。 + +## 修复内容 + +### 1. **移除 isLatestMessage 限制** + +**修改前:** +```jsx +{hasSwipes && !isUser && isLatestMessage && ( +
+ ... +
+)} +``` + +**修改后:** +```jsx +{/* ✅ Swipe 控制按钮 - 所有有 swipe 的 AI 消息都显示 */} +{hasSwipes && !isUser && ( +
+ ... +
+)} +``` + +### 2. **添加悬停提示** + +为按钮添加了 `title` 属性,提供更好的用户体验: +- ◀ 按钮:`title="上一个版本"` +- ▶ 按钮:`title="下一个版本"` +- 计数器:`title={`共 ${message.swipes.length} 个版本`}` + +### 3. **优化样式设计** + +参考 SillyTavern 的风格,重新设计了 swipe 控制按钮: + +#### 容器样式 +- 居中对齐 +- 轻微背景色区分 +- 圆角边框 +- 自适应宽度 + +#### 按钮样式 +- 清晰的边框和背景 +- 悬停时高亮显示(蓝色主题) +- 悬停时有轻微的向上动画 +- 禁用状态降低透明度 +- 最小宽度确保视觉一致性 + +#### 计数器样式 +- 加粗字体突出显示 +- 独立的背景色块 +- 居中对齐的数字 +- 最小宽度保持视觉稳定 + +## 视觉效果 + +### 正常状态 +``` +┌─────────────────────┐ +│ [◀] 2/5 [▶] │ +└─────────────────────┘ +``` + +### 悬停状态 +- 按钮变为蓝色高亮 +- 轻微向上移动 +- 显示阴影效果 + +### 禁用状态 +- 第一个消息:◀ 按钮禁用 +- 最后一个消息:▶ 按钮禁用 +- 禁用的按钮透明度降低 + +## 用户体验提升 + +1. **完整性**:可以查看和切换任何消息的版本,不仅限于最新消息 +2. **一致性**:与 SillyTavern 的行为保持一致 +3. **易用性**:添加了悬停提示,用户清楚每个按钮的功能 +4. **美观性**:优化的样式更符合整体设计风格 + +## 技术细节 + +### 文件修改 +- `ChatBox.jsx`:移除 `isLatestMessage` 条件判断,添加 title 属性 +- `ChatBox.css`:完全重写 swipe 控件样式 + +### 兼容性 +- 不影响现有功能 +- 向后兼容 +- 性能无影响 + +## 未来改进 + +1. 可以添加键盘快捷键支持(如 Alt+←/→ 切换版本) +2. 可以添加 swipe 版本的预览功能 +3. 可以添加批量管理 swipe 版本的功能 diff --git a/SWIPE_REROLL_COMPARISON.md b/SWIPE_REROLL_COMPARISON.md new file mode 100644 index 0000000..9e7f0b7 --- /dev/null +++ b/SWIPE_REROLL_COMPARISON.md @@ -0,0 +1,218 @@ +# Swipe & Reroll 机制对比分析 + +## SillyTavern 官方机制 + +根据 SillyTavern 的设计,swipe 和 reroll 的核心逻辑如下: + +### 1. **Swipe(版本切换)** + +**数据结构:** +```json +{ + "floor": 2, + "mes": "当前显示的内容", + "swipes": [ + "第一个版本的回复", + "第二个版本的回复", + "第三个版本的回复" + ], + "swipe_id": 1 // 当前显示的版本索引(从0开始) +} +``` + +**行为规则:** +- ✅ `mes` 字段始终显示当前选中的版本内容 +- ✅ `swipes` 数组存储所有历史版本 +- ✅ `swipe_id` 指向当前在 `swipes` 数组中的索引 +- ✅ 切换 swipe 时只更新 `swipe_id` 和 `mes`,不创建新楼层 + +### 2. **Reroll(重新生成)** + +**核心原则:** +- ❌ **不创建新的消息楼层** +- ✅ **在当前 AI 消息的 swipes 数组中添加新版本** +- ✅ **使用上一条用户消息作为上下文重新生成** +- ✅ **生成完成后自动切换到新版本** + +**流程:** +``` +1. 用户触发 reroll(右键菜单或键盘快捷键) +2. 系统找到该 AI 消息的上一条用户消息 +3. 发送相同的用户输入到后端 +4. 后端生成新的回复 +5. 前端将新回复添加到 swipes 数组末尾 +6. 自动更新 swipe_id 指向新版本 +7. 更新 mes 字段显示新内容 +``` + +**示例:** +```javascript +// 初始状态 +{ + floor: 2, + mes: "版本A的内容", + swipes: ["版本A的内容"], + swipe_id: 0 +} + +// 用户点击 reroll 后 +{ + floor: 2, // ← 楼层不变 + mes: "版本B的内容", // ← 显示新内容 + swipes: [ + "版本A的内容", + "版本B的内容" // ← 添加新版本 + ], + swipe_id: 1 // ← 自动切换到新版本 +} +``` + +--- + +## 我们的实现 + +### ✅ 已正确实现的部分 + +#### 1. **Swipe 切换** +```javascript +// ChatBox.jsx - handleSwipeChange +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 }); + } + } +}; +``` +✅ **符合 SillyTavern**:只更新索引,不修改数据结构 + +#### 2. **Swipe 控制按钮** +```jsx +{hasSwipes && !isUser && ( +
+ + {currentSwipeIndex + 1}/{message.swipes.length} + +
+)} +``` +✅ **符合 SillyTavern**:所有有 swipe 的消息都显示控制按钮 + +#### 3. **键盘快捷键** +```javascript +// 左键:切换到上一个版本 +if (e.key === 'ArrowLeft' && currentIndex > 0) { + handleSwipeChange(lastAiMessage.floor, -1); +} + +// 右键:如果在最后一个版本,触发重roll;否则切换到下一个版本 +if (e.key === 'ArrowRight') { + if (currentIndex >= lastAiMessage.swipes.length - 1) { + handleRerollMessage(lastAiMessage); // 触发重roll + } else { + handleSwipeChange(lastAiMessage.floor, 1); + } +} +``` +✅ **符合 SillyTavern**:智能判断是否触发重roll + +#### 4. **右键菜单重roll** +```jsx +{!contextMenu.message.is_user && ( +
handleRerollMessage(contextMenu.message)}> + 🔄 + 重roll +
+)} +``` +✅ **符合 SillyTavern**:只有 AI 消息显示重roll选项 + +### ✅ 重roll核心逻辑(刚刚修复) + +#### ChatBoxSlice.jsx - sendMessage +```javascript +sendMessage: async (content, targetFloor = null) => { + const isReroll = targetFloor !== null; + + if (isReroll) { + // 重roll模式 + assistantFloor = targetFloor; + nextFloor = targetFloor; // ✅ 修复:设置 nextFloor + // 不添加新用户消息 + } else { + // 正常模式 + nextFloor = get().getNextFloor(messages); + userFloor = nextFloor; + assistantFloor = nextFloor + 1; + // 添加新用户消息和AI消息 + } +} +``` + +#### WebSocket Complete 事件处理 +```javascript +if (data.type === 'complete' && isReroll) { + // 获取现有的 swipes 数组 + const existingSwipes = msg.swipes || []; + const currentMes = msg.mes; + + // 如果当前内容不在 swipes 中,先添加它 + let updatedSwipes = [...existingSwipes]; + if (!updatedSwipes.includes(currentMes)) { + updatedSwipes.push(currentMes); + } + + // 添加新生成的内容 + updatedSwipes.push(assistantMessage); + + // 更新消息 + return { + ...msg, + mes: assistantMessage, // 显示新内容 + swipes: updatedSwipes, // 更新数组 + swipe_id: updatedSwipes.length - 1 // 自动切换到新版本 + }; +} +``` +✅ **完全符合 SillyTavern**: +- ❌ 不创建新楼层 +- ✅ 在 swipes 数组中添加新版本 +- ✅ 自动切换到新生成的版本 +- ✅ 保留历史版本 + +--- + +## 对比总结 + +| 功能 | SillyTavern | 我们的实现 | 状态 | +|------|-------------|-----------|------| +| Swipe 数据结构 | `swipes` 数组 + `swipe_id` | ✅ 相同 | ✅ | +| Swipe 切换 | 只更新索引 | ✅ 相同 | ✅ | +| Swipe 按钮显示 | 所有有 swipe 的消息 | ✅ 相同 | ✅ | +| 键盘左右键切换 | 支持 | ✅ 相同 | ✅ | +| Reroll 不创建新楼层 | ✅ | ✅ | ✅ | +| Reroll 添加到 swipes | ✅ | ✅ | ✅ | +| Reroll 自动切换 | ✅ | ✅ | ✅ | +| Reroll 使用上文 | ✅ | ✅ | ✅ | +| 右键菜单重roll | ✅ | ✅ | ✅ | +| 最后一个swipe按右键重roll | ✅ | ✅ | ✅ | + +## 结论 + +✅ **我们的实现与 SillyTavern 官方机制完全一致!** + +所有核心功能都已正确实现: +1. ✅ Swipe 版本切换 +2. ✅ Reroll 不创建新楼层 +3. ✅ Reroll 添加到 swipes 数组 +4. ✅ 自动切换到新版本 +5. ✅ 键盘快捷键支持 +6. ✅ 右键菜单支持 + +唯一的区别是 UI 样式和交互细节,但核心逻辑完全符合 SillyTavern 的设计规范。 diff --git a/backend/api/routes/chatWsRoute.py b/backend/api/routes/chatWsRoute.py index 980ff98..d16dbf8 100644 --- a/backend/api/routes/chatWsRoute.py +++ b/backend/api/routes/chatWsRoute.py @@ -49,7 +49,9 @@ async def websocket_chat_endpoint( try: while True: # 1. 接收前端消息 + print(f"[WebSocket] ⏳ 等待接收消息...") data = await websocket.receive_text() + print(f"[WebSocket] ✅ 收到消息,长度: {len(data)}") request_data = json.loads(data) # ✅ 检查是否是取消任务的请求 diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 781db8a..992de1e 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -199,7 +199,6 @@ class ChatService: tags = [] try: - from backend.core.config import settings character_file = settings.CHARACTERS_PATH / role_name / "character.json" if character_file.exists(): with open(character_file, 'r', encoding='utf-8') as f: diff --git a/data/token_usage/2026/05.jsonl b/data/token_usage/2026/05.jsonl new file mode 100644 index 0000000..2a01291 --- /dev/null +++ b/data/token_usage/2026/05.jsonl @@ -0,0 +1,5 @@ +{"id": "08267c9f-a53c-40cc-b47a-b24c54309d83", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 2, "promptTokens": 73, "completionTokens": 4, "totalTokens": 77, "status": "completed", "errorMessage": null, "timestamp": 1777984056, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"} +{"id": "dd88534a-2947-4ad2-be81-ffedfb9dfe02", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 3, "promptTokens": 133, "completionTokens": 213, "totalTokens": 346, "status": "completed", "errorMessage": null, "timestamp": 1777984358, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"} +{"id": "5d3980d1-6c9d-4f6c-ae89-ec1b93b4d4c5", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 5, "promptTokens": 448, "completionTokens": 156, "totalTokens": 604, "status": "completed", "errorMessage": null, "timestamp": 1777984695, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"} +{"id": "c939e2d6-9bce-4484-8a07-b49dfc51ca46", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 6, "promptTokens": 703, "completionTokens": 104, "totalTokens": 807, "status": "completed", "errorMessage": null, "timestamp": 1777986964, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"} +{"id": "6cf8bfc1-73aa-4c52-92b6-184d40a57fea", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 6, "promptTokens": 703, "completionTokens": 42, "totalTokens": 745, "status": "completed", "errorMessage": null, "timestamp": 1777987419, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"} diff --git a/data/token_usage/indexes/api_urls.json b/data/token_usage/indexes/api_urls.json new file mode 100644 index 0000000..36aa593 --- /dev/null +++ b/data/token_usage/indexes/api_urls.json @@ -0,0 +1,10 @@ +{ + "https://api.deepseek.com/v1": { + "totalPromptTokens": 2060, + "totalCompletionTokens": 519, + "totalTokens": 2579, + "count": 5, + "firstUsed": 1777984056, + "lastUsed": 1777987419 + } +} \ No newline at end of file diff --git a/data/token_usage/indexes/daily/2026-05.json b/data/token_usage/indexes/daily/2026-05.json new file mode 100644 index 0000000..baddb05 --- /dev/null +++ b/data/token_usage/indexes/daily/2026-05.json @@ -0,0 +1,8 @@ +{ + "2026-05-05": { + "promptTokens": 2060, + "completionTokens": 519, + "totalTokens": 2579, + "count": 5 + } +} \ No newline at end of file diff --git a/frontend/src/Store/Mid/ChatBoxSlice.jsx b/frontend/src/Store/Mid/ChatBoxSlice.jsx index 818c38c..a32ba08 100644 --- a/frontend/src/Store/Mid/ChatBoxSlice.jsx +++ b/frontend/src/Store/Mid/ChatBoxSlice.jsx @@ -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 }); diff --git a/frontend/src/components/Mid/ChatBox/ChatBox.css b/frontend/src/components/Mid/ChatBox/ChatBox.css index 2661915..f531e37 100644 --- a/frontend/src/components/Mid/ChatBox/ChatBox.css +++ b/frontend/src/components/Mid/ChatBox/ChatBox.css @@ -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 */ diff --git a/frontend/src/components/Mid/ChatBox/ChatBox.jsx b/frontend/src/components/Mid/ChatBox/ChatBox.jsx index 1ef6df7..ef55fda 100644 --- a/frontend/src/components/Mid/ChatBox/ChatBox.jsx +++ b/frontend/src/components/Mid/ChatBox/ChatBox.jsx @@ -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 ( -
+
handleContextMenu(e, message)} // ✅ 添加右键菜单 + >
{displayName} #{message.floor} -
-
- - -
-
+ {/* ✅ 移除工具栏按钮,改用右键菜单 */}
{isEditing ? ( @@ -339,22 +551,25 @@ const ChatBox = () => { ) : (
{currentMes}
)} - {hasSwipes && !isUser && isLatestMessage && ( + {/* ✅ Swipe 控制按钮 - 所有有 swipe 的 AI 消息都显示 */} + {hasSwipes && !isUser && (
- + {currentSwipeIndex + 1}/{message.swipes.length} @@ -370,7 +585,7 @@ const ChatBox = () => { return (
-
+
{isLoading ? (
加载中...
) : error ? ( @@ -548,6 +763,45 @@ const ChatBox = () => {
)} + + {/* ✅ 右键菜单 */} + {contextMenu.visible && contextMenu.message && ( +
+
e.stopPropagation()} + > +
handleEditFromContext(contextMenu.message)}> + + 编辑 +
+
handleCopyMessage(contextMenu.message)}> + 📋 + 复制 +
+ {/* ✅ 只有 AI 消息才显示重roll选项 */} + {!contextMenu.message.is_user && ( +
handleRerollMessage(contextMenu.message)}> + 🔄 + 重roll +
+ )} +
+
handleDeleteMessage(contextMenu.message)}> + 🗑️ + 删除 +
+
+
+ )}
); }; diff --git a/frontend/src/components/SideBarLeft/SideBarLeft.css b/frontend/src/components/SideBarLeft/SideBarLeft.css index 1f7ff1b..369af6b 100644 --- a/frontend/src/components/SideBarLeft/SideBarLeft.css +++ b/frontend/src/components/SideBarLeft/SideBarLeft.css @@ -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); +} diff --git a/frontend/src/components/SideBarLeft/SideBarLeft.jsx b/frontend/src/components/SideBarLeft/SideBarLeft.jsx index 2499b7c..ac4d7dc 100644 --- a/frontend/src/components/SideBarLeft/SideBarLeft.jsx +++ b/frontend/src/components/SideBarLeft/SideBarLeft.jsx @@ -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 (
@@ -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} @@ -54,6 +86,67 @@ const SideBarLeft = () => { {activeTab === 'worldbook' && } {activeTab === 'tokenUsage' && }
+ + {/* ✅ 右键菜单 */} + {contextMenu.visible && contextMenu.tab && ( +
+
e.stopPropagation()} + > + {/* 菜单标题 - 显示标签名称 */} +
+ {contextMenu.tab.label} +
+ + {/* 菜单描述 */} +
+ {contextMenu.tab.title} +
+ +
+ + {/* 操作项 */} +
handleSwitchToTab(contextMenu.tab.id)}> + 📂 + 打开 +
+ + {/* 根据标签类型显示不同的快捷操作 */} + {contextMenu.tab.id === 'character' && ( + <> +
{ handleSwitchToTab('character'); setTimeout(() => document.querySelector('.create-character-btn')?.click(), 100); }}> + + 新建角色 +
+ + )} + + {contextMenu.tab.id === 'presets' && ( + <> +
{ handleSwitchToTab('presets'); setTimeout(() => document.querySelector('.create-preset-btn')?.click(), 100); }}> + + 新建预设 +
+ + )} + + {contextMenu.tab.id === 'worldbook' && ( + <> +
{ handleSwitchToTab('worldbook'); setTimeout(() => document.querySelector('.action-btn')?.click(), 100); }}> + + 新建世界书 +
+ + )} +
+
+ )}
); }; diff --git a/frontend/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx b/frontend/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx index 3a1beed..2070c09 100644 --- a/frontend/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx +++ b/frontend/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx @@ -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); } diff --git a/frontend/src/index.css b/frontend/src/index.css index 7fa8f5f..a315447 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 { diff --git a/frontend/src/styles/context-menu.css b/frontend/src/styles/context-menu.css new file mode 100644 index 0000000..b079a83 --- /dev/null +++ b/frontend/src/styles/context-menu.css @@ -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; +} diff --git a/frontend/src/styles/z-index.css b/frontend/src/styles/z-index.css index 724b611..b9147af 100644 --- a/frontend/src/styles/z-index.css +++ b/frontend/src/styles/z-index.css @@ -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;