swipe、右键菜单
This commit is contained in:
173
REROLL_DEBUG_GUIDE.md
Normal file
173
REROLL_DEBUG_GUIDE.md
Normal file
@@ -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. ✅ 不影响页面其他部分
|
||||||
150
REROLL_IMPROVEMENT.md
Normal file
150
REROLL_IMPROVEMENT.md
Normal file
@@ -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`
|
||||||
78
SIDEBAR_CONTEXT_MENU.md
Normal file
78
SIDEBAR_CONTEXT_MENU.md
Normal file
@@ -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. 可以添加菜单项的图标自定义功能
|
||||||
105
SWIPE_BUTTON_FIX.md
Normal file
105
SWIPE_BUTTON_FIX.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Swipe 切换功能优化
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
之前的实现中,swipe 切换按钮只在**最新消息**上显示,这导致用户无法查看和切换历史消息的不同版本。
|
||||||
|
|
||||||
|
## SillyTavern 的设计
|
||||||
|
|
||||||
|
在 SillyTavern 中,**所有包含多个版本的 AI 消息**都会显示 swipe 切换按钮,用户可以随时查看和切换任何消息的不同版本。
|
||||||
|
|
||||||
|
## 修复内容
|
||||||
|
|
||||||
|
### 1. **移除 isLatestMessage 限制**
|
||||||
|
|
||||||
|
**修改前:**
|
||||||
|
```jsx
|
||||||
|
{hasSwipes && !isUser && isLatestMessage && (
|
||||||
|
<div className="swipe-controls">
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后:**
|
||||||
|
```jsx
|
||||||
|
{/* ✅ Swipe 控制按钮 - 所有有 swipe 的 AI 消息都显示 */}
|
||||||
|
{hasSwipes && !isUser && (
|
||||||
|
<div className="swipe-controls">
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 版本的功能
|
||||||
218
SWIPE_REROLL_COMPARISON.md
Normal file
218
SWIPE_REROLL_COMPARISON.md
Normal file
@@ -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 && (
|
||||||
|
<div className="swipe-controls">
|
||||||
|
<button onClick={() => handleSwipeChange(message.floor, -1)}>◀</button>
|
||||||
|
<span>{currentSwipeIndex + 1}/{message.swipes.length}</span>
|
||||||
|
<button onClick={() => handleSwipeChange(message.floor, 1)}>▶</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
✅ **符合 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 && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleRerollMessage(contextMenu.message)}>
|
||||||
|
<span className="menu-icon">🔄</span>
|
||||||
|
<span className="menu-label">重roll</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
✅ **符合 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 的设计规范。
|
||||||
@@ -49,7 +49,9 @@ async def websocket_chat_endpoint(
|
|||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
# 1. 接收前端消息
|
# 1. 接收前端消息
|
||||||
|
print(f"[WebSocket] ⏳ 等待接收消息...")
|
||||||
data = await websocket.receive_text()
|
data = await websocket.receive_text()
|
||||||
|
print(f"[WebSocket] ✅ 收到消息,长度: {len(data)}")
|
||||||
request_data = json.loads(data)
|
request_data = json.loads(data)
|
||||||
|
|
||||||
# ✅ 检查是否是取消任务的请求
|
# ✅ 检查是否是取消任务的请求
|
||||||
|
|||||||
@@ -199,7 +199,6 @@ class ChatService:
|
|||||||
tags = []
|
tags = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from backend.core.config import settings
|
|
||||||
character_file = settings.CHARACTERS_PATH / role_name / "character.json"
|
character_file = settings.CHARACTERS_PATH / role_name / "character.json"
|
||||||
if character_file.exists():
|
if character_file.exists():
|
||||||
with open(character_file, 'r', encoding='utf-8') as f:
|
with open(character_file, 'r', encoding='utf-8') as f:
|
||||||
|
|||||||
5
data/token_usage/2026/05.jsonl
Normal file
5
data/token_usage/2026/05.jsonl
Normal file
@@ -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"}
|
||||||
10
data/token_usage/indexes/api_urls.json
Normal file
10
data/token_usage/indexes/api_urls.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"https://api.deepseek.com/v1": {
|
||||||
|
"totalPromptTokens": 2060,
|
||||||
|
"totalCompletionTokens": 519,
|
||||||
|
"totalTokens": 2579,
|
||||||
|
"count": 5,
|
||||||
|
"firstUsed": 1777984056,
|
||||||
|
"lastUsed": 1777987419
|
||||||
|
}
|
||||||
|
}
|
||||||
8
data/token_usage/indexes/daily/2026-05.json
Normal file
8
data/token_usage/indexes/daily/2026-05.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"2026-05-05": {
|
||||||
|
"promptTokens": 2060,
|
||||||
|
"completionTokens": 519,
|
||||||
|
"totalTokens": 2579,
|
||||||
|
"count": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,7 +128,7 @@ const useChatBoxStore = create(
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 发送消息
|
// 发送消息
|
||||||
sendMessage: async (content) => {
|
sendMessage: async (content, targetFloor = null) => {
|
||||||
const { messages, userName, characterName, currentRole, currentChat, options, wsConnection } = get();
|
const { messages, userName, characterName, currentRole, currentChat, options, wsConnection } = get();
|
||||||
|
|
||||||
// ✅ 如果启用了自动掷骰子,处理内容
|
// ✅ 如果启用了自动掷骰子,处理内容
|
||||||
@@ -194,15 +194,22 @@ const useChatBoxStore = create(
|
|||||||
wsConnection.close();
|
wsConnection.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算下一个楼层号
|
// ✅ 判断是重roll还是新消息(现在重roll也创建新消息)
|
||||||
const nextFloor = get().getNextFloor(messages);
|
const isReroll = targetFloor !== null;
|
||||||
|
|
||||||
|
let userFloor, assistantFloor, nextFloor;
|
||||||
|
|
||||||
|
// ✅ 重roll模式也创建新消息,而不是更新现有消息
|
||||||
|
nextFloor = get().getNextFloor(messages);
|
||||||
|
userFloor = nextFloor;
|
||||||
|
assistantFloor = nextFloor + 1;
|
||||||
|
|
||||||
set({
|
set({
|
||||||
isGenerating: true,
|
isGenerating: true,
|
||||||
wsConnection: null, // 重置连接
|
wsConnection: null, // 重置连接
|
||||||
messages: [...messages, {
|
messages: [...messages, {
|
||||||
id: Date.now(),
|
id: `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, // ✅ 使用唯一ID
|
||||||
floor: nextFloor,
|
floor: userFloor,
|
||||||
mes: processedContent, // 使用处理后的内容
|
mes: processedContent, // 使用处理后的内容
|
||||||
is_user: true,
|
is_user: true,
|
||||||
name: userName || 'User',
|
name: userName || 'User',
|
||||||
@@ -235,9 +242,11 @@ const useChatBoxStore = create(
|
|||||||
// 保存WebSocket连接到store
|
// 保存WebSocket连接到store
|
||||||
set({ wsConnection: ws });
|
set({ wsConnection: ws });
|
||||||
|
|
||||||
// 添加一个空的助手消息,稍后会更新
|
// ✅ 根据模式处理 AI 消息
|
||||||
const newMessageId = Date.now();
|
let newMessageId;
|
||||||
const assistantFloor = nextFloor + 1; // 助手消息的楼层是用户消息楼层+1
|
|
||||||
|
// ✅ 正常模式:添加一个空的助手消息,稍后会更新
|
||||||
|
newMessageId = `ai_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // ✅ 使用唯一ID
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messages: [...state.messages, {
|
messages: [...state.messages, {
|
||||||
id: newMessageId,
|
id: newMessageId,
|
||||||
@@ -246,7 +255,8 @@ const useChatBoxStore = create(
|
|||||||
is_user: false,
|
is_user: false,
|
||||||
name: characterName || 'Assistant',
|
name: characterName || 'Assistant',
|
||||||
sendDate: new Date().toISOString()
|
sendDate: new Date().toISOString()
|
||||||
}]
|
}],
|
||||||
|
isGenerating: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let assistantMessage = '';
|
let assistantMessage = '';
|
||||||
@@ -284,6 +294,8 @@ const useChatBoxStore = create(
|
|||||||
|
|
||||||
// 处理流式数据块
|
// 处理流式数据块
|
||||||
assistantMessage += data.content;
|
assistantMessage += data.content;
|
||||||
|
|
||||||
|
// ✅ 更新新创建的消息
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messages: state.messages.map((msg) =>
|
messages: state.messages.map((msg) =>
|
||||||
msg.id === newMessageId ? { ...msg, mes: assistantMessage } : msg
|
msg.id === newMessageId ? { ...msg, mes: assistantMessage } : msg
|
||||||
@@ -345,6 +357,7 @@ const useChatBoxStore = create(
|
|||||||
console.log('\n[WebSocket] ✅ 收到完成信号');
|
console.log('\n[WebSocket] ✅ 收到完成信号');
|
||||||
console.log(' - 总 Chunks:', chunkCount);
|
console.log(' - 总 Chunks:', chunkCount);
|
||||||
console.log(' - 消息长度:', assistantMessage.length);
|
console.log(' - 消息长度:', assistantMessage.length);
|
||||||
|
|
||||||
// 完成响应
|
// 完成响应
|
||||||
isStreamComplete = true;
|
isStreamComplete = true;
|
||||||
ws.close();
|
ws.close();
|
||||||
@@ -382,7 +395,10 @@ const useChatBoxStore = create(
|
|||||||
|
|
||||||
// 发送请求到WebSocket(确保连接已建立)
|
// 发送请求到WebSocket(确保连接已建立)
|
||||||
const sendAfterConnect = () => {
|
const sendAfterConnect = () => {
|
||||||
|
console.log('[WebSocket] 📤 sendAfterConnect 被调用, readyState:', ws.readyState);
|
||||||
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
console.log('[WebSocket] ✅ 连接已打开,准备发送消息');
|
||||||
// ✅ 获取 API 配置
|
// ✅ 获取 API 配置
|
||||||
const apiConfigData = {
|
const apiConfigData = {
|
||||||
api_url: apiConfigStore.currentProfile?.apis?.mainLLM?.apiUrl || '',
|
api_url: apiConfigStore.currentProfile?.apis?.mainLLM?.apiUrl || '',
|
||||||
@@ -393,6 +409,7 @@ const useChatBoxStore = create(
|
|||||||
console.log('\n' + '-'.repeat(80));
|
console.log('\n' + '-'.repeat(80));
|
||||||
console.log('[WebSocket] 📤 发送消息:');
|
console.log('[WebSocket] 📤 发送消息:');
|
||||||
console.log(' - Floor:', nextFloor);
|
console.log(' - Floor:', nextFloor);
|
||||||
|
console.log(' - Mode:', isReroll ? '🔄 Reroll (新消息)' : '➕ New Message');
|
||||||
console.log(' - Role:', currentRole);
|
console.log(' - Role:', currentRole);
|
||||||
console.log(' - Chat:', actualChat);
|
console.log(' - Chat:', actualChat);
|
||||||
console.log(' - Stream:', options.streamOutput);
|
console.log(' - Stream:', options.streamOutput);
|
||||||
@@ -401,8 +418,9 @@ const useChatBoxStore = create(
|
|||||||
console.log(' - Current Profile:', apiConfigStore.currentProfile);
|
console.log(' - Current Profile:', apiConfigStore.currentProfile);
|
||||||
console.log('-'.repeat(80) + '\n');
|
console.log('-'.repeat(80) + '\n');
|
||||||
|
|
||||||
ws.send(JSON.stringify({
|
// ✅ 实际发送消息
|
||||||
floor: nextFloor,
|
const messageData = JSON.stringify({
|
||||||
|
floor: nextFloor, // ✅ 总是使用 nextFloor,因为重roll也创建新消息
|
||||||
mes: processedContent, // 使用处理后的内容
|
mes: processedContent, // 使用处理后的内容
|
||||||
is_user: true,
|
is_user: true,
|
||||||
currentRole: currentRole,
|
currentRole: currentRole,
|
||||||
@@ -452,7 +470,11 @@ const useChatBoxStore = create(
|
|||||||
// ✅ 时间戳(用于冲突解决)
|
// ✅ 时间戳(用于冲突解决)
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
stream: options.streamOutput
|
stream: options.streamOutput
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
console.log('[WebSocket] 📤 正在发送消息,数据长度:', messageData.length);
|
||||||
|
ws.send(messageData);
|
||||||
|
console.log('[WebSocket] ✅ 消息已发送');
|
||||||
} else if (ws.readyState === WebSocket.CONNECTING) {
|
} else if (ws.readyState === WebSocket.CONNECTING) {
|
||||||
// 如果正在连接,继续等待
|
// 如果正在连接,继续等待
|
||||||
console.log('[WebSocket] 等待连接...', { readyState: ws.readyState });
|
console.log('[WebSocket] 等待连接...', { readyState: ws.readyState });
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
|
|
||||||
.message-content {
|
.message-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
min-height: calc(1lh + var(--spacing-xs) * 2); /* ✅ 确保编辑时保持高度 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble {
|
.bubble {
|
||||||
@@ -171,22 +172,24 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: calc(1lh + var(--spacing-xs) * 2); /* ✅ 至少保持一行文本的高度 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-textarea {
|
.edit-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: auto; /* 自适应内容高度 */
|
min-height: calc(1lh + var(--spacing-xs) * 2); /* ✅ 与 bubble 的 padding 保持一致 */
|
||||||
max-height: none; /* 不限制最大高度 */
|
max-height: none; /* 不限制最大高度 */
|
||||||
padding: var(--spacing-xs) 0; /* 与 bubble 保持一致 */
|
padding: var(--spacing-xs) 0; /* 与 bubble 保持一致 */
|
||||||
border: none; /* 去掉边框 */
|
border: 2px solid var(--color-accent); /* ✅ 添加边框提示编辑状态 */
|
||||||
border-radius: 0;
|
border-radius: var(--radius-sm);
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
background-color: transparent; /* 透明背景 */
|
background-color: rgba(102, 126, 234, 0.05); /* ✅ 轻微背景色区分编辑状态 */
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
box-sizing: border-box; /* ✅ 确保宽度计算正确 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-textarea:focus {
|
.edit-textarea:focus {
|
||||||
@@ -234,33 +237,65 @@
|
|||||||
.swipe-controls {
|
.swipe-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--spacing-sm);
|
||||||
margin-top: 10px;
|
margin-top: var(--spacing-md);
|
||||||
justify-content: center;
|
justify-content: space-between; /* ✅ 左右分布 */
|
||||||
|
padding: 0 var(--spacing-xs); /* ✅ 减少内边距 */
|
||||||
|
width: 100%; /* ✅ 占满宽度 */
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swipe-button {
|
.swipe-button {
|
||||||
background: none;
|
background: rgba(255, 255, 255, 0.15); /* ✅ 半透明背景 */
|
||||||
border: 1px solid #ddd;
|
backdrop-filter: blur(8px); /* ✅ 毛玻璃效果 */
|
||||||
border-radius: 4px;
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 8px;
|
padding: 6px 12px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.9rem;
|
||||||
transition: background-color 0.2s;
|
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) {
|
.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 {
|
.swipe-button:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.3;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.swipe-counter {
|
.swipe-counter {
|
||||||
font-size: 0.8rem;
|
font-size: 0.85rem;
|
||||||
color: #666;
|
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 ==================== */
|
/* ==================== Input Area - Fixed at Bottom ==================== */
|
||||||
@@ -721,3 +756,5 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ✅ 右键菜单样式已移至全局 context-menu.css */
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ const ChatBox = () => {
|
|||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const optionsRef = useRef(null);
|
const optionsRef = useRef(null);
|
||||||
const chatSelectorRef = 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 状态
|
// ✅ 从 ChatBoxUIStore 获取 UI 状态
|
||||||
const {
|
const {
|
||||||
@@ -86,6 +97,26 @@ const ChatBox = () => {
|
|||||||
};
|
};
|
||||||
}, [showChatSelector]);
|
}, [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 handleInputHeight = (e) => {
|
||||||
const textarea = e.target;
|
const textarea = e.target;
|
||||||
|
|
||||||
@@ -114,15 +145,105 @@ const ChatBox = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 自动滚动到底部
|
// 自动滚动到底部(智能滚动)
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = (force = false) => {
|
||||||
|
// 只有在用户在底部或强制滚动时才执行
|
||||||
|
if (isUserAtBottomRef.current || force) {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
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(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages]);
|
}, [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) => {
|
const handleEdit = (message) => {
|
||||||
startEditing(message.floor, message.mes); // ✅ 使用 store 方法
|
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 = () => {
|
const toggleOptionsPanel = () => {
|
||||||
toggleOptions(); // ✅ 使用 store 方法
|
toggleOptions(); // ✅ 使用 store 方法
|
||||||
@@ -268,28 +492,16 @@ const ChatBox = () => {
|
|||||||
const uniqueKey = message.id || `${message.floor}-${index}`;
|
const uniqueKey = message.id || `${message.floor}-${index}`;
|
||||||
|
|
||||||
return (
|
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-container">
|
||||||
<div className="message-header">
|
<div className="message-header">
|
||||||
<span className="message-name">{displayName}</span>
|
<span className="message-name">{displayName}</span>
|
||||||
<span className="message-id">#{message.floor}</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>
|
||||||
<div className="message-content">
|
<div className="message-content">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
@@ -339,22 +551,25 @@ const ChatBox = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="plain-text">{currentMes}</div>
|
<div className="plain-text">{currentMes}</div>
|
||||||
)}
|
)}
|
||||||
{hasSwipes && !isUser && isLatestMessage && (
|
{/* ✅ Swipe 控制按钮 - 所有有 swipe 的 AI 消息都显示 */}
|
||||||
|
{hasSwipes && !isUser && (
|
||||||
<div className="swipe-controls">
|
<div className="swipe-controls">
|
||||||
<button
|
<button
|
||||||
className="swipe-button"
|
className="swipe-button"
|
||||||
onClick={() => handleSwipeChange(message.floor, -1)}
|
onClick={() => handleSwipeChange(message.floor, -1)}
|
||||||
disabled={currentSwipeIndex === 0}
|
disabled={currentSwipeIndex === 0}
|
||||||
|
title="上一个版本"
|
||||||
>
|
>
|
||||||
◀
|
◀
|
||||||
</button>
|
</button>
|
||||||
<span className="swipe-counter">
|
<span className="swipe-counter" title={`共 ${message.swipes.length} 个版本`}>
|
||||||
{currentSwipeIndex + 1}/{message.swipes.length}
|
{currentSwipeIndex + 1}/{message.swipes.length}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="swipe-button"
|
className="swipe-button"
|
||||||
onClick={() => handleSwipeChange(message.floor, 1)}
|
onClick={() => handleSwipeChange(message.floor, 1)}
|
||||||
disabled={currentSwipeIndex === message.swipes.length - 1}
|
disabled={currentSwipeIndex === message.swipes.length - 1}
|
||||||
|
title="下一个版本"
|
||||||
>
|
>
|
||||||
▶
|
▶
|
||||||
</button>
|
</button>
|
||||||
@@ -370,7 +585,7 @@ const ChatBox = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-box">
|
<div className="chat-box">
|
||||||
<div className="chat-messages">
|
<div className="chat-messages" ref={messagesContainerRef}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="loading">加载中...</div>
|
<div className="loading">加载中...</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
@@ -548,6 +763,45 @@ const ChatBox = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -94,3 +94,29 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.5;
|
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
|
// frontend-react/src/components/SideBarLeft/SideBarLeft.jsx
|
||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import './SideBarLeft.css';
|
import './SideBarLeft.css';
|
||||||
import { useSideBarLeftStore } from '../../Store/indexStore';
|
import { useSideBarLeftStore } from '../../Store/indexStore';
|
||||||
import usePresetStore from '../../Store/SideBarLeft/PresetSlice';
|
import usePresetStore from '../../Store/SideBarLeft/PresetSlice';
|
||||||
@@ -15,6 +15,15 @@ const SideBarLeft = () => {
|
|||||||
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
|
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
|
||||||
const { fetchPresets } = usePresetStore();
|
const { fetchPresets } = usePresetStore();
|
||||||
const { fetchProfiles } = useApiConfigStore();
|
const { fetchProfiles } = useApiConfigStore();
|
||||||
|
const contextMenuRef = useRef(null);
|
||||||
|
|
||||||
|
// ✅ 右键菜单状态
|
||||||
|
const [contextMenu, setContextMenu] = React.useState({
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
tab: null
|
||||||
|
});
|
||||||
|
|
||||||
// 处理标签切换
|
// 处理标签切换
|
||||||
const handleTabClick = (tabId) => {
|
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 (
|
return (
|
||||||
<div className="sidebar-left">
|
<div className="sidebar-left">
|
||||||
<div className="sidebar-tabs">
|
<div className="sidebar-tabs">
|
||||||
@@ -39,7 +70,8 @@ const SideBarLeft = () => {
|
|||||||
key={tab.id}
|
key={tab.id}
|
||||||
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
|
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
|
||||||
onClick={() => handleTabClick(tab.id)}
|
onClick={() => handleTabClick(tab.id)}
|
||||||
title={tab.title}
|
onContextMenu={(e) => handleContextMenu(e, tab)} // ✅ 添加右键菜单
|
||||||
|
// ✅ 移除 title,改用右键菜单显示详情
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -54,6 +86,67 @@ const SideBarLeft = () => {
|
|||||||
{activeTab === 'worldbook' && <WorldBook />}
|
{activeTab === 'worldbook' && <WorldBook />}
|
||||||
{activeTab === 'tokenUsage' && <TokenUsage />}
|
{activeTab === 'tokenUsage' && <TokenUsage />}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import React, { useState, useEffect, useRef, useCallback, memo } from 'react'; /
|
|||||||
import { useCharacterStore, useCharacterCardUIStore } from '../../../../Store/SideBarLeft'; // ✅ 新增
|
import { useCharacterStore, useCharacterCardUIStore } from '../../../../Store/SideBarLeft'; // ✅ 新增
|
||||||
import useChatBoxStore from '../../../../Store/Mid/ChatBoxSlice';
|
import useChatBoxStore from '../../../../Store/Mid/ChatBoxSlice';
|
||||||
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice'; // 引入世界书 Store
|
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice'; // 引入世界书 Store
|
||||||
|
import useSideBarLeftStore from '../../../../Store/SideBarLeft/SideBarLeftSlice'; // ✅ 引入侧边栏 Store
|
||||||
import './CharacterCard.css';
|
import './CharacterCard.css';
|
||||||
|
|
||||||
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
|
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
|
||||||
@@ -236,6 +237,12 @@ const CharacterCard = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteCharacter(name);
|
await deleteCharacter(name);
|
||||||
|
|
||||||
|
// ✅ 删除成功后自动切换到角色分页
|
||||||
|
const { setActiveTab } = useSideBarLeftStore.getState();
|
||||||
|
setActiveTab('character');
|
||||||
|
|
||||||
|
console.log('[CharacterCard] 角色已删除,已切换到角色分页');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('删除失败:', err);
|
console.error('删除失败:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@import './styles/variables.css';
|
@import './styles/variables.css';
|
||||||
@import './styles/reset.css';
|
@import './styles/reset.css';
|
||||||
@import './styles/z-index.css'; /* ✅ Z-Index 层级规范 */
|
@import './styles/z-index.css'; /* ✅ Z-Index 层级规范 */
|
||||||
|
@import './styles/context-menu.css'; /* ✅ 右键菜单通用样式 */
|
||||||
|
|
||||||
/* ==================== Main Layout ==================== */
|
/* ==================== Main Layout ==================== */
|
||||||
.app {
|
.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-tooltip: 1200;
|
||||||
--z-chat-actions: 1000;
|
--z-chat-actions: 1000;
|
||||||
--z-character-preview: 1000;
|
--z-character-preview: 1000;
|
||||||
|
--z-context-menu: 1500; /* ✅ 右键菜单 */
|
||||||
|
|
||||||
/* ==================== 弹窗层 (10000-19999) ==================== */
|
/* ==================== 弹窗层 (10000-19999) ==================== */
|
||||||
--z-modal-overlay: 10000;
|
--z-modal-overlay: 10000;
|
||||||
|
|||||||
Reference in New Issue
Block a user